diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 4b73e69..db8fad9 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/assets/convert.js b/app/assets/convert.js index 86e8bd6..c7195f2 100644 --- a/app/assets/convert.js +++ b/app/assets/convert.js @@ -1 +1 @@ -import { d as A, o as H, a as n, c as o, b as j, B as z, e as s, F as w, r as F, t as r, f as _, n as E, w as K, u as M, x as q, g as u, h as T, y as J, i as k, E as i, j as L, z as O, A as G, C as Q, _ as W, q as X, s as Y } from "./pywebview-DdV1XQSM.js"; const Z = { class: "page-shell module-page" }, ee = { class: "main-content" }, se = { class: "left-panel" }, te = { class: "upload-zone" }, ae = { class: "selected-files clean-placeholder" }, le = { key: 0, class: "more-line" }, ne = { key: 1 }, oe = { class: "option-group" }, ie = ["value"], re = { class: "template-item-content" }, ce = { class: "label" }, ue = { key: 0, class: "template-tag" }, de = { key: 1, class: "template-tag muted" }, ve = { key: 2, class: "template-tag custom" }, pe = { class: "desc" }, _e = { key: 0, class: "empty-option" }, fe = { class: "option-group" }, me = { class: "radio-item active" }, he = { class: "desc" }, ye = { class: "run-row" }, ge = ["disabled"], be = { class: "loading-msg" }, we = { class: "right-panel" }, ke = { class: "task-list-wrap" }, Ce = { class: "clean-result-summary" }, xe = { class: "summary-card" }, Fe = { class: "summary-card" }, Pe = { class: "summary-card" }, Ee = { class: "result-list-wrap" }, Te = { key: 0, class: "empty-tasks" }, Ie = { key: 1, class: "task-list clean-result-list" }, Be = { class: "left" }, Ne = ["title"], $e = { key: 0, class: "files" }, De = { key: 1, class: "files" }, Re = { class: "task-right" }, Se = ["onClick"], Ue = ["onClick"], Ve = A({ __name: "BrandConvertTab", setup(He) { const v = u([]), g = u(""), f = u([]), m = u(!1), b = u([]), h = u({ total: 0, successCount: 0, failedCount: 0, items: [] }), d = u([]), c = u(""), C = T(() => v.value.slice(0, 8)), I = T(() => d.value.find(a => a.id === c.value) || null); async function P(a) { const e = k(); if (!e?.upload_file_to_java) throw new Error("当前桌面端未提供文件上传桥接能力"); const t = []; for (const l of a) { const p = typeof l == "string" ? l : l.absolutePath, V = typeof l == "string" ? void 0 : l.relativePath, y = await e.upload_file_to_java(p, V); if (!y?.success || !y.data) throw new Error(y?.error || y?.message || `上传失败:${p}`); t.push(y.data) } return t } async function B(a) { const e = await J(); d.value = e || []; const t = d.value.find(p => p.id === c.value); if (t) { c.value = t.id; return } const l = d.value.find(p => p.isDefault); if (l) { c.value = l.id; return } c.value = d.value[0]?.id || "" } async function N(a, e) { v.value = a, g.value = "", f.value = await P(a), i.success(e) } async function $() { const a = k(); if (!a?.select_brand_xlsx_files) { i.warning("当前环境不支持文件选择,请在本机客户端中打开"); return } try { const e = await a.select_brand_xlsx_files(); if (!e?.length) return; await N(e, `已选择 ${e.length} 个待转换文件`) } catch (e) { i.error(e instanceof Error ? e.message : "选择失败") } } async function D() { const a = k(); if (!a?.select_brand_folder) { i.warning("当前环境不支持文件夹选择,请在本机客户端中打开"); return } try { const e = await a.select_brand_folder(); if (!e) return; g.value = e.split(/[/\\]/).filter(Boolean).pop() || ""; const t = await L(e); if (t.success && t.items?.length) { v.value = t.items.map(l => l.relativePath), f.value = await P(t.items), i.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`); return } i.warning(t.error || "该文件夹下没有 xlsx 文件") } catch (e) { i.error(e instanceof Error ? e.message : "选择失败") } } async function R() { if (!f.value.length) { i.warning("请先选择待转换 Excel 文件或文件夹"); return } if (!c.value) { i.warning("请先选择模板"); return } try { m.value = !0; const a = await O({ files: f.value.map(e => ({ fileKey: e.fileKey, originalFilename: e.originalFilename, relativePath: e.relativePath })), templateId: c.value, archiveName: f.value.some(e => !!e.relativePath) && g.value ? g.value : void 0 }); h.value = a, b.value = a.items || [], await x(), i.success("格式转换完成") } catch (a) { i.error(a instanceof Error ? a.message : "格式转换失败") } finally { m.value = !1 } } async function x() { try { const a = await G(); b.value = a.items || [], h.value = { total: a.items?.length || 0, successCount: a.items?.filter(e => e.success).length || 0, failedCount: a.items?.filter(e => !e.success).length || 0, items: a.items || [] } } catch { } } async function S(a) { try { await Q(a), await x(), i.success("已删除") } catch (e) { i.error(e instanceof Error ? e.message : "删除失败") } } async function U(a) { const e = k(); if (!a.downloadUrl) { i.warning("当前结果没有下载地址"); return } const t = a.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, "")}.txt`; if (e?.save_file_from_url) { const l = await e.save_file_from_url(a.downloadUrl, t); l.success ? i.success(`已保存:${l.path || t}`) : l.error && l.error !== "用户取消" && i.error(l.error); return } window.open(a.downloadUrl, "_blank") } return H(() => { B().catch(() => { }), x().catch(() => { }) }), (a, e) => (n(), o("div", Z, [j(z, { active: "convert" }), s("div", ee, [s("aside", se, [e[5] || (e[5] = s("div", { class: "section-title" }, "选择文件", -1)), s("div", te, [e[1] || (e[1] = s("div", { class: "hint" }, "选择需要转换的 Excel 文件或文件夹,将按所选模板生成 txt 文件。", -1)), s("div", { class: "btns" }, [s("button", { type: "button", class: "opt-btn", onClick: $ }, "选择 Excel 文件"), s("button", { type: "button", class: "opt-btn", onClick: D }, "选择文件夹")]), s("div", ae, [v.value.length ? (n(), o(w, { key: 0 }, [(n(!0), o(w, null, F(C.value, t => (n(), o("span", { key: t }, r(t), 1))), 128)), v.value.length > C.value.length ? (n(), o("span", le, " 还有 " + r(v.value.length - C.value.length) + " 个文件未展开显示 ", 1)) : _("", !0)], 64)) : (n(), o("span", ne, "暂未选择待转换文件"))])]), e[6] || (e[6] = s("div", { class: "section-title" }, "模板选择", -1)), s("div", oe, [(n(!0), o(w, null, F(d.value, t => (n(), o("label", { key: t.id, class: E(["radio-item", { active: c.value === t.id }]) }, [K(s("input", { "onUpdate:modelValue": e[0] || (e[0] = l => c.value = l), type: "radio", value: t.id, name: "convert-template" }, null, 8, ie), [[M, c.value]]), s("div", re, [s("div", null, [s("span", ce, [q(r(t.templateName) + " ", 1), t.isDefault ? (n(), o("span", ue, "默认")) : t.builtIn ? (n(), o("span", de, "内置")) : (n(), o("span", ve, "自定义"))]), s("div", pe, "输出文件:" + r(t.outputFilename), 1)])])], 2))), 128)), d.value.length ? _("", !0) : (n(), o("div", _e, "暂无可用模板"))]), e[7] || (e[7] = s("div", { class: "section-title" }, "转换说明", -1)), s("div", fe, [s("label", me, [e[3] || (e[3] = s("input", { type: "checkbox", checked: "", disabled: "" }, null, -1)), s("div", null, [e[2] || (e[2] = s("span", { class: "label" }, "按当前模板输出", -1)), s("div", he, "当前模板:" + r(I.value?.templateName || "未加载模板"), 1)])]), e[4] || (e[4] = s("label", { class: "radio-item active" }, [s("input", { type: "checkbox", checked: "", disabled: "" }), s("div", null, [s("span", { class: "label" }, "SKU 使用雪花ID-序号"), s("div", { class: "desc" }, "例如 1773816146796-1")])], -1))]), s("div", ye, [s("button", { type: "button", class: "btn-run", disabled: m.value, onClick: R }, r(m.value ? "转换中..." : "开始转换"), 9, ge), s("span", be, r(m.value ? "正在生成并上传结果,请稍候…" : "选择文件后即可执行格式转换,结果会显示在右侧供下载"), 1)])]), s("section", we, [e[12] || (e[12] = s("div", { class: "panel-header" }, "转换结果", -1)), s("div", ke, [s("div", Ce, [s("div", xe, [e[8] || (e[8] = s("span", { class: "summary-label" }, "已处理文件", -1)), s("strong", null, r(h.value.total), 1)]), s("div", Fe, [e[9] || (e[9] = s("span", { class: "summary-label" }, "成功结果", -1)), s("strong", null, r(h.value.successCount), 1)]), s("div", Pe, [e[10] || (e[10] = s("span", { class: "summary-label" }, "失败文件", -1)), s("strong", null, r(h.value.failedCount), 1)])]), s("div", Ee, [e[11] || (e[11] = s("div", { class: "result-list-header" }, [s("span", null, "生成的结果压缩包列表")], -1)), b.value.length === 0 ? (n(), o("div", Te, " 暂无转换结果,完成格式转换后会在这里展示结果压缩包 ")) : (n(), o("ul", Ie, [(n(!0), o(w, null, F(b.value, t => (n(), o("li", { key: `${t.resultId || t.outputFilename || t.sourceFilename}`, class: "task-item" }, [s("div", Be, [s("span", { class: "id", title: t.sourceFilename }, r(t.sourceFilename || "-"), 9, Ne), t.outputFilename ? (n(), o("div", $e, "压缩包文件:" + r(t.outputFilename), 1)) : _("", !0), t.error ? (n(), o("div", De, "错误信息:" + r(t.error), 1)) : _("", !0)]), s("div", Re, [s("span", { class: E(["status", t.success ? "success" : "failed"]) }, r(t.success ? "已完成" : "失败"), 3), t.success && t.downloadUrl ? (n(), o("button", { key: 0, type: "button", class: "download", onClick: l => U(t) }, " 下载压缩包 ", 8, Se)) : _("", !0), t.resultId ? (n(), o("button", { key: 1, type: "button", class: "btn-delete", onClick: l => S(t.resultId) }, " 删除 ", 8, Ue)) : _("", !0)])]))), 128))]))])])])])])) } }), Ae = W(Ve, [["__scopeId", "data-v-89690cc3"]]); X(Ae).use(Y).mount("#app"); +import{d as A,o as H,a as n,c as o,b as j,B as z,e as s,F as w,r as F,t as r,f as _,n as E,w as K,u as M,x as q,g as u,h as T,y as J,i as k,E as i,j as L,z as O,A as G,C as Q,_ as W,q as X,s as Y}from"./pywebview-p4QBqp2b.js";const Z={class:"page-shell module-page"},ee={class:"main-content"},se={class:"left-panel"},te={class:"upload-zone"},ae={class:"selected-files clean-placeholder"},le={key:0,class:"more-line"},ne={key:1},oe={class:"option-group"},ie=["value"],re={class:"template-item-content"},ce={class:"label"},ue={key:0,class:"template-tag"},de={key:1,class:"template-tag muted"},ve={key:2,class:"template-tag custom"},pe={class:"desc"},_e={key:0,class:"empty-option"},fe={class:"option-group"},me={class:"radio-item active"},he={class:"desc"},ye={class:"run-row"},ge=["disabled"],be={class:"loading-msg"},we={class:"right-panel"},ke={class:"task-list-wrap"},Ce={class:"clean-result-summary"},xe={class:"summary-card"},Fe={class:"summary-card"},Pe={class:"summary-card"},Ee={class:"result-list-wrap"},Te={key:0,class:"empty-tasks"},Ie={key:1,class:"task-list clean-result-list"},Be={class:"left"},Ne=["title"],$e={key:0,class:"files"},De={key:1,class:"files"},Re={class:"task-right"},Se=["onClick"],Ue=["onClick"],Ve=A({__name:"BrandConvertTab",setup(He){const v=u([]),g=u(""),f=u([]),m=u(!1),b=u([]),h=u({total:0,successCount:0,failedCount:0,items:[]}),d=u([]),c=u(""),C=T(()=>v.value.slice(0,8)),I=T(()=>d.value.find(a=>a.id===c.value)||null);async function P(a){const e=k();if(!e?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传桥接能力");const t=[];for(const l of a){const p=typeof l=="string"?l:l.absolutePath,V=typeof l=="string"?void 0:l.relativePath,y=await e.upload_file_to_java(p,V);if(!y?.success||!y.data)throw new Error(y?.error||y?.message||`上传失败:${p}`);t.push(y.data)}return t}async function B(a){const e=await J();d.value=e||[];const t=d.value.find(p=>p.id===c.value);if(t){c.value=t.id;return}const l=d.value.find(p=>p.isDefault);if(l){c.value=l.id;return}c.value=d.value[0]?.id||""}async function N(a,e){v.value=a,g.value="",f.value=await P(a),i.success(e)}async function $(){const a=k();if(!a?.select_brand_xlsx_files){i.warning("当前环境不支持文件选择,请在本机客户端中打开");return}try{const e=await a.select_brand_xlsx_files();if(!e?.length)return;await N(e,`已选择 ${e.length} 个待转换文件`)}catch(e){i.error(e instanceof Error?e.message:"选择失败")}}async function D(){const a=k();if(!a?.select_brand_folder){i.warning("当前环境不支持文件夹选择,请在本机客户端中打开");return}try{const e=await a.select_brand_folder();if(!e)return;g.value=e.split(/[/\\]/).filter(Boolean).pop()||"";const t=await L(e);if(t.success&&t.items?.length){v.value=t.items.map(l=>l.relativePath),f.value=await P(t.items),i.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`);return}i.warning(t.error||"该文件夹下没有 xlsx 文件")}catch(e){i.error(e instanceof Error?e.message:"选择失败")}}async function R(){if(!f.value.length){i.warning("请先选择待转换 Excel 文件或文件夹");return}if(!c.value){i.warning("请先选择模板");return}try{m.value=!0;const a=await O({files:f.value.map(e=>({fileKey:e.fileKey,originalFilename:e.originalFilename,relativePath:e.relativePath})),templateId:c.value,archiveName:f.value.some(e=>!!e.relativePath)&&g.value?g.value:void 0});h.value=a,b.value=a.items||[],await x(),i.success("格式转换完成")}catch(a){i.error(a instanceof Error?a.message:"格式转换失败")}finally{m.value=!1}}async function x(){try{const a=await G();b.value=a.items||[],h.value={total:a.items?.length||0,successCount:a.items?.filter(e=>e.success).length||0,failedCount:a.items?.filter(e=>!e.success).length||0,items:a.items||[]}}catch{}}async function S(a){try{await Q(a),await x(),i.success("已删除")}catch(e){i.error(e instanceof Error?e.message:"删除失败")}}async function U(a){const e=k();if(!a.downloadUrl){i.warning("当前结果没有下载地址");return}const t=a.outputFilename||`${new Date().toISOString().slice(0,10).replace(/-/g,"")}.txt`;if(e?.save_file_from_url){const l=await e.save_file_from_url(a.downloadUrl,t);l.success?i.success(`已保存:${l.path||t}`):l.error&&l.error!=="用户取消"&&i.error(l.error);return}window.open(a.downloadUrl,"_blank")}return H(()=>{B().catch(()=>{}),x().catch(()=>{})}),(a,e)=>(n(),o("div",Z,[j(z,{active:"convert"}),s("div",ee,[s("aside",se,[e[5]||(e[5]=s("div",{class:"section-title"},"选择文件",-1)),s("div",te,[e[1]||(e[1]=s("div",{class:"hint"},"选择需要转换的 Excel 文件或文件夹,将按所选模板生成 txt 文件。",-1)),s("div",{class:"btns"},[s("button",{type:"button",class:"opt-btn",onClick:$},"选择 Excel 文件"),s("button",{type:"button",class:"opt-btn",onClick:D},"选择文件夹")]),s("div",ae,[v.value.length?(n(),o(w,{key:0},[(n(!0),o(w,null,F(C.value,t=>(n(),o("span",{key:t},r(t),1))),128)),v.value.length>C.value.length?(n(),o("span",le," 还有 "+r(v.value.length-C.value.length)+" 个文件未展开显示 ",1)):_("",!0)],64)):(n(),o("span",ne,"暂未选择待转换文件"))])]),e[6]||(e[6]=s("div",{class:"section-title"},"模板选择",-1)),s("div",oe,[(n(!0),o(w,null,F(d.value,t=>(n(),o("label",{key:t.id,class:E(["radio-item",{active:c.value===t.id}])},[K(s("input",{"onUpdate:modelValue":e[0]||(e[0]=l=>c.value=l),type:"radio",value:t.id,name:"convert-template"},null,8,ie),[[M,c.value]]),s("div",re,[s("div",null,[s("span",ce,[q(r(t.templateName)+" ",1),t.isDefault?(n(),o("span",ue,"默认")):t.builtIn?(n(),o("span",de,"内置")):(n(),o("span",ve,"自定义"))]),s("div",pe,"输出文件:"+r(t.outputFilename),1)])])],2))),128)),d.value.length?_("",!0):(n(),o("div",_e,"暂无可用模板"))]),e[7]||(e[7]=s("div",{class:"section-title"},"转换说明",-1)),s("div",fe,[s("label",me,[e[3]||(e[3]=s("input",{type:"checkbox",checked:"",disabled:""},null,-1)),s("div",null,[e[2]||(e[2]=s("span",{class:"label"},"按当前模板输出",-1)),s("div",he,"当前模板:"+r(I.value?.templateName||"未加载模板"),1)])]),e[4]||(e[4]=s("label",{class:"radio-item active"},[s("input",{type:"checkbox",checked:"",disabled:""}),s("div",null,[s("span",{class:"label"},"SKU 使用雪花ID-序号"),s("div",{class:"desc"},"例如 1773816146796-1")])],-1))]),s("div",ye,[s("button",{type:"button",class:"btn-run",disabled:m.value,onClick:R},r(m.value?"转换中...":"开始转换"),9,ge),s("span",be,r(m.value?"正在生成并上传结果,请稍候…":"选择文件后即可执行格式转换,结果会显示在右侧供下载"),1)])]),s("section",we,[e[12]||(e[12]=s("div",{class:"panel-header"},"转换结果",-1)),s("div",ke,[s("div",Ce,[s("div",xe,[e[8]||(e[8]=s("span",{class:"summary-label"},"已处理文件",-1)),s("strong",null,r(h.value.total),1)]),s("div",Fe,[e[9]||(e[9]=s("span",{class:"summary-label"},"成功结果",-1)),s("strong",null,r(h.value.successCount),1)]),s("div",Pe,[e[10]||(e[10]=s("span",{class:"summary-label"},"失败文件",-1)),s("strong",null,r(h.value.failedCount),1)])]),s("div",Ee,[e[11]||(e[11]=s("div",{class:"result-list-header"},[s("span",null,"生成的结果压缩包列表")],-1)),b.value.length===0?(n(),o("div",Te," 暂无转换结果,完成格式转换后会在这里展示结果压缩包 ")):(n(),o("ul",Ie,[(n(!0),o(w,null,F(b.value,t=>(n(),o("li",{key:`${t.resultId||t.outputFilename||t.sourceFilename}`,class:"task-item"},[s("div",Be,[s("span",{class:"id",title:t.sourceFilename},r(t.sourceFilename||"-"),9,Ne),t.outputFilename?(n(),o("div",$e,"压缩包文件:"+r(t.outputFilename),1)):_("",!0),t.error?(n(),o("div",De,"错误信息:"+r(t.error),1)):_("",!0)]),s("div",Re,[s("span",{class:E(["status",t.success?"success":"failed"])},r(t.success?"已完成":"失败"),3),t.success&&t.downloadUrl?(n(),o("button",{key:0,type:"button",class:"download",onClick:l=>U(t)}," 下载压缩包 ",8,Se)):_("",!0),t.resultId?(n(),o("button",{key:1,type:"button",class:"btn-delete",onClick:l=>S(t.resultId)}," 删除 ",8,Ue)):_("",!0)])]))),128))]))])])])])]))}}),Ae=W(Ve,[["__scopeId","data-v-89690cc3"]]);X(Ae).use(Y).mount("#app"); diff --git a/app/assets/dedupe.js b/app/assets/dedupe.js index eeabceb..2206740 100644 --- a/app/assets/dedupe.js +++ b/app/assets/dedupe.js @@ -1 +1 @@ -import { d as z, o as W, a as n, c as o, b as q, B as J, e as s, F as C, r as F, t as i, f as b, n as I, w as P, v as B, g as u, h as L, i as x, E as r, j as O, k as G, l as Q, m as X, p as Y, _ as Z, q as ee, s as se } from "./pywebview-DdV1XQSM.js"; const te = { class: "page-shell module-page" }, le = { class: "main-content" }, ae = { class: "left-panel" }, ne = { class: "upload-zone" }, oe = { class: "selected-files clean-placeholder" }, re = { key: 0, class: "more-line" }, ie = { key: 1 }, ue = { class: "option-group column-option-group" }, ce = { class: "column-toolbar" }, de = { class: "column-count" }, ve = { key: 0, class: "column-grid" }, pe = ["value"], fe = { key: 1, class: "empty-column-state" }, _e = { class: "option-group" }, me = { class: "run-row" }, he = ["disabled"], ge = { class: "loading-msg" }, ye = { class: "right-panel" }, be = { class: "task-list-wrap" }, we = { class: "clean-result-summary" }, ke = { class: "summary-card" }, Ce = { class: "summary-card" }, Ie = { class: "summary-card" }, xe = { class: "result-list-wrap" }, Ee = { key: 0, class: "empty-tasks" }, De = { key: 1, class: "task-list clean-result-list" }, Fe = { class: "left" }, Pe = ["title"], Be = { key: 0, class: "files" }, Se = { key: 1, class: "files" }, Ue = { class: "task-right" }, Ne = ["onClick"], $e = ["onClick"], Ae = z({ __name: "BrandDedupeTab", setup(Ke) { const p = u([]), c = u([]), f = u([]), w = u(""), d = u([]), S = u(!1), _ = u(!0), m = u(!0), h = u(!1), k = u([]), g = u({ total: 0, successCount: 0, failedCount: 0, items: [] }), E = L(() => f.value.slice(0, 8)); function $() { c.value = [...p.value] } function A() { c.value = [] } async function U(l) { const e = x(); if (!e?.upload_file_to_java) throw new Error("当前桌面端未提供文件上传桥接能力"); const t = []; for (const a of l) { const v = typeof a == "string" ? a : a.absolutePath, j = typeof a == "string" ? void 0 : a.relativePath, y = await e.upload_file_to_java(v, j); if (!y?.success || !y.data) throw new Error(y?.error || y?.message || `上传失败:${v}`); t.push(y.data) } return t } async function N(l) { const e = await Y(l); if (!e.headers?.length) { r.error("读取 Excel 表头失败"), p.value = [], c.value = []; return } const t = ["id", "ASIN", "国家", "价格", "卖家名称"], a = [...t.filter(v => e.headers?.includes(v)), ...(e.headers || []).filter(v => !t.includes(v))]; p.value = a, c.value = t.filter(v => a.includes(v)) } async function H(l, e) { f.value = l, w.value = "", d.value = await U(l), d.value.length > 0 && await N(d.value[0].fileKey), r.success(e) } async function K() { const l = x(); if (!l?.select_brand_xlsx_files) { r.warning("当前环境不支持文件选择,请在本机客户端中打开"); return } try { const e = await l.select_brand_xlsx_files(); if (!e?.length) return; await H(e, `已选择 ${e.length} 个待清洗文件`) } catch (e) { r.error(e instanceof Error ? e.message : "选择失败") } } async function R() { const l = x(); if (!l?.select_brand_folder) { r.warning("当前环境不支持文件夹选择,请在本机客户端中打开"); return } try { const e = await l.select_brand_folder(); if (!e) return; w.value = e.split(/[/\\]/).filter(Boolean).pop() || ""; const t = await O(e); if (t.success && t.items?.length) { f.value = t.items.map(a => a.relativePath), d.value = await U(t.items), d.value.length > 0 && await N(d.value[0].fileKey), r.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`); return } r.warning(t.error || "该文件夹下没有 xlsx 文件") } catch (e) { r.error(e instanceof Error ? e.message : "选择失败") } } async function V() { if (!d.value.length) { r.warning("请先选择待清洗 Excel 文件或文件夹"); return } if (!c.value.length) { r.warning("请至少选择一列保留列"); return } if (!S.value && !_.value && !m.value) { r.warning("请至少选择一种 ID 保留规则"); return } try { h.value = !0; const l = await G({ files: d.value.map(e => ({ fileKey: e.fileKey, originalFilename: e.originalFilename, relativePath: e.relativePath })), selectedColumns: c.value, keepIntegerIds: S.value, keepUnderscoreIds: _.value, keepIntegerMainIdsWhenNoSubIds: m.value, archiveName: d.value.some(e => !!e.relativePath) && w.value ? w.value : void 0 }); g.value = l, k.value = l.items || [], await D(), r.success("数据去重完成") } catch (l) { r.error(l instanceof Error ? l.message : "去重失败") } finally { h.value = !1 } } async function D() { try { const l = await Q(); k.value = l.items || [], g.value = { total: l.items?.length || 0, successCount: l.items?.filter(e => e.success).length || 0, failedCount: l.items?.filter(e => !e.success).length || 0, items: l.items || [] } } catch { } } async function M(l) { try { await X(l), await D(), r.success("已删除") } catch (e) { r.error(e instanceof Error ? e.message : "删除失败") } } async function T(l) { const e = x(); if (!l.downloadUrl) { r.warning("当前结果没有下载地址"); return } const t = l.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, "")}_cleaned.xlsx`; if (e?.save_file_from_url) { const a = await e.save_file_from_url(l.downloadUrl, t); a.success ? r.success(`已保存:${a.path || t}`) : a.error && a.error !== "用户取消" && r.error(a.error); return } window.open(l.downloadUrl, "_blank") } return W(() => { D().catch(() => { }) }), (l, e) => (n(), o("div", te, [q(J, { active: "dedupe" }), s("div", le, [s("aside", ae, [e[7] || (e[7] = s("div", { class: "section-title" }, "选择文件", -1)), s("div", ne, [e[3] || (e[3] = s("div", { class: "hint" }, "选择需要清洗的 Excel 文件或文件夹,后续将按所选规则输出处理结果。", -1)), s("div", { class: "btns" }, [s("button", { type: "button", class: "opt-btn", onClick: K }, "选择 Excel 文件"), s("button", { type: "button", class: "opt-btn", onClick: R }, "选择文件夹")]), s("div", oe, [f.value.length ? (n(), o(C, { key: 0 }, [(n(!0), o(C, null, F(E.value, t => (n(), o("span", { key: t }, i(t), 1))), 128)), f.value.length > E.value.length ? (n(), o("span", re, " 还有 " + i(f.value.length - E.value.length) + " 个文件未展开显示 ", 1)) : b("", !0)], 64)) : (n(), o("span", ie, "暂未选择待清洗文件"))])]), e[8] || (e[8] = s("div", { class: "section-title" }, "保留列设置", -1)), s("div", ue, [s("div", ce, [s("span", de, "已选择 " + i(c.value.length) + " / " + i(p.value.length) + " 列", 1), s("div", { class: "column-actions" }, [s("button", { type: "button", class: "opt-btn small", onClick: $ }, "全选"), s("button", { type: "button", class: "opt-btn small", onClick: A }, "清空")])]), p.value.length ? (n(), o("div", ve, [(n(!0), o(C, null, F(p.value, t => (n(), o("label", { key: t, class: I(["column-item", { active: c.value.includes(t) }]) }, [P(s("input", { "onUpdate:modelValue": e[0] || (e[0] = a => c.value = a), type: "checkbox", value: t }, null, 8, pe), [[B, c.value]]), s("span", null, i(t), 1)], 2))), 128))])) : (n(), o("div", fe, "请选择 Excel 文件后读取表头并填充可保留列")), e[4] || (e[4] = s("div", { class: "desc" }, "读取首个 Excel 文件的第一行作为表头,用于动态选择需要保留的列。", -1))]), e[9] || (e[9] = s("div", { class: "section-title" }, "ID 保留规则", -1)), s("div", _e, [s("label", { class: I(["radio-item", { active: _.value }]) }, [P(s("input", { "onUpdate:modelValue": e[1] || (e[1] = t => _.value = t), type: "checkbox" }, null, 512), [[B, _.value]]), e[5] || (e[5] = s("div", null, [s("span", { class: "label" }, "保留子链接"), s("div", { class: "desc" }, "例如 1_1、2_3 这种带下划线的子项 ID")], -1))], 2), s("label", { class: I(["radio-item", { active: m.value }]) }, [P(s("input", { "onUpdate:modelValue": e[2] || (e[2] = t => m.value = t), type: "checkbox" }, null, 512), [[B, m.value]]), e[6] || (e[6] = s("div", null, [s("span", { class: "label" }, "仅剩主链接无子链接"), s("div", { class: "desc" }, "当某个主链接只有 13 这类主 ID、没有 13_1 这类对应子 ID 时,自动保留该主 ID")], -1))], 2)]), s("div", me, [s("button", { type: "button", class: "btn-run", disabled: h.value, onClick: V }, i(h.value ? "清洗中..." : "开始清洗"), 9, he), s("span", ge, i(h.value ? "正在处理文件并上传结果,请稍候…" : "选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载"), 1)])]), s("section", ye, [e[14] || (e[14] = s("div", { class: "panel-header" }, "去重结果", -1)), s("div", be, [s("div", we, [s("div", ke, [e[10] || (e[10] = s("span", { class: "summary-label" }, "已处理文件", -1)), s("strong", null, i(g.value.total), 1)]), s("div", Ce, [e[11] || (e[11] = s("span", { class: "summary-label" }, "成功结果", -1)), s("strong", null, i(g.value.successCount), 1)]), s("div", Ie, [e[12] || (e[12] = s("span", { class: "summary-label" }, "失败文件", -1)), s("strong", null, i(g.value.failedCount), 1)])]), s("div", xe, [e[13] || (e[13] = s("div", { class: "result-list-header" }, [s("span", null, "处理后的 Excel 列表")], -1)), k.value.length === 0 ? (n(), o("div", Ee, " 暂无去重结果,完成数据去重后会在这里展示输出文件 ")) : (n(), o("ul", De, [(n(!0), o(C, null, F(k.value, t => (n(), o("li", { key: `${t.resultId || t.outputFilename || t.sourceFilename}`, class: "task-item" }, [s("div", Fe, [s("span", { class: "id", title: t.sourceFilename }, i(t.sourceFilename || "-"), 9, Pe), t.outputFilename ? (n(), o("div", Be, "输出文件:" + i(t.outputFilename), 1)) : b("", !0), t.error ? (n(), o("div", Se, "错误信息:" + i(t.error), 1)) : b("", !0)]), s("div", Ue, [s("span", { class: I(["status", t.success ? "success" : "failed"]) }, i(t.success ? "已完成" : "失败"), 3), t.success && t.downloadUrl ? (n(), o("button", { key: 0, type: "button", class: "download", onClick: a => T(t) }, " 下载文件 ", 8, Ne)) : b("", !0), t.resultId ? (n(), o("button", { key: 1, type: "button", class: "btn-delete", onClick: a => M(t.resultId) }, " 删除 ", 8, $e)) : b("", !0)])]))), 128))]))])])])])])) } }), He = Z(Ae, [["__scopeId", "data-v-6b14da88"]]); ee(He).use(se).mount("#app"); +import{d as z,o as W,a as n,c as o,b as q,B as J,e as s,F as C,r as F,t as i,f as b,n as I,w as P,v as B,g as u,h as L,i as x,E as r,j as O,k as G,l as Q,m as X,p as Y,_ as Z,q as ee,s as se}from"./pywebview-p4QBqp2b.js";const te={class:"page-shell module-page"},le={class:"main-content"},ae={class:"left-panel"},ne={class:"upload-zone"},oe={class:"selected-files clean-placeholder"},re={key:0,class:"more-line"},ie={key:1},ue={class:"option-group column-option-group"},ce={class:"column-toolbar"},de={class:"column-count"},ve={key:0,class:"column-grid"},pe=["value"],fe={key:1,class:"empty-column-state"},_e={class:"option-group"},me={class:"run-row"},he=["disabled"],ge={class:"loading-msg"},ye={class:"right-panel"},be={class:"task-list-wrap"},we={class:"clean-result-summary"},ke={class:"summary-card"},Ce={class:"summary-card"},Ie={class:"summary-card"},xe={class:"result-list-wrap"},Ee={key:0,class:"empty-tasks"},De={key:1,class:"task-list clean-result-list"},Fe={class:"left"},Pe=["title"],Be={key:0,class:"files"},Se={key:1,class:"files"},Ue={class:"task-right"},Ne=["onClick"],$e=["onClick"],Ae=z({__name:"BrandDedupeTab",setup(Ke){const p=u([]),c=u([]),f=u([]),w=u(""),d=u([]),S=u(!1),_=u(!0),m=u(!0),h=u(!1),k=u([]),g=u({total:0,successCount:0,failedCount:0,items:[]}),E=L(()=>f.value.slice(0,8));function $(){c.value=[...p.value]}function A(){c.value=[]}async function U(l){const e=x();if(!e?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传桥接能力");const t=[];for(const a of l){const v=typeof a=="string"?a:a.absolutePath,j=typeof a=="string"?void 0:a.relativePath,y=await e.upload_file_to_java(v,j);if(!y?.success||!y.data)throw new Error(y?.error||y?.message||`上传失败:${v}`);t.push(y.data)}return t}async function N(l){const e=await Y(l);if(!e.headers?.length){r.error("读取 Excel 表头失败"),p.value=[],c.value=[];return}const t=["id","ASIN","国家","价格","卖家名称"],a=[...t.filter(v=>e.headers?.includes(v)),...(e.headers||[]).filter(v=>!t.includes(v))];p.value=a,c.value=t.filter(v=>a.includes(v))}async function H(l,e){f.value=l,w.value="",d.value=await U(l),d.value.length>0&&await N(d.value[0].fileKey),r.success(e)}async function K(){const l=x();if(!l?.select_brand_xlsx_files){r.warning("当前环境不支持文件选择,请在本机客户端中打开");return}try{const e=await l.select_brand_xlsx_files();if(!e?.length)return;await H(e,`已选择 ${e.length} 个待清洗文件`)}catch(e){r.error(e instanceof Error?e.message:"选择失败")}}async function R(){const l=x();if(!l?.select_brand_folder){r.warning("当前环境不支持文件夹选择,请在本机客户端中打开");return}try{const e=await l.select_brand_folder();if(!e)return;w.value=e.split(/[/\\]/).filter(Boolean).pop()||"";const t=await O(e);if(t.success&&t.items?.length){f.value=t.items.map(a=>a.relativePath),d.value=await U(t.items),d.value.length>0&&await N(d.value[0].fileKey),r.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`);return}r.warning(t.error||"该文件夹下没有 xlsx 文件")}catch(e){r.error(e instanceof Error?e.message:"选择失败")}}async function V(){if(!d.value.length){r.warning("请先选择待清洗 Excel 文件或文件夹");return}if(!c.value.length){r.warning("请至少选择一列保留列");return}if(!S.value&&!_.value&&!m.value){r.warning("请至少选择一种 ID 保留规则");return}try{h.value=!0;const l=await G({files:d.value.map(e=>({fileKey:e.fileKey,originalFilename:e.originalFilename,relativePath:e.relativePath})),selectedColumns:c.value,keepIntegerIds:S.value,keepUnderscoreIds:_.value,keepIntegerMainIdsWhenNoSubIds:m.value,archiveName:d.value.some(e=>!!e.relativePath)&&w.value?w.value:void 0});g.value=l,k.value=l.items||[],await D(),r.success("数据去重完成")}catch(l){r.error(l instanceof Error?l.message:"去重失败")}finally{h.value=!1}}async function D(){try{const l=await Q();k.value=l.items||[],g.value={total:l.items?.length||0,successCount:l.items?.filter(e=>e.success).length||0,failedCount:l.items?.filter(e=>!e.success).length||0,items:l.items||[]}}catch{}}async function M(l){try{await X(l),await D(),r.success("已删除")}catch(e){r.error(e instanceof Error?e.message:"删除失败")}}async function T(l){const e=x();if(!l.downloadUrl){r.warning("当前结果没有下载地址");return}const t=l.outputFilename||`${new Date().toISOString().slice(0,10).replace(/-/g,"")}_cleaned.xlsx`;if(e?.save_file_from_url){const a=await e.save_file_from_url(l.downloadUrl,t);a.success?r.success(`已保存:${a.path||t}`):a.error&&a.error!=="用户取消"&&r.error(a.error);return}window.open(l.downloadUrl,"_blank")}return W(()=>{D().catch(()=>{})}),(l,e)=>(n(),o("div",te,[q(J,{active:"dedupe"}),s("div",le,[s("aside",ae,[e[7]||(e[7]=s("div",{class:"section-title"},"选择文件",-1)),s("div",ne,[e[3]||(e[3]=s("div",{class:"hint"},"选择需要清洗的 Excel 文件或文件夹,后续将按所选规则输出处理结果。",-1)),s("div",{class:"btns"},[s("button",{type:"button",class:"opt-btn",onClick:K},"选择 Excel 文件"),s("button",{type:"button",class:"opt-btn",onClick:R},"选择文件夹")]),s("div",oe,[f.value.length?(n(),o(C,{key:0},[(n(!0),o(C,null,F(E.value,t=>(n(),o("span",{key:t},i(t),1))),128)),f.value.length>E.value.length?(n(),o("span",re," 还有 "+i(f.value.length-E.value.length)+" 个文件未展开显示 ",1)):b("",!0)],64)):(n(),o("span",ie,"暂未选择待清洗文件"))])]),e[8]||(e[8]=s("div",{class:"section-title"},"保留列设置",-1)),s("div",ue,[s("div",ce,[s("span",de,"已选择 "+i(c.value.length)+" / "+i(p.value.length)+" 列",1),s("div",{class:"column-actions"},[s("button",{type:"button",class:"opt-btn small",onClick:$},"全选"),s("button",{type:"button",class:"opt-btn small",onClick:A},"清空")])]),p.value.length?(n(),o("div",ve,[(n(!0),o(C,null,F(p.value,t=>(n(),o("label",{key:t,class:I(["column-item",{active:c.value.includes(t)}])},[P(s("input",{"onUpdate:modelValue":e[0]||(e[0]=a=>c.value=a),type:"checkbox",value:t},null,8,pe),[[B,c.value]]),s("span",null,i(t),1)],2))),128))])):(n(),o("div",fe,"请选择 Excel 文件后读取表头并填充可保留列")),e[4]||(e[4]=s("div",{class:"desc"},"读取首个 Excel 文件的第一行作为表头,用于动态选择需要保留的列。",-1))]),e[9]||(e[9]=s("div",{class:"section-title"},"ID 保留规则",-1)),s("div",_e,[s("label",{class:I(["radio-item",{active:_.value}])},[P(s("input",{"onUpdate:modelValue":e[1]||(e[1]=t=>_.value=t),type:"checkbox"},null,512),[[B,_.value]]),e[5]||(e[5]=s("div",null,[s("span",{class:"label"},"保留子链接"),s("div",{class:"desc"},"例如 1_1、2_3 这种带下划线的子项 ID")],-1))],2),s("label",{class:I(["radio-item",{active:m.value}])},[P(s("input",{"onUpdate:modelValue":e[2]||(e[2]=t=>m.value=t),type:"checkbox"},null,512),[[B,m.value]]),e[6]||(e[6]=s("div",null,[s("span",{class:"label"},"仅剩主链接无子链接"),s("div",{class:"desc"},"当某个主链接只有 13 这类主 ID、没有 13_1 这类对应子 ID 时,自动保留该主 ID")],-1))],2)]),s("div",me,[s("button",{type:"button",class:"btn-run",disabled:h.value,onClick:V},i(h.value?"清洗中...":"开始清洗"),9,he),s("span",ge,i(h.value?"正在处理文件并上传结果,请稍候…":"选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载"),1)])]),s("section",ye,[e[14]||(e[14]=s("div",{class:"panel-header"},"去重结果",-1)),s("div",be,[s("div",we,[s("div",ke,[e[10]||(e[10]=s("span",{class:"summary-label"},"已处理文件",-1)),s("strong",null,i(g.value.total),1)]),s("div",Ce,[e[11]||(e[11]=s("span",{class:"summary-label"},"成功结果",-1)),s("strong",null,i(g.value.successCount),1)]),s("div",Ie,[e[12]||(e[12]=s("span",{class:"summary-label"},"失败文件",-1)),s("strong",null,i(g.value.failedCount),1)])]),s("div",xe,[e[13]||(e[13]=s("div",{class:"result-list-header"},[s("span",null,"处理后的 Excel 列表")],-1)),k.value.length===0?(n(),o("div",Ee," 暂无去重结果,完成数据去重后会在这里展示输出文件 ")):(n(),o("ul",De,[(n(!0),o(C,null,F(k.value,t=>(n(),o("li",{key:`${t.resultId||t.outputFilename||t.sourceFilename}`,class:"task-item"},[s("div",Fe,[s("span",{class:"id",title:t.sourceFilename},i(t.sourceFilename||"-"),9,Pe),t.outputFilename?(n(),o("div",Be,"输出文件:"+i(t.outputFilename),1)):b("",!0),t.error?(n(),o("div",Se,"错误信息:"+i(t.error),1)):b("",!0)]),s("div",Ue,[s("span",{class:I(["status",t.success?"success":"failed"])},i(t.success?"已完成":"失败"),3),t.success&&t.downloadUrl?(n(),o("button",{key:0,type:"button",class:"download",onClick:a=>T(t)}," 下载文件 ",8,Ne)):b("",!0),t.resultId?(n(),o("button",{key:1,type:"button",class:"btn-delete",onClick:a=>M(t.resultId)}," 删除 ",8,$e)):b("",!0)])]))),128))]))])])])])]))}}),He=Z(Ae,[["__scopeId","data-v-6b14da88"]]);ee(He).use(se).mount("#app"); diff --git a/app/assets/delete-brand-BgPrpPsj.css b/app/assets/delete-brand-BgPrpPsj.css deleted file mode 100644 index 1ec488e..0000000 --- a/app/assets/delete-brand-BgPrpPsj.css +++ /dev/null @@ -1 +0,0 @@ -.module-page[data-v-ff0a9bae]{min-height:100vh;background:#1a1a1a}.main-content[data-v-ff0a9bae]{display:flex;min-height:calc(100vh - 56px);height:calc(100vh - 56px)}.left-panel[data-v-ff0a9bae]{width:380px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-ff0a9bae]{flex:1;display:flex;flex-direction:column;min-width:0;background:#1a1a1a}.section-title[data-v-ff0a9bae]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-ff0a9bae]{border:1px dashed #3a3a3a;border-radius:10px;padding:24px;text-align:center;background:#252525;margin-bottom:20px;transition:all .2s ease}.upload-zone[data-v-ff0a9bae]:hover{border-color:#3498db;background:#2a2a2a}.hint[data-v-ff0a9bae]{color:#888;font-size:13px;margin-bottom:12px;line-height:1.5}.btns[data-v-ff0a9bae]{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}.opt-btn[data-v-ff0a9bae],.btn-run[data-v-ff0a9bae],.download[data-v-ff0a9bae],.btn-delete[data-v-ff0a9bae]{border:none;cursor:pointer;transition:all .2s ease}.opt-btn[data-v-ff0a9bae]{padding:8px 16px;font-size:13px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px}.opt-btn[data-v-ff0a9bae]:hover{color:#3498db;background:#333;border-color:#3498db}.selected-files[data-v-ff0a9bae]{margin-top:14px;font-size:12px;color:#888;max-height:112px;overflow-y:auto;text-align:left}.selected-files span[data-v-ff0a9bae]{display:block;margin:4px 0;word-break:break-all}.more-line[data-v-ff0a9bae]{color:#b8c1cc}.split-selected-files[data-v-ff0a9bae]{max-height:120px;min-height:72px;padding-right:4px}.option-group[data-v-ff0a9bae]{margin-bottom:20px}.radio-item[data-v-ff0a9bae]{display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:10px 12px;border-radius:8px;background:#252525;border:1px solid #2a2a2a;margin-bottom:8px;transition:all .2s ease}.radio-item[data-v-ff0a9bae]:hover,.radio-item.active[data-v-ff0a9bae]{background:#2a2a2a;border-color:#3a3a3a}.radio-item input[data-v-ff0a9bae]{margin-top:3px}.label[data-v-ff0a9bae]{font-weight:500;color:#e0e0e0;font-size:13px}.desc[data-v-ff0a9bae]{font-size:12px;color:#888;margin-top:2px;line-height:1.5;font-weight:400}.run-row[data-v-ff0a9bae]{display:flex;align-items:center;flex-wrap:wrap;gap:10px 12px;margin-top:16px}.btn-run[data-v-ff0a9bae]{padding:10px 22px;background:#3498db;color:#fff;border-radius:8px;font-size:14px}.btn-run[data-v-ff0a9bae]:hover{background:#2980b9}.btn-run[data-v-ff0a9bae]:disabled{background:#555;color:#999;cursor:not-allowed}.loading-msg[data-v-ff0a9bae]{color:#3498db;font-size:13px}.panel-header[data-v-ff0a9bae]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-ff0a9bae]{flex:1;overflow-y:auto;padding:16px}.task-list[data-v-ff0a9bae]{list-style:none;margin:0;padding:0}.task-item[data-v-ff0a9bae]{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:10px;background:#1e1e1e}.split-result-item[data-v-ff0a9bae]{align-items:flex-start}.left[data-v-ff0a9bae]{flex:1;min-width:0}.split-result-main[data-v-ff0a9bae]{display:flex;flex-direction:column;gap:4px}.id[data-v-ff0a9bae]{display:inline-block;font-weight:600;color:#e0e0e0;font-size:13px}.files[data-v-ff0a9bae]{font-size:12px;color:#888;margin-top:4px}.time[data-v-ff0a9bae]{font-size:12px;color:#666;margin-top:2px}.split-entry-list[data-v-ff0a9bae]{line-height:1.6;word-break:break-all;max-width:100%}.delete-brand-preview[data-v-ff0a9bae]{white-space:normal}.task-right[data-v-ff0a9bae]{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.split-result-actions[data-v-ff0a9bae]{flex-shrink:0;min-width:180px;justify-content:flex-end;align-self:center}.status[data-v-ff0a9bae]{padding:4px 10px;border-radius:6px;font-size:12px;white-space:nowrap}.status.success[data-v-ff0a9bae]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-ff0a9bae]{background:#e74c3c2e;color:#ff6b6b}.download[data-v-ff0a9bae]{padding:6px 10px;border-radius:6px;font-size:12px;background:#3498db2e;color:#69b6ff}.download[data-v-ff0a9bae]:hover{background:#3498db47}.btn-delete[data-v-ff0a9bae]{padding:6px 10px;border-radius:6px;font-size:12px;background:#e74c3c1f;color:#ff8f8f}.btn-delete[data-v-ff0a9bae]:hover{background:#e74c3c38}.empty-tasks[data-v-ff0a9bae]{color:#666;font-size:13px;padding:24px 8px}.clean-placeholder[data-v-ff0a9bae]{max-height:112px}.clean-result-summary[data-v-ff0a9bae]{display:grid;grid-template-columns:repeat(3,minmax(0,220px));gap:16px;margin-bottom:18px}.summary-card[data-v-ff0a9bae]{padding:16px 18px;border:1px solid #2a2a2a;border-radius:10px;background:#1e1e1e}.summary-card strong[data-v-ff0a9bae]{display:block;margin-top:8px;font-size:24px;color:#eaf4ff}.summary-label[data-v-ff0a9bae]{font-size:12px;color:#8d8d8d}.result-list-wrap[data-v-ff0a9bae]{border:1px solid #2a2a2a;border-radius:10px;background:#1e1e1e;min-height:260px}.result-list-header[data-v-ff0a9bae]{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #2a2a2a;font-size:14px;color:#ddd}@media(max-width:1100px){.main-content[data-v-ff0a9bae]{flex-direction:column;height:auto}.left-panel[data-v-ff0a9bae]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.right-panel[data-v-ff0a9bae]{min-height:420px}.task-item[data-v-ff0a9bae]{flex-direction:column;align-items:flex-start}.task-right[data-v-ff0a9bae]{width:100%;justify-content:flex-start}.split-result-actions[data-v-ff0a9bae]{min-width:0;align-self:flex-start}.clean-result-summary[data-v-ff0a9bae]{grid-template-columns:1fr}} diff --git a/app/assets/delete-brand.js b/app/assets/delete-brand.js index e29de2d..baf207a 100644 --- a/app/assets/delete-brand.js +++ b/app/assets/delete-brand.js @@ -1 +1 @@ -import{d as S,o as D,a as l,c as n,b as N,B as j,e as a,F as y,r as x,t as o,f as c,J as H,n as T,g as _,h as V,i as g,E as r,j as A,K,L as M,M as z,_ as J,q as L,s as U}from"./pywebview-DdV1XQSM.js";const q={class:"page-shell module-page"},G={class:"main-content"},O={class:"left-panel"},Q={class:"upload-zone"},W={class:"selected-files clean-placeholder split-selected-files"},X={key:0,class:"more-line"},Y={key:1},Z={class:"run-row"},ss=["disabled"],es={class:"loading-msg"},as={class:"right-panel"},ts={class:"task-list-wrap"},ls={class:"clean-result-summary"},ns={class:"summary-card"},os={class:"summary-card"},is={class:"summary-card"},rs={class:"result-list-wrap"},cs={key:0,class:"empty-tasks"},ds={key:1,class:"task-list clean-result-list"},us={class:"left split-result-main"},vs=["title"],fs={class:"files"},ps={class:"files"},_s={key:0,class:"files"},hs={key:1,class:"files"},bs={key:2,class:"time"},ys={key:3,class:"files split-entry-list delete-brand-preview"},gs={key:4,class:"files"},ms={class:"task-right split-result-actions"},ks=["onClick"],ws=["onClick"],xs=S({__name:"BrandDeleteBrandTab",setup(Bs){const d=_([]),h=_([]),u=_(!1),v=_([]),f=_({total:0,successCount:0,failedCount:0,items:[]}),b=V(()=>d.value.slice(0,8));function C(t){f.value={total:t.length,successCount:t.filter(s=>s.success).length,failedCount:t.filter(s=>!s.success).length,items:t}}async function m(t){const s=g();if(!s?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传桥接能力");const e=[];for(const i of t){const w=typeof i=="string"?i:i.absolutePath,R=typeof i=="string"?void 0:i.relativePath,p=await s.upload_file_to_java(w,R);if(!p?.success||!p.data)throw new Error(p?.error||p?.message||`上传失败:${w}`);e.push(p.data)}return e}async function B(){const t=g();if(!t?.select_brand_xlsx_files){r.warning("当前环境不支持文件选择,请在本机客户端中打开");return}try{const s=await t.select_brand_xlsx_files();if(!s?.length)return;d.value=s,h.value=await m(s),r.success(`已选择 ${s.length} 个删除品牌文件`)}catch(s){r.error(s instanceof Error?s.message:"选择失败")}}async function E(){const t=g();if(!t?.select_brand_folder){r.warning("当前环境不支持文件夹选择,请在本机客户端中打开");return}try{const s=await t.select_brand_folder();if(!s)return;const e=await A(s);if(!e.success||!e.items?.length){r.warning(e.error||"该文件夹下没有 xlsx 文件");return}d.value=e.items.map(i=>i.relativePath||i.absolutePath),h.value=await m(e.items),r.success(`已选择文件夹内 ${e.items.length} 个 xlsx 文件`)}catch(s){r.error(s instanceof Error?s.message:"选择失败")}}async function P(){if(!h.value.length){r.warning("请先选择待处理文件");return}try{u.value=!0;const t=await K({files:h.value.map(s=>({fileKey:s.fileKey,originalFilename:s.originalFilename,relativePath:s.relativePath}))});f.value=t,v.value=t.items||[],r.success("删除品牌解析完成")}catch(t){r.error(t instanceof Error?t.message:"执行失败")}finally{u.value=!1}}async function k(){try{const t=await M();v.value=t.items||[],C(v.value)}catch{}}async function $(t){try{await z(t),await k(),r.success("已删除")}catch(s){r.error(s instanceof Error?s.message:"删除失败")}}function F(t){const s=t.slice(0,5).map(i=>`${i.country}:${i.asin}${i.status?`(${i.status})`:""}`);return t.length-s.length>0?`${s.join("、")} 等 ${t.length} 条`:s.join("、")}function I(t){window.open(t,"_blank")}return D(()=>{k().catch(()=>{})}),(t,s)=>(l(),n("div",q,[N(j,{active:"delete-brand"}),a("div",G,[a("aside",O,[s[1]||(s[1]=a("div",{class:"section-title"},"选择文件",-1)),a("div",Q,[s[0]||(s[0]=a("div",{class:"hint"},"上传删除品牌 Excel;无论是直接上传文件还是上传文件夹批量导入,都会按每个 Excel 文件名作为店铺名。",-1)),a("div",{class:"btns"},[a("button",{type:"button",class:"opt-btn",onClick:B},"选择 Excel 文件"),a("button",{type:"button",class:"opt-btn",onClick:E},"选择文件夹")]),a("div",W,[d.value.length?(l(),n(y,{key:0},[(l(!0),n(y,null,x(b.value,e=>(l(),n("span",{key:e},o(e),1))),128)),d.value.length>b.value.length?(l(),n("span",X," 还有 "+o(d.value.length-b.value.length)+" 个文件未展开显示 ",1)):c("",!0)],64)):(l(),n("span",Y,"暂未选择删除品牌文件"))])]),s[2]||(s[2]=H('
执行说明
',2)),a("div",Z,[a("button",{type:"button",class:"btn-run",disabled:u.value,onClick:P},o(u.value?"解析中...":"开始解析"),9,ss),a("span",es,o(u.value?"正在读取删除品牌数据,请稍候…":"解析后会在右侧展示按国家分组的去重结果"),1)])]),a("section",as,[s[7]||(s[7]=a("div",{class:"panel-header"},"删除品牌结果",-1)),a("div",ts,[a("div",ls,[a("div",ns,[s[3]||(s[3]=a("span",{class:"summary-label"},"已处理文件",-1)),a("strong",null,o(f.value.total),1)]),a("div",os,[s[4]||(s[4]=a("span",{class:"summary-label"},"成功结果",-1)),a("strong",null,o(f.value.successCount),1)]),a("div",is,[s[5]||(s[5]=a("span",{class:"summary-label"},"失败文件",-1)),a("strong",null,o(f.value.failedCount),1)])]),a("div",rs,[s[6]||(s[6]=a("div",{class:"result-list-header"},[a("span",null,"删除品牌结果列表")],-1)),v.value.length===0?(l(),n("div",cs," 暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果 ")):(l(),n("ul",ds,[(l(!0),n(y,null,x(v.value,e=>(l(),n("li",{key:`${e.resultId||e.taskId||e.sourceFilename}`,class:"task-item split-result-item"},[a("div",us,[a("span",{class:"id",title:e.sourceFilename},o(e.sourceFilename||"-"),9,vs),a("div",fs,"店铺名:"+o(e.shopName||"-"),1),a("div",ps,"匹配结果:"+o(e.matched?`已匹配 ${e.shopId||""}`:"未匹配到紫鸟店铺"),1),e.platform?(l(),n("div",_s,"平台:"+o(e.platform),1)):c("",!0),e.countryCount!==void 0?(l(),n("div",hs,"国家数:"+o(e.countryCount),1)):c("",!0),e.totalRows!==void 0?(l(),n("div",bs,"去重后 "+o(e.totalRows)+" 条",1)):c("",!0),e.previewRows?.length?(l(),n("div",ys,o(F(e.previewRows)),1)):c("",!0),e.error?(l(),n("div",gs,"错误信息:"+o(e.error),1)):c("",!0)]),a("div",ms,[a("span",{class:T(["status",e.success?"success":"failed"])},o(e.success?"已完成":"失败"),3),e.openStoreUrl?(l(),n("button",{key:0,type:"button",class:"download",onClick:i=>I(e.openStoreUrl)}," 打开紫鸟 ",8,ks)):c("",!0),e.resultId?(l(),n("button",{key:1,type:"button",class:"btn-delete",onClick:i=>$(e.resultId)}," 删除 ",8,ws)):c("",!0)])]))),128))]))])])])])]))}}),Cs=J(xs,[["__scopeId","data-v-ff0a9bae"]]);L(Cs).use(U).mount("#app"); +import{d as ts,o as as,J as ls,a as n,c as o,b as ns,B as os,e as a,F as g,r as B,t as r,f as i,K as rs,L as J,n as O,g as v,h as P,i as w,E as u,j as is,M as cs,N as us,O as ds,P as vs,Q as _s,_ as ps,q as fs,s as hs}from"./pywebview-p4QBqp2b.js";const ys={class:"page-shell module-page"},bs={class:"main-content"},ks={class:"left-panel"},gs={class:"upload-zone"},ws={class:"selected-files clean-placeholder split-selected-files"},ms={key:0,class:"more-line"},Is={key:1},$s={class:"run-row"},Cs=["disabled"],xs={class:"loading-msg"},Ps={key:0,class:"queue-debug-card"},Ss={key:0,class:"queue-debug-line"},Rs={key:1,class:"queue-debug-payload"},Fs={class:"right-panel"},Bs={class:"task-list-wrap"},Ds={class:"clean-result-summary"},Es={class:"summary-card"},Ns={class:"summary-card"},Ts={class:"summary-card"},qs={class:"result-list-wrap"},Us={key:0,class:"empty-tasks"},js={key:0,class:"result-subsection"},Ms={class:"task-list clean-result-list"},zs={class:"left split-result-main"},Vs=["title"],As={class:"files"},Ks={class:"files"},Hs={key:0,class:"files"},Js={key:1,class:"files"},Os={key:2,class:"time"},Ls={key:3,class:"delete-brand-progress-block"},Gs={class:"delete-brand-progress-header"},Qs={class:"delete-brand-progress-bar"},Ws={class:"files delete-brand-progress"},Xs={key:4,class:"files split-entry-list delete-brand-preview"},Ys={key:5,class:"files"},Zs={class:"task-right split-result-actions"},se=["onClick"],ee=["onClick"],te=["onClick"],ae={key:1,class:"result-subsection"},le={class:"task-list clean-result-list"},ne={class:"left split-result-main"},oe=["title"],re={class:"files"},ie={class:"files"},ce={key:0,class:"files"},ue={key:1,class:"files"},de={key:2,class:"time"},ve={key:3,class:"delete-brand-progress-block"},_e={class:"delete-brand-progress-header"},pe={class:"delete-brand-progress-bar"},fe={class:"files delete-brand-progress"},he={key:4,class:"files split-entry-list delete-brand-preview"},ye={key:5,class:"files"},be={class:"task-right split-result-actions"},ke=["onClick"],ge=["onClick"],we=["onClick"],me=ts({__name:"BrandDeleteBrandTab",setup($e){const p=v([]),m=v([]),h=v(!1),S=v([]),R=v([]),D=v([]),I=v({total:0,successCount:0,failedCount:0,items:[]}),f=v(""),y=v(""),_=v({}),b=v(null),F=P(()=>p.value.slice(0,10)),$=P(()=>R.value),C=P(()=>D.value.filter(t=>!R.value.some(e=>(e.resultId||e.taskId||e.fileKey||e.sourceFilename)===(t.resultId||t.taskId||t.fileKey||t.sourceFilename)))),L=P(()=>$.value.length>0||C.value.length>0);function E(t){return t.map(e=>e.matched?e:{...e,success:!1,error:e.error||"未匹配到紫鸟店铺",taskId:void 0,openStoreUrl:void 0})}function G(){S.value=[...$.value,...C.value]}function N(){G(),Q(S.value)}function Q(t){I.value={total:t.length,successCount:t.filter(e=>e.success).length,failedCount:t.filter(e=>!e.success).length,items:t}}function W(t){return t.taskId||0}function T(t){if(!t)return"";const e=_.value[t],s=e?.line_progress?.info;if(!e?.line_progress?.has_progress||!s){const c=e?.task?.status;return c?`任务状态:${c}`:""}const l=[];return s.phase&&l.push(`阶段:${s.phase}`),s.file_index&&s.file_total&&l.push(`文件:${s.file_index}/${s.file_total}`),s.file_name&&l.push(`当前文件:${s.file_name}`),s.current_line!==void 0&&s.total_lines!==void 0&&l.push(`进度:${s.current_line}/${s.total_lines}`),s.current_country&&l.push(`国家:${s.current_country}`),s.current_asin&&l.push(`ASIN:${s.current_asin}`),s.finished_files!==void 0&&l.push(`已完成文件:${s.finished_files}`),l.join("|")}function x(t){if(!t)return 0;const e=_.value[t]?.line_progress?.info;return e?e.current_line!==void 0&&e.total_lines&&e.total_lines>0?Math.max(0,Math.min(100,Math.round(e.current_line/e.total_lines*100))):e.finished_files!==void 0&&e.file_total&&e.file_total>0?Math.max(0,Math.min(100,Math.round(e.finished_files/e.file_total*100))):_.value[t]?.task?.status==="SUCCESS"?100:0:0}function X(t){return _.value[t]?.task?.status==="RUNNING"}function q(t){const e=t.taskId;return!e||!t.matched?!1:_.value[e]?.task?.status==="SUCCESS"}async function U(){const t=Array.from(new Set(S.value.map(W).filter(e=>e>0)));for(const e of t)try{const s=await _s(e);_.value[e]=s}catch{}}function Y(){b.value||(b.value=window.setInterval(async()=>{await U(),Object.keys(_.value).some(e=>X(Number(e)))||j()},1500))}function j(){b.value&&(window.clearInterval(b.value),b.value=null)}async function M(t){const e=w();if(!e?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传桥接能力");const s=[];for(const l of t){const c=typeof l=="string"?l:l.absolutePath,d=typeof l=="string"?void 0:l.relativePath,k=await e.upload_file_to_java(c,d);if(!k?.success||!k.data)throw new Error(k?.error||k?.message||`上传失败:${c}`);s.push(k.data)}return s}async function Z(){const t=w();if(!t?.select_brand_xlsx_files){u.warning("当前环境不支持文件选择,请在本机客户端中打开");return}try{const e=await t.select_brand_xlsx_files();if(!e?.length)return;p.value=e,m.value=await M(e),u.success(`已选择 ${e.length} 个删除品牌文件`)}catch(e){u.error(e instanceof Error?e.message:"选择失败")}}async function ss(){const t=w();if(!t?.select_brand_folder){u.warning("当前环境不支持文件夹选择,请在本机客户端中打开");return}try{const e=await t.select_brand_folder();if(!e)return;const s=await is(e);if(!s.success||!s.items?.length){u.warning(s.error||"该文件夹下没有 xlsx 文件");return}p.value=s.items.map(l=>l.relativePath||l.absolutePath),m.value=await M(s.items),u.success(`已选择文件夹内 ${s.items.length} 个 xlsx 文件`)}catch(e){u.error(e instanceof Error?e.message:"选择失败")}}async function es(){if(!m.value.length){u.warning("请先选择待处理文件");return}try{h.value=!0;const t=await cs({files:m.value.map(c=>({fileKey:c.fileKey,originalFilename:c.originalFilename,relativePath:c.relativePath}))}),e=E(t.items||[]);R.value=e,N(),await U(),Y();const s=w(),l=e.some(c=>!c.matched);if(!l&&s?.enqueue_json){const c={type:"delete-brand-run",ts:Date.now(),data:{...t,items:e}};y.value=JSON.stringify(c,null,2);const d=await s.enqueue_json(c);d?.success?f.value=`已推送到 Python 队列,当前队列长度:${d.queue_size??"-"}`:f.value=`推送到 Python 队列失败:${d?.error||"未知错误"}`}else f.value=l?"存在未匹配到紫鸟店铺的文件,已跳过推送到 Python 队列":"当前环境未启用 pywebview enqueue_json(浏览器环境不会推送)",y.value="";l?u.error("存在未匹配到紫鸟店铺的文件,已标记为失败"):u.success("删除品牌解析完成")}catch(t){u.error(t instanceof Error?t.message:"执行失败")}finally{h.value=!1}}async function z(t){const e=t.taskId;if(!e){u.warning("未找到任务ID");return}const s=w(),l=us(e),c=t.outputFilename||t.sourceFilename||`delete-brand_${e}.xlsx`;if(s?.save_file_from_url){const d=await s.save_file_from_url(l,c);d.success?u.success(`已保存:${d.path||c}`):d.error&&d.error!=="用户取消"&&u.error(d.error);return}window.open(l,"_blank")}async function V(){try{const t=await ds();D.value=E(t.items||[]),N()}catch{}}async function A(t){try{await vs(t),await V(),u.success("已删除")}catch(e){u.error(e instanceof Error?e.message:"删除失败")}}function K(t){const e=t.slice(0,5).map(l=>`${l.country}:${l.asin}${l.status?`(${l.status})`:""}`);return t.length-e.length>0?`${e.join("、")} 等 ${t.length} 条`:e.join("、")}function H(t){window.open(t,"_blank")}return as(()=>{V().catch(()=>{})}),ls(()=>{j()}),(t,e)=>(n(),o("div",ys,[ns(os,{active:"delete-brand"}),a("div",bs,[a("aside",ks,[e[2]||(e[2]=a("div",{class:"section-title"},"选择文件",-1)),a("div",gs,[e[0]||(e[0]=a("div",{class:"hint"},"上传删除品牌 Excel;无论是直接上传文件还是上传文件夹批量导入,都会按每个 Excel 文件名作为店铺名。",-1)),a("div",{class:"btns"},[a("button",{type:"button",class:"opt-btn",onClick:Z},"选择 Excel 文件"),a("button",{type:"button",class:"opt-btn",onClick:ss},"选择文件夹")]),a("div",ws,[p.value.length?(n(),o(g,{key:0},[(n(!0),o(g,null,B(F.value,s=>(n(),o("span",{key:s},r(s),1))),128)),p.value.length>F.value.length?(n(),o("span",ms," 还有 "+r(p.value.length-F.value.length)+" 个文件未展开显示 ",1)):i("",!0)],64)):(n(),o("span",Is,"暂未选择删除品牌文件"))])]),e[3]||(e[3]=rs('
执行说明
',2)),a("div",$s,[a("button",{type:"button",class:"btn-run",disabled:h.value,onClick:es},r(h.value?"解析中...":"开始解析"),9,Cs),a("span",xs,r(h.value?"正在读取删除品牌数据,请稍候…":"解析后会在右侧展示按国家分组的去重结果"),1)]),f.value||y.value?(n(),o("div",Ps,[e[1]||(e[1]=a("div",{class:"section-title queue-debug-title"},"Python 队列推送结果",-1)),f.value?(n(),o("div",Ss,r(f.value),1)):i("",!0),y.value?(n(),o("pre",Rs,r(y.value),1)):i("",!0)])):i("",!0)]),a("section",Fs,[e[12]||(e[12]=a("div",{class:"panel-header"},"删除品牌结果",-1)),a("div",Bs,[a("div",Ds,[a("div",Es,[e[4]||(e[4]=a("span",{class:"summary-label"},"已处理文件",-1)),a("strong",null,r(I.value.total),1)]),a("div",Ns,[e[5]||(e[5]=a("span",{class:"summary-label"},"成功结果",-1)),a("strong",null,r(I.value.successCount),1)]),a("div",Ts,[e[6]||(e[6]=a("span",{class:"summary-label"},"失败文件",-1)),a("strong",null,r(I.value.failedCount),1)])]),a("div",qs,[e[11]||(e[11]=a("div",{class:"result-list-header"},[a("span",null,"删除品牌结果列表")],-1)),L.value?(n(),o(g,{key:1},[$.value.length?(n(),o("div",js,[e[8]||(e[8]=a("div",{class:"result-subsection-title"},"当前任务",-1)),a("ul",Ms,[(n(!0),o(g,null,B($.value,s=>(n(),o("li",{key:`current-${s.resultId||s.taskId||s.sourceFilename}`,class:"task-item split-result-item"},[a("div",zs,[a("span",{class:"id",title:s.sourceFilename},r(s.sourceFilename||"-"),9,Vs),a("div",As,"店铺名:"+r(s.shopName||"-"),1),a("div",Ks,"匹配结果:"+r(s.matched?`已匹配 ${s.shopId||""}`:"未匹配到紫鸟店铺"),1),s.platform?(n(),o("div",Hs,"平台:"+r(s.platform),1)):i("",!0),s.countryCount!==void 0&&s.matched?(n(),o("div",Js,"国家数:"+r(s.countryCount),1)):i("",!0),s.totalRows!==void 0&&s.matched?(n(),o("div",Os,"去重后 "+r(s.totalRows)+" 条",1)):i("",!0),s.taskId&&s.matched?(n(),o("div",Ls,[a("div",Gs,[e[7]||(e[7]=a("span",null,"任务进度",-1)),a("span",null,r(x(s.taskId))+"%",1)]),a("div",Qs,[a("div",{class:"delete-brand-progress-bar-fill",style:J({width:`${x(s.taskId)}%`})},null,4)]),a("div",Ws,r(T(s.taskId)),1)])):i("",!0),s.previewRows?.length?(n(),o("div",Xs,r(K(s.previewRows)),1)):i("",!0),s.error?(n(),o("div",Ys,"错误信息:"+r(s.error),1)):i("",!0)]),a("div",Zs,[a("span",{class:O(["status",s.success?"success":"failed"])},r(s.success?"已完成":"失败"),3),s.openStoreUrl?(n(),o("button",{key:0,type:"button",class:"download",onClick:l=>H(s.openStoreUrl)}," 打开紫鸟 ",8,se)):i("",!0),q(s)?(n(),o("button",{key:1,type:"button",class:"download",onClick:l=>z(s)}," 下载 ",8,ee)):i("",!0),s.resultId?(n(),o("button",{key:2,type:"button",class:"btn-delete",onClick:l=>A(s.resultId)}," 删除 ",8,te)):i("",!0)])]))),128))])])):i("",!0),C.value.length?(n(),o("div",ae,[e[10]||(e[10]=a("div",{class:"result-subsection-title"},"历史记录",-1)),a("ul",le,[(n(!0),o(g,null,B(C.value,s=>(n(),o("li",{key:`history-${s.resultId||s.taskId||s.sourceFilename}`,class:"task-item split-result-item"},[a("div",ne,[a("span",{class:"id",title:s.sourceFilename},r(s.sourceFilename||"-"),9,oe),a("div",re,"店铺名:"+r(s.shopName||"-"),1),a("div",ie,"匹配结果:"+r(s.matched?`已匹配 ${s.shopId||""}`:"未匹配到紫鸟店铺"),1),s.platform?(n(),o("div",ce,"平台:"+r(s.platform),1)):i("",!0),s.countryCount!==void 0&&s.matched?(n(),o("div",ue,"国家数:"+r(s.countryCount),1)):i("",!0),s.totalRows!==void 0&&s.matched?(n(),o("div",de,"去重后 "+r(s.totalRows)+" 条",1)):i("",!0),s.taskId&&s.matched?(n(),o("div",ve,[a("div",_e,[e[9]||(e[9]=a("span",null,"任务进度",-1)),a("span",null,r(x(s.taskId))+"%",1)]),a("div",pe,[a("div",{class:"delete-brand-progress-bar-fill",style:J({width:`${x(s.taskId)}%`})},null,4)]),a("div",fe,r(T(s.taskId)),1)])):i("",!0),s.previewRows?.length?(n(),o("div",he,r(K(s.previewRows)),1)):i("",!0),s.error?(n(),o("div",ye,"错误信息:"+r(s.error),1)):i("",!0)]),a("div",be,[a("span",{class:O(["status",s.success?"success":"failed"])},r(s.success?"已完成":"失败"),3),s.openStoreUrl?(n(),o("button",{key:0,type:"button",class:"download",onClick:l=>H(s.openStoreUrl)}," 打开紫鸟 ",8,ke)):i("",!0),q(s)?(n(),o("button",{key:1,type:"button",class:"download",onClick:l=>z(s)}," 下载 ",8,ge)):i("",!0),s.resultId?(n(),o("button",{key:2,type:"button",class:"btn-delete",onClick:l=>A(s.resultId)}," 删除 ",8,we)):i("",!0)])]))),128))])])):i("",!0)],64)):(n(),o("div",Us," 暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果 "))])])])])]))}}),Ie=ps(me,[["__scopeId","data-v-375b57cc"]]);fs(Ie).use(hs).mount("#app"); diff --git a/app/assets/split.js b/app/assets/split.js index d86bc9e..da17d24 100644 --- a/app/assets/split.js +++ b/app/assets/split.js @@ -1 +1 @@ -import { d as G, o as J, a, c as n, b as L, B as O, e, F as S, r as $, t as i, f as v, n as x, w as k, u as V, D as M, g as d, h as Q, i as F, E as r, j as W, G as X, H as Y, p as Z, v as ss, I as es, _ as ts, q as ls, s as as } from "./pywebview-DdV1XQSM.js"; const ns = { class: "page-shell module-page" }, os = { class: "main-content" }, is = { class: "left-panel" }, rs = { class: "upload-zone" }, us = { class: "selected-files clean-placeholder split-selected-files" }, ds = { key: 0, class: "more-line" }, cs = { key: 1 }, ps = { class: "option-group column-option-group" }, vs = { class: "column-toolbar" }, fs = { class: "column-count" }, _s = { class: "column-actions" }, ms = { key: 0, class: "column-grid" }, ys = ["value"], gs = { key: 1, class: "empty-column-state" }, hs = { class: "option-group" }, bs = { class: "radio-item active" }, ws = { class: "desc" }, ks = { class: "option-group" }, Cs = { class: "option-group" }, Ss = { key: 0, class: "split-input-card" }, xs = { key: 1, class: "split-input-card" }, Fs = { class: "run-row" }, Es = ["disabled"], Ps = { class: "loading-msg" }, $s = { class: "right-panel" }, Bs = { class: "task-list-wrap" }, Is = { class: "clean-result-summary" }, Rs = { class: "summary-card" }, Us = { class: "summary-card" }, Vs = { class: "summary-card" }, Ms = { class: "result-list-wrap" }, Ts = { key: 0, class: "empty-tasks" }, js = { key: 1, class: "task-list clean-result-list" }, Ds = { class: "left split-result-main" }, Hs = ["title"], Ns = { key: 0, class: "files" }, As = { key: 1, class: "time" }, Ks = { key: 2, class: "files" }, zs = { key: 3, class: "files split-entry-list" }, qs = { key: 4, class: "files" }, Gs = { class: "task-right split-result-actions" }, Js = ["onClick"], Ls = ["onClick"], Os = G({ __name: "BrandSplitTab", setup(Ws) { const f = d([]), C = d(""), c = d([]), _ = d([]), p = d([]), B = d(0), u = d("rows_per_file"), m = d(2e3), y = d(5), g = d(!1), h = d([]), b = d({ total: 0, successCount: 0, failedCount: 0, items: [] }), E = Q(() => f.value.slice(0, 8)); function T(l) { b.value = { total: l.length, successCount: l.filter(s => s.success).length, failedCount: l.filter(s => !s.success).length, items: l } } async function I(l) { const s = F(); if (!s?.upload_file_to_java) throw new Error("当前桌面端未提供文件上传桥接能力"); const t = []; for (const o of l) { const U = typeof o == "string" ? o : o.absolutePath, q = typeof o == "string" ? void 0 : o.relativePath, w = await s.upload_file_to_java(U, q); if (!w?.success || !w.data) throw new Error(w?.error || w?.message || `上传失败:${U}`); t.push(w.data) } return t } async function R(l) { const s = await Z(l); _.value = s.headers || [], p.value = [...s.headers || []], B.value = s.totalRows || 0 } async function j(l, s) { f.value = l, C.value = "", c.value = await I(l), c.value.length > 0 && await R(c.value[0].fileKey), r.success(s) } async function D() { const l = F(); if (!l?.select_brand_xlsx_files) { r.warning("当前环境不支持文件选择,请在本机客户端中打开"); return } try { const s = await l.select_brand_xlsx_files(); if (!s?.length) return; await j(s, `已选择 ${s.length} 个待拆分文件`) } catch (s) { r.error(s instanceof Error ? s.message : "选择失败") } } async function H() { const l = F(); if (!l?.select_brand_folder) { r.warning("当前环境不支持文件夹选择,请在本机客户端中打开"); return } try { const s = await l.select_brand_folder(); if (!s) return; C.value = s.split(/[/\\]/).filter(Boolean).pop() || ""; const t = await W(s); if (t.success && t.items?.length) { f.value = t.items.map(o => o.relativePath), c.value = await I(t.items), c.value.length > 0 && await R(c.value[0].fileKey), r.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`); return } r.warning(t.error || "该文件夹下没有 xlsx 文件") } catch (s) { r.error(s instanceof Error ? s.message : "选择失败") } } async function N() { if (!c.value.length) { r.warning("请先选择待拆分 Excel 文件或文件夹"); return } if (!p.value.length) { r.warning("请至少选择一列保留列"); return } if (u.value === "rows_per_file" && (!m.value || m.value <= 0)) { r.warning("请输入有效的每份条数"); return } if (u.value === "parts" && (!y.value || y.value <= 0)) { r.warning("请输入有效的拆分份数"); return } try { g.value = !0; const l = await X({ files: c.value.map(s => ({ fileKey: s.fileKey, originalFilename: s.originalFilename, relativePath: s.relativePath })), selectedColumns: p.value, splitMode: u.value, rowsPerFile: u.value === "rows_per_file" && m.value || void 0, parts: u.value === "parts" && y.value || void 0, archiveName: c.value.some(s => !!s.relativePath) && C.value ? C.value : void 0 }); b.value = l, h.value = l.items || [], await P(), r.success("数据拆分完成") } catch (l) { r.error(l instanceof Error ? l.message : "数据拆分失败") } finally { g.value = !1 } } async function P() { try { const l = await Y(); h.value = l.items || [], T(h.value) } catch { } } async function A(l) { try { await es(l), await P(), r.success("已删除") } catch (s) { r.error(s instanceof Error ? s.message : "删除失败") } } function K(l) { const s = l.slice(0, 4).map(o => o.rowCount !== void 0 ? `${o.filename}(${o.rowCount}条)` : o.filename); return l.length - s.length > 0 ? `${s.join("、")} 等 ${l.length} 个文件` : s.join("、") } async function z(l) { const s = F(); if (!l.downloadUrl) { r.warning("当前结果没有下载地址"); return } const t = l.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, "")}.zip`; if (s?.save_file_from_url) { const o = await s.save_file_from_url(l.downloadUrl, t); o.success ? r.success(`已保存:${o.path || t}`) : o.error && o.error !== "用户取消" && r.error(o.error); return } window.open(l.downloadUrl, "_blank") } return J(() => { P().catch(() => { }) }), (l, s) => (a(), n("div", ns, [L(O, { active: "split" }), e("div", os, [e("aside", is, [s[14] || (s[14] = e("div", { class: "section-title" }, "选择文件", -1)), e("div", rs, [s[7] || (s[7] = e("div", { class: "hint" }, "选择需要拆分的 Excel 文件或文件夹,按规则拆成多个 Excel 文件。", -1)), e("div", { class: "btns" }, [e("button", { type: "button", class: "opt-btn", onClick: D }, "选择 Excel 文件"), e("button", { type: "button", class: "opt-btn", onClick: H }, "选择文件夹")]), e("div", us, [f.value.length ? (a(), n(S, { key: 0 }, [(a(!0), n(S, null, $(E.value, t => (a(), n("span", { key: t }, i(t), 1))), 128)), f.value.length > E.value.length ? (a(), n("span", ds, " 还有 " + i(f.value.length - E.value.length) + " 个文件未展开显示 ", 1)) : v("", !0)], 64)) : (a(), n("span", cs, "暂未选择待拆分文件"))])]), s[15] || (s[15] = e("div", { class: "section-title" }, "保留列设置", -1)), e("div", ps, [e("div", vs, [e("span", fs, "已选择 " + i(p.value.length) + " / " + i(_.value.length) + " 列", 1), e("div", _s, [e("button", { type: "button", class: "opt-btn small", onClick: s[0] || (s[0] = t => p.value = [..._.value]) }, "全选"), e("button", { type: "button", class: "opt-btn small", onClick: s[1] || (s[1] = t => p.value = []) }, "清空")])]), _.value.length ? (a(), n("div", ms, [(a(!0), n(S, null, $(_.value, t => (a(), n("label", { key: t, class: x(["column-item", { active: p.value.includes(t) }]) }, [k(e("input", { "onUpdate:modelValue": s[2] || (s[2] = o => p.value = o), type: "checkbox", value: t }, null, 8, ys), [[ss, p.value]]), e("span", null, i(t), 1)], 2))), 128))])) : (a(), n("div", gs, "请选择 Excel 文件后读取表头并填充可保留列"))]), s[16] || (s[16] = e("div", { class: "section-title" }, "总条数", -1)), e("div", hs, [e("label", bs, [s[9] || (s[9] = e("input", { type: "checkbox", checked: "", disabled: "" }, null, -1)), e("div", null, [s[8] || (s[8] = e("span", { class: "label" }, "当前数据总条数", -1)), e("div", ws, i(B.value) + " 条(不含表头)", 1)])])]), s[17] || (s[17] = e("div", { class: "section-title" }, "拆分模式", -1)), e("div", ks, [e("label", { class: x(["radio-item", { active: u.value === "rows_per_file" }]) }, [k(e("input", { "onUpdate:modelValue": s[3] || (s[3] = t => u.value = t), type: "radio", value: "rows_per_file", name: "split-mode" }, null, 512), [[V, u.value]]), s[10] || (s[10] = e("div", null, [e("span", { class: "label" }, "按每份条数"), e("div", { class: "desc" }, "例如每份 2000 条,最后一份放剩余数据")], -1))], 2), e("label", { class: x(["radio-item", { active: u.value === "parts" }]) }, [k(e("input", { "onUpdate:modelValue": s[4] || (s[4] = t => u.value = t), type: "radio", value: "parts", name: "split-mode" }, null, 512), [[V, u.value]]), s[11] || (s[11] = e("div", null, [e("span", { class: "label" }, "按份数平均拆分"), e("div", { class: "desc" }, "例如拆 5 份,按总条数尽量平均分配")], -1))], 2)]), s[18] || (s[18] = e("div", { class: "section-title" }, "拆分参数", -1)), e("div", Cs, [u.value === "rows_per_file" ? (a(), n("label", Ss, [s[12] || (s[12] = e("span", { class: "label" }, "每份条数", -1)), k(e("input", { "onUpdate:modelValue": s[5] || (s[5] = t => m.value = t), class: "split-number-input", type: "number", min: "1", placeholder: "例如 2000" }, null, 512), [[M, m.value, void 0, { number: !0 }]])])) : (a(), n("label", xs, [s[13] || (s[13] = e("span", { class: "label" }, "拆分份数", -1)), k(e("input", { "onUpdate:modelValue": s[6] || (s[6] = t => y.value = t), class: "split-number-input", type: "number", min: "1", placeholder: "例如 5" }, null, 512), [[M, y.value, void 0, { number: !0 }]])]))]), e("div", Fs, [e("button", { type: "button", class: "btn-run", disabled: g.value, onClick: N }, i(g.value ? "拆分中..." : "开始拆分"), 9, Es), e("span", Ps, i(g.value ? "正在拆分并上传结果,请稍候…" : "选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载"), 1)])]), e("section", $s, [s[23] || (s[23] = e("div", { class: "panel-header" }, "拆分结果", -1)), e("div", Bs, [e("div", Is, [e("div", Rs, [s[19] || (s[19] = e("span", { class: "summary-label" }, "已处理文件", -1)), e("strong", null, i(b.value.total), 1)]), e("div", Us, [s[20] || (s[20] = e("span", { class: "summary-label" }, "成功结果", -1)), e("strong", null, i(b.value.successCount), 1)]), e("div", Vs, [s[21] || (s[21] = e("span", { class: "summary-label" }, "失败文件", -1)), e("strong", null, i(b.value.failedCount), 1)])]), e("div", Ms, [s[22] || (s[22] = e("div", { class: "result-list-header" }, [e("span", null, "拆分结果压缩包列表")], -1)), h.value.length === 0 ? (a(), n("div", Ts, " 暂无拆分结果,完成数据拆分后会在这里展示压缩包文件 ")) : (a(), n("ul", js, [(a(!0), n(S, null, $(h.value, t => (a(), n("li", { key: `${t.resultId || t.outputFilename || t.sourceFilename}-${t.rowCount || 0}`, class: "task-item split-result-item" }, [e("div", Ds, [e("span", { class: "id", title: t.sourceFilename }, i(t.sourceFilename || "-"), 9, Hs), t.outputFilename ? (a(), n("div", Ns, "压缩包文件:" + i(t.outputFilename), 1)) : v("", !0), t.rowCount !== void 0 ? (a(), n("div", As, "压缩包共包含 " + i(t.rowCount) + " 条数据", 1)) : v("", !0), t.entryCount && t.entryCount > 0 ? (a(), n("div", Ks, "压缩包内共 " + i(t.entryCount) + " 个拆分文件", 1)) : v("", !0), t.entries && t.entries.length ? (a(), n("div", zs, " 压缩包内容:" + i(K(t.entries)), 1)) : v("", !0), t.error ? (a(), n("div", qs, "错误信息:" + i(t.error), 1)) : v("", !0)]), e("div", Gs, [e("span", { class: x(["status", t.success ? "success" : "failed"]) }, i(t.success ? "已完成" : "失败"), 3), t.success && t.downloadUrl ? (a(), n("button", { key: 0, type: "button", class: "download", onClick: o => z(t) }, " 下载压缩包 ", 8, Js)) : v("", !0), t.resultId ? (a(), n("button", { key: 1, type: "button", class: "btn-delete", onClick: o => A(t.resultId) }, " 删除 ", 8, Ls)) : v("", !0)])]))), 128))]))])])])])])) } }), Qs = ts(Os, [["__scopeId", "data-v-4df71c3f"]]); ls(Qs).use(as).mount("#app"); +import{d as G,o as J,a,c as n,b as L,B as O,e,F as S,r as $,t as i,f as v,n as x,w as k,u as V,D as M,g as d,h as Q,i as F,E as r,j as W,G as X,H as Y,p as Z,v as ss,I as es,_ as ts,q as ls,s as as}from"./pywebview-p4QBqp2b.js";const ns={class:"page-shell module-page"},os={class:"main-content"},is={class:"left-panel"},rs={class:"upload-zone"},us={class:"selected-files clean-placeholder split-selected-files"},ds={key:0,class:"more-line"},cs={key:1},ps={class:"option-group column-option-group"},vs={class:"column-toolbar"},fs={class:"column-count"},_s={class:"column-actions"},ms={key:0,class:"column-grid"},ys=["value"],gs={key:1,class:"empty-column-state"},hs={class:"option-group"},bs={class:"radio-item active"},ws={class:"desc"},ks={class:"option-group"},Cs={class:"option-group"},Ss={key:0,class:"split-input-card"},xs={key:1,class:"split-input-card"},Fs={class:"run-row"},Es=["disabled"],Ps={class:"loading-msg"},$s={class:"right-panel"},Bs={class:"task-list-wrap"},Is={class:"clean-result-summary"},Rs={class:"summary-card"},Us={class:"summary-card"},Vs={class:"summary-card"},Ms={class:"result-list-wrap"},Ts={key:0,class:"empty-tasks"},js={key:1,class:"task-list clean-result-list"},Ds={class:"left split-result-main"},Hs=["title"],Ns={key:0,class:"files"},As={key:1,class:"time"},Ks={key:2,class:"files"},zs={key:3,class:"files split-entry-list"},qs={key:4,class:"files"},Gs={class:"task-right split-result-actions"},Js=["onClick"],Ls=["onClick"],Os=G({__name:"BrandSplitTab",setup(Ws){const f=d([]),C=d(""),c=d([]),_=d([]),p=d([]),B=d(0),u=d("rows_per_file"),m=d(2e3),y=d(5),g=d(!1),h=d([]),b=d({total:0,successCount:0,failedCount:0,items:[]}),E=Q(()=>f.value.slice(0,8));function T(l){b.value={total:l.length,successCount:l.filter(s=>s.success).length,failedCount:l.filter(s=>!s.success).length,items:l}}async function I(l){const s=F();if(!s?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传桥接能力");const t=[];for(const o of l){const U=typeof o=="string"?o:o.absolutePath,q=typeof o=="string"?void 0:o.relativePath,w=await s.upload_file_to_java(U,q);if(!w?.success||!w.data)throw new Error(w?.error||w?.message||`上传失败:${U}`);t.push(w.data)}return t}async function R(l){const s=await Z(l);_.value=s.headers||[],p.value=[...s.headers||[]],B.value=s.totalRows||0}async function j(l,s){f.value=l,C.value="",c.value=await I(l),c.value.length>0&&await R(c.value[0].fileKey),r.success(s)}async function D(){const l=F();if(!l?.select_brand_xlsx_files){r.warning("当前环境不支持文件选择,请在本机客户端中打开");return}try{const s=await l.select_brand_xlsx_files();if(!s?.length)return;await j(s,`已选择 ${s.length} 个待拆分文件`)}catch(s){r.error(s instanceof Error?s.message:"选择失败")}}async function H(){const l=F();if(!l?.select_brand_folder){r.warning("当前环境不支持文件夹选择,请在本机客户端中打开");return}try{const s=await l.select_brand_folder();if(!s)return;C.value=s.split(/[/\\]/).filter(Boolean).pop()||"";const t=await W(s);if(t.success&&t.items?.length){f.value=t.items.map(o=>o.relativePath),c.value=await I(t.items),c.value.length>0&&await R(c.value[0].fileKey),r.success(`已选择文件夹内 ${t.items.length} 个 xlsx 文件`);return}r.warning(t.error||"该文件夹下没有 xlsx 文件")}catch(s){r.error(s instanceof Error?s.message:"选择失败")}}async function N(){if(!c.value.length){r.warning("请先选择待拆分 Excel 文件或文件夹");return}if(!p.value.length){r.warning("请至少选择一列保留列");return}if(u.value==="rows_per_file"&&(!m.value||m.value<=0)){r.warning("请输入有效的每份条数");return}if(u.value==="parts"&&(!y.value||y.value<=0)){r.warning("请输入有效的拆分份数");return}try{g.value=!0;const l=await X({files:c.value.map(s=>({fileKey:s.fileKey,originalFilename:s.originalFilename,relativePath:s.relativePath})),selectedColumns:p.value,splitMode:u.value,rowsPerFile:u.value==="rows_per_file"&&m.value||void 0,parts:u.value==="parts"&&y.value||void 0,archiveName:c.value.some(s=>!!s.relativePath)&&C.value?C.value:void 0});b.value=l,h.value=l.items||[],await P(),r.success("数据拆分完成")}catch(l){r.error(l instanceof Error?l.message:"数据拆分失败")}finally{g.value=!1}}async function P(){try{const l=await Y();h.value=l.items||[],T(h.value)}catch{}}async function A(l){try{await es(l),await P(),r.success("已删除")}catch(s){r.error(s instanceof Error?s.message:"删除失败")}}function K(l){const s=l.slice(0,4).map(o=>o.rowCount!==void 0?`${o.filename}(${o.rowCount}条)`:o.filename);return l.length-s.length>0?`${s.join("、")} 等 ${l.length} 个文件`:s.join("、")}async function z(l){const s=F();if(!l.downloadUrl){r.warning("当前结果没有下载地址");return}const t=l.outputFilename||`${new Date().toISOString().slice(0,10).replace(/-/g,"")}.zip`;if(s?.save_file_from_url){const o=await s.save_file_from_url(l.downloadUrl,t);o.success?r.success(`已保存:${o.path||t}`):o.error&&o.error!=="用户取消"&&r.error(o.error);return}window.open(l.downloadUrl,"_blank")}return J(()=>{P().catch(()=>{})}),(l,s)=>(a(),n("div",ns,[L(O,{active:"split"}),e("div",os,[e("aside",is,[s[14]||(s[14]=e("div",{class:"section-title"},"选择文件",-1)),e("div",rs,[s[7]||(s[7]=e("div",{class:"hint"},"选择需要拆分的 Excel 文件或文件夹,按规则拆成多个 Excel 文件。",-1)),e("div",{class:"btns"},[e("button",{type:"button",class:"opt-btn",onClick:D},"选择 Excel 文件"),e("button",{type:"button",class:"opt-btn",onClick:H},"选择文件夹")]),e("div",us,[f.value.length?(a(),n(S,{key:0},[(a(!0),n(S,null,$(E.value,t=>(a(),n("span",{key:t},i(t),1))),128)),f.value.length>E.value.length?(a(),n("span",ds," 还有 "+i(f.value.length-E.value.length)+" 个文件未展开显示 ",1)):v("",!0)],64)):(a(),n("span",cs,"暂未选择待拆分文件"))])]),s[15]||(s[15]=e("div",{class:"section-title"},"保留列设置",-1)),e("div",ps,[e("div",vs,[e("span",fs,"已选择 "+i(p.value.length)+" / "+i(_.value.length)+" 列",1),e("div",_s,[e("button",{type:"button",class:"opt-btn small",onClick:s[0]||(s[0]=t=>p.value=[..._.value])},"全选"),e("button",{type:"button",class:"opt-btn small",onClick:s[1]||(s[1]=t=>p.value=[])},"清空")])]),_.value.length?(a(),n("div",ms,[(a(!0),n(S,null,$(_.value,t=>(a(),n("label",{key:t,class:x(["column-item",{active:p.value.includes(t)}])},[k(e("input",{"onUpdate:modelValue":s[2]||(s[2]=o=>p.value=o),type:"checkbox",value:t},null,8,ys),[[ss,p.value]]),e("span",null,i(t),1)],2))),128))])):(a(),n("div",gs,"请选择 Excel 文件后读取表头并填充可保留列"))]),s[16]||(s[16]=e("div",{class:"section-title"},"总条数",-1)),e("div",hs,[e("label",bs,[s[9]||(s[9]=e("input",{type:"checkbox",checked:"",disabled:""},null,-1)),e("div",null,[s[8]||(s[8]=e("span",{class:"label"},"当前数据总条数",-1)),e("div",ws,i(B.value)+" 条(不含表头)",1)])])]),s[17]||(s[17]=e("div",{class:"section-title"},"拆分模式",-1)),e("div",ks,[e("label",{class:x(["radio-item",{active:u.value==="rows_per_file"}])},[k(e("input",{"onUpdate:modelValue":s[3]||(s[3]=t=>u.value=t),type:"radio",value:"rows_per_file",name:"split-mode"},null,512),[[V,u.value]]),s[10]||(s[10]=e("div",null,[e("span",{class:"label"},"按每份条数"),e("div",{class:"desc"},"例如每份 2000 条,最后一份放剩余数据")],-1))],2),e("label",{class:x(["radio-item",{active:u.value==="parts"}])},[k(e("input",{"onUpdate:modelValue":s[4]||(s[4]=t=>u.value=t),type:"radio",value:"parts",name:"split-mode"},null,512),[[V,u.value]]),s[11]||(s[11]=e("div",null,[e("span",{class:"label"},"按份数平均拆分"),e("div",{class:"desc"},"例如拆 5 份,按总条数尽量平均分配")],-1))],2)]),s[18]||(s[18]=e("div",{class:"section-title"},"拆分参数",-1)),e("div",Cs,[u.value==="rows_per_file"?(a(),n("label",Ss,[s[12]||(s[12]=e("span",{class:"label"},"每份条数",-1)),k(e("input",{"onUpdate:modelValue":s[5]||(s[5]=t=>m.value=t),class:"split-number-input",type:"number",min:"1",placeholder:"例如 2000"},null,512),[[M,m.value,void 0,{number:!0}]])])):(a(),n("label",xs,[s[13]||(s[13]=e("span",{class:"label"},"拆分份数",-1)),k(e("input",{"onUpdate:modelValue":s[6]||(s[6]=t=>y.value=t),class:"split-number-input",type:"number",min:"1",placeholder:"例如 5"},null,512),[[M,y.value,void 0,{number:!0}]])]))]),e("div",Fs,[e("button",{type:"button",class:"btn-run",disabled:g.value,onClick:N},i(g.value?"拆分中...":"开始拆分"),9,Es),e("span",Ps,i(g.value?"正在拆分并上传结果,请稍候…":"选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载"),1)])]),e("section",$s,[s[23]||(s[23]=e("div",{class:"panel-header"},"拆分结果",-1)),e("div",Bs,[e("div",Is,[e("div",Rs,[s[19]||(s[19]=e("span",{class:"summary-label"},"已处理文件",-1)),e("strong",null,i(b.value.total),1)]),e("div",Us,[s[20]||(s[20]=e("span",{class:"summary-label"},"成功结果",-1)),e("strong",null,i(b.value.successCount),1)]),e("div",Vs,[s[21]||(s[21]=e("span",{class:"summary-label"},"失败文件",-1)),e("strong",null,i(b.value.failedCount),1)])]),e("div",Ms,[s[22]||(s[22]=e("div",{class:"result-list-header"},[e("span",null,"拆分结果压缩包列表")],-1)),h.value.length===0?(a(),n("div",Ts," 暂无拆分结果,完成数据拆分后会在这里展示压缩包文件 ")):(a(),n("ul",js,[(a(!0),n(S,null,$(h.value,t=>(a(),n("li",{key:`${t.resultId||t.outputFilename||t.sourceFilename}-${t.rowCount||0}`,class:"task-item split-result-item"},[e("div",Ds,[e("span",{class:"id",title:t.sourceFilename},i(t.sourceFilename||"-"),9,Hs),t.outputFilename?(a(),n("div",Ns,"压缩包文件:"+i(t.outputFilename),1)):v("",!0),t.rowCount!==void 0?(a(),n("div",As,"压缩包共包含 "+i(t.rowCount)+" 条数据",1)):v("",!0),t.entryCount&&t.entryCount>0?(a(),n("div",Ks,"压缩包内共 "+i(t.entryCount)+" 个拆分文件",1)):v("",!0),t.entries&&t.entries.length?(a(),n("div",zs," 压缩包内容:"+i(K(t.entries)),1)):v("",!0),t.error?(a(),n("div",qs,"错误信息:"+i(t.error),1)):v("",!0)]),e("div",Gs,[e("span",{class:x(["status",t.success?"success":"failed"])},i(t.success?"已完成":"失败"),3),t.success&&t.downloadUrl?(a(),n("button",{key:0,type:"button",class:"download",onClick:o=>z(t)}," 下载压缩包 ",8,Js)):v("",!0),t.resultId?(a(),n("button",{key:1,type:"button",class:"btn-delete",onClick:o=>A(t.resultId)}," 删除 ",8,Ls)):v("",!0)])]))),128))]))])])])])]))}}),Qs=ts(Os,[["__scopeId","data-v-4df71c3f"]]);ls(Qs).use(as).mount("#app"); diff --git a/app/new_web_source/convert.html b/app/new_web_source/convert.html index 6d5bea2..43b05bf 100644 --- a/app/new_web_source/convert.html +++ b/app/new_web_source/convert.html @@ -1,18 +1,15 @@ - - - - - 格式转换 - 数富AI - - - - - - - -
- - - \ No newline at end of file + + + + 格式转换 - 数富AI + + + + + + +
+ + diff --git a/app/new_web_source/dedupe.html b/app/new_web_source/dedupe.html index e8bf54d..e259439 100644 --- a/app/new_web_source/dedupe.html +++ b/app/new_web_source/dedupe.html @@ -1,18 +1,15 @@ - - - - - 数据去重 - 数富AI - - - - - - - -
- - - \ No newline at end of file + + + + 数据去重 - 数富AI + + + + + + +
+ + diff --git a/app/new_web_source/delete-brand.html b/app/new_web_source/delete-brand.html index 033c537..0148700 100644 --- a/app/new_web_source/delete-brand.html +++ b/app/new_web_source/delete-brand.html @@ -5,9 +5,9 @@ 删除品牌 - 数富AI - - - + + +
diff --git a/app/new_web_source/split.html b/app/new_web_source/split.html index 8bfb7bf..8ba13d3 100644 --- a/app/new_web_source/split.html +++ b/app/new_web_source/split.html @@ -1,18 +1,15 @@ - - - - - 数据拆分 - 数富AI - - - - - - - -
- - - \ No newline at end of file + + + + 数据拆分 - 数富AI + + + + + + +
+ + diff --git a/app/user_data/2026_03_27.log b/app/user_data/2026_03_27.log index 9a16db5..ca584b8 100644 --- a/app/user_data/2026_03_27.log +++ b/app/user_data/2026_03_27.log @@ -750,3 +750,9 @@ Press CTRL+C to quit 127.0.0.1 - - [27/Mar/2026 21:56:32] "GET /brand HTTP/1.1" 200 - ȡб {"success":true,"message":"ɹ","data":{"items":[{"id":594,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774585928180/172.xlsx","relativePath":"F:\\project\\crawler-plugin\\xlsx\\xlsl\\172.xlsx","originalFilename":"172.xlsx"}],"desc":"[Terms] 172.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/40e55435-4ab2-41d5-bdd2-877d4c1d3368/172.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/02f19435-c553-49e3-822b-0906b4a1df97/172.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-27 12:32","updated_at":"2026-03-27 12:32"},{"id":566,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774576241363/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/504514fa-4ec9-4be7-a633-92d38d97f81d/566_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/01980c06-ef1a-4956-9182-60f43c65b93e/brand_task_566.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-27 09:50","updated_at":"2026-03-27 09:50"},{"id":556,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774532942768/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/f1698892-6f3f-4bbf-9953-7e28c14ffdb1/556_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/6f94686d-839e-4b1b-a518-fb16c12d3278/brand_task_556.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-26 21:49","updated_at":"2026-03-26 21:49"},{"id":555,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774531944010/172.xlsx","relativePath":"F:\\project\\crawler-plugin\\xlsx\\xlsl\\172.xlsx","originalFilename":"172.xlsx"}],"desc":"[Terms] 172.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/ee5e2f00-f25e-453f-9c71-8625205d7df9/555_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/e661af11-822c-4295-b87b-1bf23bead54e/brand_task_555.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-26 21:32","updated_at":"2026-03-26 21:32"},{"id":472,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774422424321/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/1270108c-e374-4f40-9963-95bbc3a97762/3.10_result.xlsx?Expires=1774426033&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=lLjhOUtEqv7ZIwR5Q1W6SEFWs7c%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/0f82a790-49f4-4821-9af2-14c5495ee88f/brand_task_472.zip?Expires=1774426033&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=FcyP8TXIX%2B%2FFHcTHW2T85aibxds%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 15:07","updated_at":"2026-03-25 15:07"},{"id":471,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774422242487/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/0c2af99f-5961-438d-b916-9c365f7b130c/3.10_result.xlsx?Expires=1774425851&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=qQMqARZXAhNt%2BBj5tSUuIy%2FBs7s%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/15c0a0f4-ed0f-4383-aae9-fe729cee3478/brand_task_471.zip?Expires=1774425851&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=g6wx7uf8%2Fb1A8OI9etzgFwSVz84%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 15:04","updated_at":"2026-03-25 15:04"},{"id":452,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774413501904/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/7e76b423-4521-45fa-be55-b160d8792458/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774417103&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=LfzPmA8%2Bh52FZXWumWJSdB5mTc8%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/ab3be4c4-89b0-4393-86b3-d93d2a6f262d/brand_task_452.zip?Expires=1774417104&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=wBGt%2BvMYca%2FQMZe0%2BNdBn0iMs%2FI%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 12:38","updated_at":"2026-03-25 12:38"},{"id":451,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774410631939/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/86576b36-654c-49ce-a896-a6255fcbd25c/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774414234&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=IylV9xBzTp0JHK6hMvy%2FWxqWk%2Fo%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/f3b048fd-1763-4bf9-a91b-aaec85c25088/brand_task_451.zip?Expires=1774414234&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=pI7wYI0SinQzIomMi7SG%2FCbHRNM%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 11:50","updated_at":"2026-03-25 11:50"},{"id":448,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774409042698/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/b0626bc6-c2b7-4cf0-8aa5-6944aa9826b1/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774412645&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=lkSoSM9KmrxOxCV1TNtAWHY6WAA%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/3791cffc-9472-44c1-8dcc-fdacdbd938f5/brand_task_448.zip?Expires=1774412645&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=rJXyEwy1QMa2eGyOBr8agblEJE4%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 11:24","updated_at":"2026-03-25 11:24"},{"id":447,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774407652459/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/27136a0b-b165-4b62-b48f-3947977f17e3/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774411254&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=WH6tDvnNwcCMn%2B1Q9e83Hw2uxbo%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/f324fc1e-7c3f-46b0-8e96-5bba55d1238b/brand_task_447.zip?Expires=1774411254&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=K2k0qCSJwxQwjYeLySm6Ogg1%2F%2Fk%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 11:00","updated_at":"2026-03-25 11:00"},{"id":446,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774407614781/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/93c70c70-eb34-460e-9811-5ed89c53168a/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774411217&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=3cL1SLhQyJhOnAtvxWt%2F9GivxVQ%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/f8172c74-0515-47ce-b6a4-0e53eee8ad72/brand_task_446.zip?Expires=1774411217&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=egV94cI2jmRedmwRtD9f2b4G5oQ%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 11:00","updated_at":"2026-03-25 11:00"},{"id":445,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774405413979/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/89350dfe-a9b8-4d7c-9fc1-1e8357d59903/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774409016&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=DNWraBRPYncAmjOmivEDngi7%2B7I%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/48323ad7-1d9a-41c4-8986-48f32543e45b/brand_task_445.zip?Expires=1774409016&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=S2tCOouK%2Fez7isJXMr6h2wevg24%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 10:23","updated_at":"2026-03-25 10:23"},{"id":444,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774402781580/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/8216065d-2117-4f50-9a04-8ed2ee173cce/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774406384&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=QCGhv9e2SqOAYCMRwjtgf8B9JKM%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/3ae8f526-1612-45ff-96ef-ff554831d9b8/brand_task_444.zip?Expires=1774406384&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=cCtY%2F%2FQQlm8fyGT9cT4C8%2FdFMRE%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 09:39","updated_at":"2026-03-25 09:39"},{"id":443,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774369488874/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Simple] .xlsx","strategy":"Simple","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/af697aeb-8300-4b37-8bae-e3bf603ec2f6/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774373107&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=2KNSRHnrxqN0bHotH%2BJwavBKNJk%3D"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/45204d93-cba1-4ea4-9faa-2380d0ea102c/brand_task_443.zip?Expires=1774373107&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=k%2BbOG15pH01aotAHLxo%2BQi16BYI%3D"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-25 00:24","updated_at":"2026-03-25 00:25"},{"id":441,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774364667545/.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\²\\Ա(3)\\.xlsx","originalFilename":".xlsx"}],"desc":"[Terms] .xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/9c4acd0b-f9c4-4975-b1f9-ca36f33549cc/%E5%8D%95%E7%8B%AC%E6%B5%8B%E8%AF%95_result.xlsx?Expires=1774368279&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=dvFjyQpf1jsPHyLkOYcdvp844fI%3D"]},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-24 23:04","updated_at":"2026-03-24 23:04"},{"id":440,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774362036602/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"cancelled","result_paths":null,"error_message":null,"progress_current":0,"progress_total":1,"created_at":"2026-03-24 22:20","updated_at":"2026-03-24 22:22"},{"id":439,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774362007935/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"cancelled","result_paths":null,"error_message":null,"progress_current":0,"progress_total":1,"created_at":"2026-03-24 22:20","updated_at":"2026-03-24 22:22"},{"id":438,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774361938466/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"cancelled","result_paths":null,"error_message":null,"progress_current":0,"progress_total":1,"created_at":"2026-03-24 22:19","updated_at":"2026-03-24 22:22"},{"id":437,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774361715874/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/f26c0103-07c8-4c48-b93c-81e013026951/3.10_result.xlsx?Expires=1774365331&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=Ucg8WbDhbFPkaUfq2%2Ba2XXKQlhI%3D"]},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-24 22:15","updated_at":"2026-03-24 22:15"},{"id":436,"file_paths":[{"fileUrl":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1/1774361459893/3.10.xlsx","relativePath":"D:\\˽\\maixiang_AI\\ͼƬ\\ƷƲɼ\\brand_task_76\\Դļ\\3.10.xlsx","originalFilename":"3.10.xlsx"}],"desc":"[Terms] 3.10.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/result/brand/ce1ff1ce-2d1d-4983-a06e-5c255741aca7/3.10_result.xlsx?Expires=1774365074&OSSAccessKeyId=LTAI5tNpyvzMNz9f2dHarsm8&Signature=cnAJKP%2BczCSlIvcZxYfWcXNBjlM%3D"]},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-24 22:11","updated_at":"2026-03-24 22:11"},{"id":275,"file_paths":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_input/1773742796234_1_Ʒ_.xlsx"],"desc":"[Terms] Ʒ_.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/275/tmpkey621jz_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/275/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 18:19","updated_at":"2026-03-17 18:19"},{"id":274,"file_paths":["C:\\Users\\Administrator\\Desktop\\3.17\\\\½ļ (2)\\-3-17.xlsx"],"desc":"[Simple] -3-17.xlsx","strategy":"Simple","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/274/-3-17_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/274/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 17:57","updated_at":"2026-03-17 18:23"},{"id":273,"file_paths":["C:\\Users\\Administrator\\Desktop\\3.17\\\\½ļ (2)\\-3-17.xlsx"],"desc":"[Terms] -3-17.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/273/-3-17_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/273/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 17:47","updated_at":"2026-03-17 17:55"},{"id":268,"file_paths":["C:\\Users\\Administrator\\Desktop\\3.16\\½ļ\\ܺ3-17.xlsx"],"desc":"[Simple] ܺ3-17.xlsx","strategy":"Simple","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/268/ܺ3-17_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/268/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 15:26","updated_at":"2026-03-17 16:05"},{"id":267,"file_paths":["C:\\Users\\Administrator\\Desktop\\3.16\\½ļ\\ܺ3-17.xlsx"],"desc":"[Terms] ܺ3-17.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/267/ܺ3-17_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/267/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 15:09","updated_at":"2026-03-17 15:25"},{"id":262,"file_paths":["C:\\Users\\Administrator\\Desktop\\½ļ (32)\\ƽƷ.xlsx","C:\\Users\\Administrator\\Desktop\\½ļ (32)\\Ʒ.xlsx","C:\\Users\\Administrator\\Desktop\\½ļ (32)\\Ʒ.xlsx"],"desc":"[Simple] ƽƷ.xlsx, Ʒ.xlsx, Ʒ.xlsx","strategy":"Simple","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/262/ƽƷ_result.xlsx","https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/262/Ʒ_result.xlsx","https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/262/Ʒ_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/262/result.zip"},"error_message":null,"progress_current":3,"progress_total":3,"created_at":"2026-03-17 13:58","updated_at":"2026-03-17 14:18"},{"id":260,"file_paths":["C:\\Users\\Administrator\\Desktop\\½ļ (32)\\Ʒ.xlsx"],"desc":"[Terms] Ʒ.xlsx","strategy":"Terms","status":"success","result_paths":{"urls":["https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/260/Ʒ_result.xlsx"],"zip_url":"https://nanri-ai-images.oss-cn-hangzhou.aliyuncs.com/nanri-image/brand_results/260/result.zip"},"error_message":null,"progress_current":1,"progress_total":1,"created_at":"2026-03-17 13:51","updated_at":"2026-03-17 13:55"}]}} 127.0.0.1 - - [27/Mar/2026 21:56:32] "GET /api/brand/tasks HTTP/1.1" 200 - +[pywebview] WindowAPI.__init__..() missing 1 required positional argument: '_' +Traceback (most recent call last): + File "C:\Users\\AppData\Local\Programs\Python\Python312\Lib\site-packages\webview\event.py", line 45, in execute + value = func(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^ +TypeError: WindowAPI.__init__..() missing 1 required positional argument: '_' diff --git a/app/version.txt b/app/version.txt index 8127c49..52a718a 100644 --- a/app/version.txt +++ b/app/version.txt @@ -1 +1 @@ -{"version": "1.0.17"} \ No newline at end of file +{"version": "1.0.13"} \ No newline at end of file diff --git a/backend-java/aiimage-backend.log.2026-03-27.0.gz b/backend-java/aiimage-backend.log.2026-03-27.0.gz new file mode 100644 index 0000000..d1f0d45 Binary files /dev/null and b/backend-java/aiimage-backend.log.2026-03-27.0.gz differ diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java index 9243bf3..a055d93 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java @@ -28,4 +28,16 @@ public class ZiniaoProperties { private long shopsCacheMinutes; private int connectTimeoutSeconds; private int readTimeoutSeconds; + + /** + * 店铺匹配时最多尝试多少个 apiKey; + * null/空/<=0 表示不限制(不推荐)。 + */ + private Integer keyScanMaxKeys; + + /** + * 店铺匹配时最多扫描多少秒; + * null/空/<=0 表示不限制(不推荐)。 + */ + private Integer keyScanMaxSeconds; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index 45e7971..34b6600 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -15,6 +15,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,6 +25,13 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + @RestController @RequiredArgsConstructor @@ -59,12 +68,50 @@ public class DeleteBrandRunController { return ApiResponse.success(null); } + @GetMapping("/tasks/{taskId}") + @Operation(summary = "获取删除品牌任务详情", description = "根据任务ID返回任务基础信息 + Redis 中的实时行级进度(文件/国家/ASIN)。") + public ApiResponse getTask( + @PathVariable Long taskId) { + return ApiResponse.success(deleteBrandRunService.getTaskDetail(taskId)); + } + @PostMapping("/tasks/{taskId}/result") - @Operation(summary = "提交删除品牌处理结果", description = "前端插件处理完成后回传结果;后端将基于 run 时缓存的原始数据重组结果文件并上传(该逻辑后续实现)。") + @Operation(summary = "提交删除品牌处理结果", description = "插件/前端分片回传处理结果;后端累计分片并推进进度,全部完成后再执行最终组装与落库。") public ApiResponse submitResult( @PathVariable Long taskId, @Valid @RequestBody DeleteBrandSubmitResultRequest request) { deleteBrandRunService.submitResult(taskId, request); return ApiResponse.success(null); } + + @GetMapping("/tasks/{taskId}/download") + @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url 直接保存。") + public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) { + String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId); + String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "任务无可下载结果"); + } + if (filename == null || filename.isBlank()) { + String rawPath = URI.create(url).getPath(); + filename = rawPath == null ? "delete-brand_" + taskId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1); + } + + try { + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename); + try (InputStream in = URI.create(url).toURL().openStream()) { + byte[] buffer = new byte[65536]; + int read; + while ((read = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, read); + } + response.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java new file mode 100644 index 0000000..bbe82db --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.deletebrand.model.cache; + +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class DeleteBrandParsedFileCacheDto { + private String fileKey; + private String sourceFilename; + private String shopName; + private Integer totalRows; + private List countries = new ArrayList<>(); + private List previewRows = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java index 0134846..dd99831 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java @@ -11,10 +11,37 @@ import java.util.List; @Data @Schema(description = "删除品牌单文件结果回传") public class DeleteBrandResultFileDto { + @Schema(description = "稳定文件标识,优先使用 run 阶段返回的 fileKey") + private String fileKey; + @NotBlank(message = "sourceFilename 不能为空") @Schema(description = "源文件名") private String sourceFilename; + @Schema(description = "当前是第几个文件,从 1 开始") + private Integer fileIndex; + + @Schema(description = "总文件数") + private Integer fileTotal; + + @Schema(description = "当前分片序号,从 1 开始") + private Integer chunkIndex; + + @Schema(description = "当前文件总分片数") + private Integer chunkTotal; + + @Schema(description = "当前文件已处理行数") + private Integer processedRows; + + @Schema(description = "当前文件总行数") + private Integer totalRows; + + @Schema(description = "当前国家") + private String currentCountry; + + @Schema(description = "当前 ASIN") + private String currentAsin; + @Valid @Schema(description = "按国家分组的处理结果") private List countries = new ArrayList<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java index a41c87e..354e274 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java @@ -11,6 +11,9 @@ import java.util.List; @Data @Schema(description = "删除品牌结果提交请求") public class DeleteBrandSubmitResultRequest { + @Schema(description = "可选的提交批次ID,用于幂等追踪") + private String submissionId; + @Valid @NotEmpty(message = "files 不能为空") @Schema(description = "按源文件维度提交结果") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java new file mode 100644 index 0000000..c6a346b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务实时进度详情") +public class DeleteBrandLineProgressInfoVo { + @Schema(description = "当前处理到第几个文件,从 1 开始") + private Integer file_index; + + @Schema(description = "本次任务总文件数") + private Integer file_total; + + @Schema(description = "当前处理文件名") + private String file_name; + + @Schema(description = "当前文件已处理行数") + private Integer current_line; + + @Schema(description = "当前文件总行数") + private Integer total_lines; + + @Schema(description = "当前国家") + private String current_country; + + @Schema(description = "当前 ASIN") + private String current_asin; + + @Schema(description = "已完成文件数") + private Integer finished_files; + + @Schema(description = "当前阶段:crawling/assembling/uploading/failed") + private String phase; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java new file mode 100644 index 0000000..83fd05b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务实时行级进度") +public class DeleteBrandLineProgressVo { + @Schema(description = "当前是否存在实时进度") + private boolean has_progress; + + @Schema(description = "实时进度详情") + private DeleteBrandLineProgressInfoVo info; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java index 32576ef..74af8a7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java @@ -12,6 +12,9 @@ public class DeleteBrandResultItemVo { @Schema(description = "结果记录ID") private Long resultId; + @Schema(description = "上传文件 key / 稳定文件标识") + private String fileKey; + @Schema(description = "源文件名") private String sourceFilename; @@ -33,6 +36,12 @@ public class DeleteBrandResultItemVo { @Schema(description = "总行数") private Integer totalRows; + @Schema(description = "输出文件名") + private String outputFilename; + + @Schema(description = "下载地址") + private String downloadUrl; + @Schema(description = "任务ID") private Long taskId; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java new file mode 100644 index 0000000..091fc18 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务详情响应") +public class DeleteBrandTaskDetailVo { + @Schema(description = "任务基础信息") + private DeleteBrandTaskItemVo task; + + @Schema(description = "实时行级进度信息,来自 Redis") + private DeleteBrandLineProgressVo line_progress; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java new file mode 100644 index 0000000..88b9b98 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java @@ -0,0 +1,46 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "删除品牌任务基础信息") +public class DeleteBrandTaskItemVo { + @Schema(description = "任务ID") + private Long id; + + @Schema(description = "任务编号") + private String taskNo; + + @Schema(description = "任务状态") + private String status; + + @Schema(description = "源文件数") + private Integer sourceFileCount; + + @Schema(description = "成功文件数") + private Integer successFileCount; + + @Schema(description = "失败文件数") + private Integer failedFileCount; + + @Schema(description = "错误信息") + private String errorMessage; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + @Schema(description = "完成时间") + private LocalDateTime finishedAt; + + @Schema(description = "下载地址(任务级)") + private String downloadUrl; + + @Schema(description = "下载文件名(任务级)") + private String downloadFilename; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index 61c5eb6..29205c4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -4,8 +4,10 @@ import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto; @@ -15,40 +17,55 @@ import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRe import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryAsinVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressInfoVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDetailVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskItemVo; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; @Service @RequiredArgsConstructor public class DeleteBrandRunService { private static final String MODULE_TYPE = "DELETE_BRAND"; + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; private final LocalFileStorageService localFileStorageService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; public DeleteBrandRunVo run(DeleteBrandRunRequest request) { if (request.getUserId() == null || request.getUserId() <= 0) { @@ -68,15 +85,16 @@ public class DeleteBrandRunService { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.insert(task); - // 先不与紫鸟联动:仅解析 Excel 并返回解析 payload(shop 匹配和 openStoreUrl 先不生成) List items = new ArrayList<>(); - int successCount = 0; - int failedCount = 0; - - Map parsedPayloadBySourceFilename = new LinkedHashMap<>(); + int parsedSuccessCount = 0; + int parsedFailedCount = 0; + Map parsedPayloadByFileIdentity = new LinkedHashMap<>(); + Map storeMatchByShopName = new LinkedHashMap<>(); for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); + item.setTaskId(task.getId()); + item.setFileKey(sourceFile.getFileKey()); item.setSourceFilename(sourceFile.getOriginalFilename()); item.setShopName(resolveShopName(sourceFile)); try { @@ -85,7 +103,6 @@ public class DeleteBrandRunService { throw new BusinessException("上传文件不存在,请重新上传"); } ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile); - item.setTaskId(task.getId()); item.setCountryCount(parsed.countries().size()); item.setCountries(parsed.countries()); item.setTotalRows(parsed.totalRows()); @@ -93,7 +110,11 @@ public class DeleteBrandRunService { item.setPreviewRows(parsed.previewRows()); try { - com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null); + String normalizedShopName = normalizeShopName(item.getShopName()); + com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = normalizedShopName.isBlank() + ? new com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult(false, null, null, null, null, null) + : storeMatchByShopName.computeIfAbsent(normalizedShopName, + ignored -> ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null)); item.setMatched(matchResult.matched()); if (matchResult.matched()) { item.setShopId(matchResult.shopId()); @@ -112,21 +133,30 @@ public class DeleteBrandRunService { } } - parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed); + String fileIdentity = resolveFileIdentity(sourceFile); + DeleteBrandParsedFileCacheDto cacheDto = new DeleteBrandParsedFileCacheDto(); + cacheDto.setFileKey(sourceFile.getFileKey()); + cacheDto.setSourceFilename(sourceFile.getOriginalFilename()); + cacheDto.setShopName(item.getShopName()); + cacheDto.setTotalRows(parsed.totalRows()); + cacheDto.setCountries(parsed.countries()); + cacheDto.setPreviewRows(parsed.previewRows()); + parsedPayloadByFileIdentity.put(fileIdentity, cacheDto); item.setSuccess(true); - successCount++; + parsedSuccessCount++; FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(item.getSourceFilename()); - resultEntity.setResultFilename(item.getShopName()); + resultEntity.setSourceFileUrl(sourceFile.getFileKey()); + resultEntity.setResultFilename(null); resultEntity.setResultFileUrl(null); resultEntity.setResultFileSize(0L); - resultEntity.setResultContentType("application/json"); + resultEntity.setResultContentType(CONTENT_TYPE_XLSX); resultEntity.setRowCount(parsed.totalRows()); - resultEntity.setSuccess(1); + resultEntity.setSuccess(0); resultEntity.setUserId(request.getUserId()); resultEntity.setCreatedAt(LocalDateTime.now()); fileResultMapper.insert(resultEntity); @@ -134,12 +164,13 @@ public class DeleteBrandRunService { } catch (Exception ex) { item.setSuccess(false); item.setError(ex.getMessage()); - failedCount++; + parsedFailedCount++; FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(sourceFile.getOriginalFilename()); + resultEntity.setSourceFileUrl(sourceFile.getFileKey()); resultEntity.setSuccess(0); resultEntity.setErrorMessage(ex.getMessage()); resultEntity.setUserId(request.getUserId()); @@ -150,23 +181,25 @@ public class DeleteBrandRunService { items.add(item); } - task.setSuccessFileCount(successCount); - task.setFailedFileCount(failedCount); + task.setSuccessFileCount(0); + task.setFailedFileCount(parsedFailedCount); task.setResultJson(JSONUtil.toJsonStr(items)); task.setUpdatedAt(LocalDateTime.now()); - task.setFinishedAt(LocalDateTime.now()); - task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED"); + if (parsedSuccessCount <= 0) { + task.setStatus("FAILED"); + task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null); + task.setFinishedAt(LocalDateTime.now()); + } fileTaskMapper.updateById(task); - if (!parsedPayloadBySourceFilename.isEmpty()) { - deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename); + if (!parsedPayloadByFileIdentity.isEmpty()) { + deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity); } - DeleteBrandRunVo vo = new DeleteBrandRunVo(); vo.setTotal(request.getFiles().size()); - vo.setSuccessCount(successCount); - vo.setFailedCount(failedCount); + vo.setSuccessCount(parsedSuccessCount); + vo.setFailedCount(parsedFailedCount); vo.setItems(items); return vo; } @@ -182,8 +215,13 @@ public class DeleteBrandRunService { .map(entity -> { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); item.setResultId(entity.getId()); + item.setTaskId(entity.getTaskId()); + item.setFileKey(entity.getSourceFileUrl()); item.setSourceFilename(entity.getSourceFilename()); - item.setShopName(entity.getResultFilename()); + item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename())); + item.setOutputFilename(entity.getResultFilename()); + item.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl())); + item.setTotalRows(entity.getRowCount()); item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); item.setError(entity.getErrorMessage()); return item; @@ -291,6 +329,13 @@ public class DeleteBrandRunService { return value.replace(" ", "").trim().toUpperCase(Locale.ROOT); } + private String normalizeShopName(String value) { + if (value == null) { + return ""; + } + return value.replace("\u3000", " ").trim(); + } + private List resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) { List pairs = new ArrayList<>(); int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum()); @@ -334,6 +379,139 @@ public class DeleteBrandRunService { private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) { } + private String resolveFileIdentity(DeleteBrandSourceFileDto sourceFile) { + if (sourceFile != null && sourceFile.getFileKey() != null && !sourceFile.getFileKey().isBlank()) { + return sourceFile.getFileKey().trim(); + } + return sourceFile == null ? "" : String.valueOf(sourceFile.getOriginalFilename()); + } + + public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + + DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo(); + taskVo.setId(task.getId()); + taskVo.setTaskNo(task.getTaskNo()); + taskVo.setStatus(task.getStatus()); + taskVo.setSourceFileCount(task.getSourceFileCount()); + taskVo.setSuccessFileCount(task.getSuccessFileCount()); + taskVo.setFailedFileCount(task.getFailedFileCount()); + taskVo.setErrorMessage(task.getErrorMessage()); + taskVo.setCreatedAt(task.getCreatedAt()); + taskVo.setUpdatedAt(task.getUpdatedAt()); + taskVo.setFinishedAt(task.getFinishedAt()); + taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId())); + taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId())); + + DeleteBrandLineProgressVo progressVo = buildLineProgress(taskId); + + DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo(); + detail.setTask(taskVo); + detail.setLine_progress(progressVo); + return detail; + } + + private DeleteBrandLineProgressVo buildLineProgress(Long taskId) { + Map progress = deleteBrandTaskCacheService.getProgress(taskId); + DeleteBrandLineProgressVo vo = new DeleteBrandLineProgressVo(); + if (progress == null || progress.isEmpty()) { + vo.setHas_progress(false); + vo.setInfo(null); + return vo; + } + DeleteBrandLineProgressInfoVo info = new DeleteBrandLineProgressInfoVo(); + info.setFile_index(parseInt(progress.get("file_index"))); + info.setFile_total(parseInt(progress.get("file_total"))); + info.setFile_name(stringValue(progress.get("file_name"))); + info.setCurrent_line(parseInt(progress.get("current_line"))); + info.setTotal_lines(parseInt(progress.get("total_lines"))); + info.setCurrent_country(stringValue(progress.get("current_country"))); + info.setCurrent_asin(stringValue(progress.get("current_asin"))); + info.setFinished_files(parseInt(progress.get("finished_files"))); + info.setPhase(stringValue(progress.get("phase"))); + vo.setHas_progress(true); + vo.setInfo(info); + return vo; + } + + private Integer parseInt(Object value) { + if (value == null) { + return null; + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (Exception ex) { + return null; + } + } + + private String stringValue(Object value) { + if (value == null) { + return null; + } + String raw = String.valueOf(value); + return raw.isBlank() ? null : raw; + } + + private String resolveFileIdentity(DeleteBrandResultFileDto fileDto) { + if (fileDto != null && fileDto.getFileKey() != null && !fileDto.getFileKey().isBlank()) { + return fileDto.getFileKey().trim(); + } + return fileDto == null ? "" : blankToEmpty(fileDto.getSourceFilename()); + } + + private void validateChunk(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) { + if (fileDto.getChunkIndex() == null || fileDto.getChunkTotal() == null + || fileDto.getChunkIndex() <= 0 || fileDto.getChunkTotal() <= 0 + || fileDto.getChunkIndex() > fileDto.getChunkTotal()) { + throw new BusinessException("分片参数不合法"); + } + if (fileDto.getTotalRows() != null && parsedFile.getTotalRows() != null && fileDto.getTotalRows() > parsedFile.getTotalRows()) { + throw new BusinessException("totalRows 不合法"); + } + } + + private boolean isFileCompleted(DeleteBrandResultFileDto fileDto) { + return fileDto.getChunkIndex() != null && fileDto.getChunkTotal() != null && fileDto.getChunkIndex().equals(fileDto.getChunkTotal()); + } + + private int defaultInt(Integer value, int defaultValue) { + return value == null ? defaultValue : value; + } + + private String blankToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private void validateResultFileAgainstParsed(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) { + Map byCountry = new LinkedHashMap<>(); + for (DeleteBrandCountryGroupVo group : parsedFile.getCountries()) { + byCountry.put(group.getCountry(), group); + } + + for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) { + DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry()); + if (group == null) { + throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry()); + } + + Map allowed = new LinkedHashMap<>(); + for (DeleteBrandCountryAsinVo asinVo : group.getItems()) { + allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo); + } + + for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) { + String key = normalizeAsinKey(itemDto.getAsin()); + if (key.isBlank() || !allowed.containsKey(key)) { + throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin()); + } + } + } + } + public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -347,52 +525,366 @@ public class DeleteBrandRunService { throw new BusinessException("任务不存在"); } - Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, - new TypeReference>() { + Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, + new TypeReference>() { }); if (parsedPayload == null || parsedPayload.isEmpty()) { throw new BusinessException("任务原始数据已过期,请重新执行解析"); } + task.setStatus("RUNNING"); + task.setErrorMessage(null); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(null); + fileTaskMapper.updateById(task); + for (DeleteBrandResultFileDto fileDto : request.getFiles()) { - if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) { - throw new BusinessException("sourceFilename 不能为空"); + String fileIdentity = resolveFileIdentity(fileDto); + if (fileIdentity.isBlank()) { + throw new BusinessException("fileKey/sourceFilename 不能为空"); } - ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename()); + DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity); if (parsedFile == null) { - throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename()); + throw new BusinessException("文件标识不匹配: " + fileIdentity); } - Map byCountry = new LinkedHashMap<>(); - for (DeleteBrandCountryGroupVo group : parsedFile.countries()) { - byCountry.put(group.getCountry(), group); + validateChunk(fileDto, parsedFile); + validateResultFileAgainstParsed(fileDto, parsedFile); + deleteBrandTaskCacheService.mergeResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto); + + Map progress = new LinkedHashMap<>(); + progress.put("phase", DeleteBrandTaskCacheService.PHASE_CRAWLING); + progress.put("file_index", String.valueOf(defaultInt(fileDto.getFileIndex(), 0))); + progress.put("file_total", String.valueOf(defaultInt(fileDto.getFileTotal(), parsedPayload.size()))); + progress.put("file_name", blankToEmpty(fileDto.getSourceFilename())); + progress.put("current_line", String.valueOf(defaultInt(fileDto.getProcessedRows(), 0))); + progress.put("total_lines", String.valueOf(defaultInt(fileDto.getTotalRows(), parsedFile.getTotalRows() == null ? 0 : parsedFile.getTotalRows()))); + progress.put("current_country", blankToEmpty(fileDto.getCurrentCountry())); + progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin())); + progress.put("updated_at", String.valueOf(System.currentTimeMillis())); + progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis())); + deleteBrandTaskCacheService.saveProgress(taskId, progress); + } + + Map> mergedByFile = loadMergedChunks(taskId); + int finishedFiles = countCompletedFiles(mergedByFile); + + Map progress = new LinkedHashMap<>(); + progress.put("finished_files", String.valueOf(finishedFiles)); + progress.put("updated_at", String.valueOf(System.currentTimeMillis())); + deleteBrandTaskCacheService.saveProgress(taskId, progress); + + task.setSuccessFileCount(finishedFiles); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + if (finishedFiles < parsedPayload.size()) { + return; + } + + finalizeTask(task, parsedPayload, mergedByFile); + } + + private Map> loadMergedChunks(Long taskId) { + Map> groupedJson = deleteBrandTaskCacheService.groupResultChunkJsonByFile(taskId); + Map> merged = new LinkedHashMap<>(); + for (Map.Entry> entry : groupedJson.entrySet()) { + List chunks = new ArrayList<>(); + for (String raw : entry.getValue()) { + try { + chunks.add(objectMapper.readValue(raw, DeleteBrandResultFileDto.class)); + } catch (Exception ex) { + throw new BusinessException("读取删除品牌结果分片失败"); + } + } + chunks.sort(Comparator.comparing(DeleteBrandResultFileDto::getChunkIndex, Comparator.nullsLast(Integer::compareTo))); + merged.put(entry.getKey(), chunks); + } + return merged; + } + + private int countCompletedFiles(Map> mergedByFile) { + int finishedFiles = 0; + for (List chunks : mergedByFile.values()) { + if (isMergedFileCompleted(chunks)) { + finishedFiles++; + } + } + return finishedFiles; + } + + private boolean isMergedFileCompleted(List chunks) { + if (chunks == null || chunks.isEmpty()) { + return false; + } + Integer chunkTotal = chunks.get(0).getChunkTotal(); + if (chunkTotal == null || chunkTotal <= 0 || chunks.size() < chunkTotal) { + return false; + } + Set indexes = new LinkedHashSet<>(); + for (DeleteBrandResultFileDto chunk : chunks) { + if (chunk.getChunkTotal() == null || !chunkTotal.equals(chunk.getChunkTotal()) || chunk.getChunkIndex() == null) { + return false; + } + indexes.add(chunk.getChunkIndex()); + } + for (int i = 1; i <= chunkTotal; i++) { + if (!indexes.contains(i)) { + return false; + } + } + return true; + } + + private void finalizeTask(FileTaskEntity task, + Map parsedPayload, + Map> mergedByFile) { + try { + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_ASSEMBLING, + "finished_files", String.valueOf(parsedPayload.size()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + + List resultEntities = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getTaskId, task.getId()) + .orderByAsc(FileResultEntity::getId)); + Map resultEntityByIdentity = new LinkedHashMap<>(); + for (FileResultEntity entity : resultEntities) { + String identity = entity.getSourceFileUrl(); + if (identity == null || identity.isBlank()) { + identity = blankToEmpty(entity.getSourceFilename()); + } + if (!identity.isBlank()) { + resultEntityByIdentity.putIfAbsent(identity, entity); + } } - for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) { - DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry()); - if (group == null) { - throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry()); + int successCount = 0; + List finalItems = new ArrayList<>(); + for (Map.Entry entry : parsedPayload.entrySet()) { + String fileIdentity = entry.getKey(); + DeleteBrandParsedFileCacheDto parsedFile = entry.getValue(); + List chunks = mergedByFile.get(fileIdentity); + if (!isMergedFileCompleted(chunks)) { + throw new BusinessException("存在未完成的结果分片: " + parsedFile.getSourceFilename()); } + MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks); + File outputFile = buildResultWorkbook(task.getId(), parsedFile, mergedFile); - Map allowed = new LinkedHashMap<>(); - for (DeleteBrandCountryAsinVo asinVo : group.getItems()) { - allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo); + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_UPLOADING, + "file_name", blankToEmpty(parsedFile.getSourceFilename()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + + String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE); + FileResultEntity resultEntity = resultEntityByIdentity.get(fileIdentity); + if (resultEntity == null) { + throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename()); } + resultEntity.setResultFilename(outputFile.getName()); + resultEntity.setResultFileUrl(objectKey); + resultEntity.setResultFileSize(outputFile.length()); + resultEntity.setResultContentType(CONTENT_TYPE_XLSX); + resultEntity.setRowCount(parsedFile.getTotalRows()); + resultEntity.setSuccess(1); + resultEntity.setErrorMessage(null); + fileResultMapper.updateById(resultEntity); + DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); + item.setResultId(resultEntity.getId()); + item.setTaskId(task.getId()); + item.setFileKey(parsedFile.getFileKey()); + item.setSourceFilename(parsedFile.getSourceFilename()); + item.setShopName(parsedFile.getShopName()); + item.setOutputFilename(outputFile.getName()); + item.setDownloadUrl(ossStorageService.getPublicUrl(objectKey)); + item.setTotalRows(parsedFile.getTotalRows()); + item.setCountryCount(parsedFile.getCountries() == null ? 0 : parsedFile.getCountries().size()); + item.setCountries(parsedFile.getCountries()); + item.setPreviewRows(parsedFile.getPreviewRows()); + item.setTruncated(false); + item.setSuccess(true); + finalItems.add(item); + successCount++; + } + + task.setStatus("SUCCESS"); + task.setSuccessFileCount(successCount); + task.setErrorMessage(null); + task.setResultJson(JSONUtil.toJsonStr(finalItems)); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", "success", + "file_index", String.valueOf(parsedPayload.size()), + "file_total", String.valueOf(parsedPayload.size()), + "finished_files", String.valueOf(successCount), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + } catch (Exception ex) { + task.setStatus("FAILED"); + task.setErrorMessage(ex.getMessage()); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_FAILED, + "finished_files", String.valueOf(task.getSuccessFileCount() == null ? 0 : task.getSuccessFileCount()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + if (ex instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException("删除品牌结果组装失败"); + } + } + + private MergedDeleteBrandFile mergeChunks(DeleteBrandParsedFileCacheDto parsedFile, List chunks) { + Integer chunkTotal = chunks.get(0).getChunkTotal(); + for (DeleteBrandResultFileDto chunk : chunks) { + if (!chunkTotal.equals(chunk.getChunkTotal())) { + throw new BusinessException("chunkTotal 不一致: " + parsedFile.getSourceFilename()); + } + } + + Map> processedByCountry = new LinkedHashMap<>(); + int processedRows = 0; + for (DeleteBrandResultFileDto chunk : chunks) { + processedRows = Math.max(processedRows, defaultInt(chunk.getProcessedRows(), 0)); + for (DeleteBrandProcessedCountryDto countryDto : chunk.getCountries()) { + Map byAsin = processedByCountry.computeIfAbsent(countryDto.getCountry(), ignored -> new LinkedHashMap<>()); for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) { - String key = normalizeAsinKey(itemDto.getAsin()); - if (key.isBlank() || !allowed.containsKey(key)) { - throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin()); - } + byAsin.put(normalizeAsinKey(itemDto.getAsin()), itemDto); } } } - throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑"); + List mergedCountries = new ArrayList<>(); + for (DeleteBrandCountryGroupVo parsedCountry : parsedFile.getCountries()) { + Map processedItems = processedByCountry.get(parsedCountry.getCountry()); + if (processedItems == null) { + throw new BusinessException("缺少国家结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry()); + } + DeleteBrandProcessedCountryDto mergedCountry = new DeleteBrandProcessedCountryDto(); + mergedCountry.setCountry(parsedCountry.getCountry()); + for (DeleteBrandCountryAsinVo parsedAsin : parsedCountry.getItems()) { + DeleteBrandCountryResultItemDto processed = processedItems.get(normalizeAsinKey(parsedAsin.getAsin())); + if (processed == null) { + throw new BusinessException("缺少 ASIN 结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry() + " / " + parsedAsin.getAsin()); + } + DeleteBrandCountryResultItemDto row = new DeleteBrandCountryResultItemDto(); + row.setAsin(parsedAsin.getAsin()); + row.setStatus(processed.getStatus()); + mergedCountry.getItems().add(row); + } + mergedCountries.add(mergedCountry); + } + + return new MergedDeleteBrandFile( + blankToEmpty(chunks.get(chunks.size() - 1).getCurrentCountry()), + blankToEmpty(chunks.get(chunks.size() - 1).getCurrentAsin()), + processedRows, + mergedCountries + ); + } + + private File buildResultWorkbook(Long taskId, + DeleteBrandParsedFileCacheDto parsedFile, + MergedDeleteBrandFile mergedFile) { + File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId))); + File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx")); + + try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) { + Sheet sheet = workbook.createSheet("删除品牌结果"); + Row headerRow = sheet.createRow(0); + headerRow.createCell(0).setCellValue("国家"); + headerRow.createCell(1).setCellValue("删除ASIN"); + headerRow.createCell(2).setCellValue("状态"); + + int rowIndex = 1; + for (DeleteBrandProcessedCountryDto country : mergedFile.countries()) { + for (DeleteBrandCountryResultItemDto item : country.getItems()) { + Row row = sheet.createRow(rowIndex++); + createTextCell(row, 0, country.getCountry()); + createTextCell(row, 1, item.getAsin()); + createTextCell(row, 2, item.getStatus()); + } + } + for (int i = 0; i < 3; i++) { + sheet.autoSizeColumn(i); + } + workbook.write(outputStream); + return outputFile; + } catch (Exception ex) { + throw new BusinessException("生成删除品牌结果文件失败: " + parsedFile.getSourceFilename()); + } + } + + private void createTextCell(Row row, int index, String value) { + Cell cell = row.createCell(index); + cell.setCellValue(value == null ? "" : value); + } + + private File buildNamedOutputFile(File outputDir, String filename) { + File candidate = FileUtil.file(outputDir, filename); + if (!candidate.exists()) { + return candidate; + } + String mainName = FileUtil.mainName(filename); + String extName = FileUtil.extName(filename); + int index = 2; + while (true) { + String nextFilename = extName == null || extName.isBlank() + ? mainName + "_" + index + : mainName + "_" + index + "." + extName; + File nextCandidate = FileUtil.file(outputDir, nextFilename); + if (!nextCandidate.exists()) { + return nextCandidate; + } + index++; + } + } + + public String resolveTaskDownloadUrl(Long taskId) { + FileResultEntity entity = findLatestSuccessfulResult(taskId); + return entity == null || entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl()); + } + + public String resolveTaskDownloadFilename(Long taskId) { + FileResultEntity entity = findLatestSuccessfulResult(taskId); + return entity == null ? null : entity.getResultFilename(); + } + + private FileResultEntity findLatestSuccessfulResult(Long taskId) { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getSuccess, 1) + .orderByDesc(FileResultEntity::getId) + .last("limit 1")) + .stream() + .findFirst() + .orElse(null); + } + + private String blankToDefault(String value, String defaultValue) { + String normalized = blankToEmpty(value); + return normalized.isBlank() ? defaultValue : normalized; } private record ParsedDeleteBrandFile(int totalRows, List countries, List previewRows) { } + + private record MergedDeleteBrandFile(String currentCountry, + String currentAsin, + int processedRows, + List countries) { + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java index e914def..d1fb47f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java @@ -13,6 +13,11 @@ import java.time.Duration; @RequiredArgsConstructor public class DeleteBrandTaskCacheService { + public static final String PHASE_CRAWLING = "crawling"; + public static final String PHASE_ASSEMBLING = "assembling"; + public static final String PHASE_UPLOADING = "uploading"; + public static final String PHASE_FAILED = "failed"; + private static final long PAYLOAD_TTL_HOURS = 24; private final StringRedisTemplate stringRedisTemplate; @@ -38,11 +43,65 @@ public class DeleteBrandTaskCacheService { } } + public void saveProgress(Long taskId, java.util.Map values) { + String key = buildProgressKey(taskId); + stringRedisTemplate.opsForHash().putAll(key, values); + stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS)); + } + + public java.util.Map getProgress(Long taskId) { + return stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId)); + } + + public void mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) { + String resultKey = buildResultChunksKey(taskId); + if (chunkIndex == null || chunkIndex <= 0) { + throw new BusinessException("chunkIndex 不合法"); + } + if (fileIdentity == null || fileIdentity.isBlank()) { + throw new BusinessException("fileIdentity 不能为空"); + } + String field = fileIdentity.trim() + "#" + chunkIndex; + try { + stringRedisTemplate.opsForHash().put(resultKey, field, objectMapper.writeValueAsString(chunk)); + } catch (Exception ex) { + throw new BusinessException("暂存删除品牌结果分片失败"); + } + stringRedisTemplate.expire(resultKey, Duration.ofHours(PAYLOAD_TTL_HOURS)); + } + + public java.util.Map> groupResultChunkJsonByFile(Long taskId) { + java.util.Map stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId)); + java.util.Map> grouped = new java.util.LinkedHashMap<>(); + for (java.util.Map.Entry entry : stored.entrySet()) { + if (!(entry.getKey() instanceof String field) || !(entry.getValue() instanceof String raw) || raw.isBlank()) { + continue; + } + int sep = field.lastIndexOf('#'); + if (sep <= 0) { + continue; + } + String fileIdentity = field.substring(0, sep); + grouped.computeIfAbsent(fileIdentity, ignored -> new java.util.ArrayList<>()).add(raw); + } + return grouped; + } + public void delete(Long taskId) { stringRedisTemplate.delete(buildPayloadKey(taskId)); + stringRedisTemplate.delete(buildProgressKey(taskId)); + stringRedisTemplate.delete(buildResultChunksKey(taskId)); } private String buildPayloadKey(Long taskId) { return "delete-brand:task:parsed-payload:" + taskId; } + + private String buildProgressKey(Long taskId) { + return "delete-brand:task:progress:" + taskId; + } + + private String buildResultChunksKey(Long taskId) { + return "delete-brand:task:result-chunks:" + taskId; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java new file mode 100644 index 0000000..7470418 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.shopkey.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; +import com.nanri.aiimage.modules.shopkey.service.ShopKeyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/shop-keys") +@Tag(name = "店铺密钥管理", description = "维护店铺密钥,支持增删改查。") +public class ShopKeyController { + + private final ShopKeyService shopKeyService; + + @GetMapping + @Operation(summary = "分页查询店铺密钥", description = "分页查询店铺密钥列表。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopKeyPageVo.class))) + }) + public ApiResponse page( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) { + return ApiResponse.success(shopKeyService.page(page, pageSize)); + } + + @PostMapping + @Operation(summary = "新增店铺密钥", description = "新增一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法") + }) + public ApiResponse create(@Valid @RequestBody ShopKeyCreateRequest request) { + return ApiResponse.success("创建成功", shopKeyService.create(request)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新店铺密钥", description = "按 ID 更新一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") + }) + public ApiResponse update( + @Parameter(description = "主键ID", required = true) @PathVariable Long id, + @Valid @RequestBody ShopKeyUpdateRequest request) { + return ApiResponse.success("更新成功", shopKeyService.update(id, request)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除店铺密钥", description = "按 ID 删除一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") + }) + public ApiResponse delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) { + shopKeyService.delete(id); + return ApiResponse.success("删除成功", null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java new file mode 100644 index 0000000..6d94adc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.shopkey.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ShopKeyMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java new file mode 100644 index 0000000..c86f727 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.shopkey.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "店铺密钥新增请求") +public class ShopKeyCreateRequest { + + @NotBlank(message = "紫鸟账号名称不能为空") + @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") + @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoAccountName; + + @NotBlank(message = "紫鸟令牌不能为空") + @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") + @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoToken; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java new file mode 100644 index 0000000..15a744b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.shopkey.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "店铺密钥更新请求") +public class ShopKeyUpdateRequest { + + @NotBlank(message = "紫鸟账号名称不能为空") + @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") + @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoAccountName; + + @NotBlank(message = "紫鸟令牌不能为空") + @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") + @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoToken; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java new file mode 100644 index 0000000..44c26d8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.shopkey.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_shop_key") +public class ShopKeyEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String ziniaoAccountName; + private String ziniaoToken; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java new file mode 100644 index 0000000..382993c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java @@ -0,0 +1,26 @@ +package com.nanri.aiimage.modules.shopkey.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "店铺密钥项") +public class ShopKeyItemVo { + + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "紫鸟账号名称") + private String ziniaoAccountName; + + @Schema(description = "紫鸟令牌") + private String ziniaoToken; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "修改时间") + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java new file mode 100644 index 0000000..e84b9dc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.shopkey.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "店铺密钥分页结果") +public class ShopKeyPageVo { + + @Schema(description = "列表") + private List items; + + @Schema(description = "总数") + private Long total; + + @Schema(description = "页码") + private Long page; + + @Schema(description = "每页数量") + private Long pageSize; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java new file mode 100644 index 0000000..36ae1b6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -0,0 +1,94 @@ +package com.nanri.aiimage.modules.shopkey.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ShopKeyService { + + private final ShopKeyMapper shopKeyMapper; + + public ShopKeyPageVo page(long page, long pageSize) { + long safePage = Math.max(page, 1); + long safePageSize = Math.min(Math.max(pageSize, 1), 100); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .orderByDesc(ShopKeyEntity::getId); + Long total = shopKeyMapper.selectCount(query); + List items = shopKeyMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)) + .stream() + .map(this::toItemVo) + .toList(); + ShopKeyPageVo vo = new ShopKeyPageVo(); + vo.setItems(items); + vo.setTotal(total); + vo.setPage(safePage); + vo.setPageSize(safePageSize); + return vo; + } + + @Transactional + public ShopKeyItemVo create(ShopKeyCreateRequest request) { + String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); + String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + ShopKeyEntity entity = new ShopKeyEntity(); + entity.setZiniaoAccountName(ziniaoAccountName); + entity.setZiniaoToken(ziniaoToken); + shopKeyMapper.insert(entity); + return toItemVo(getById(entity.getId())); + } + + @Transactional + public ShopKeyItemVo update(Long id, ShopKeyUpdateRequest request) { + ShopKeyEntity entity = getById(id); + String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); + String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + entity.setZiniaoAccountName(ziniaoAccountName); + entity.setZiniaoToken(ziniaoToken); + shopKeyMapper.updateById(entity); + return toItemVo(getById(id)); + } + + @Transactional + public void delete(Long id) { + ShopKeyEntity entity = getById(id); + shopKeyMapper.deleteById(entity.getId()); + } + + private ShopKeyEntity getById(Long id) { + ShopKeyEntity entity = shopKeyMapper.selectById(id); + if (entity == null) { + throw new BusinessException("店铺密钥不存在"); + } + return entity; + } + + private String normalizeRequired(String value, String message) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new BusinessException(message); + } + return normalized; + } + + private ShopKeyItemVo toItemVo(ShopKeyEntity entity) { + ShopKeyItemVo vo = new ShopKeyItemVo(); + vo.setId(entity.getId()); + vo.setZiniaoAccountName(entity.getZiniaoAccountName()); + vo.setZiniaoToken(entity.getZiniaoToken()); + vo.setCreatedAt(entity.getCreatedAt()); + vo.setUpdatedAt(entity.getUpdatedAt()); + return vo; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java index 154b189..0bf22e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java @@ -6,11 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import java.util.List; public interface ZiniaoClient { - Long getCompanyIdByApiKey(); + Long getCompanyIdByApiKey(String apiKey); - List listStaff(Long companyId); + List listStaff(String apiKey, Long companyId); - List listUserStores(Long companyId, Long userId); + List listUserStores(String apiKey, Long companyId, Long userId); - String getUserLoginToken(Long companyId, Long userId); + String getUserLoginToken(String apiKey, Long companyId, Long userId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index 7c0d89e..837e2a3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -26,8 +26,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { private final ObjectMapper objectMapper; @Override - public Long getCompanyIdByApiKey() { - String raw = getWithApiKey("/app/builtin/company", "获取 companyId"); + public Long getCompanyIdByApiKey(String apiKey) { + String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId"); try { JsonNode root = objectMapper.readTree(raw); JsonNode data = firstNonNull(root.get("data"), root.get("result"), root); @@ -44,8 +44,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public List listStaff(Long companyId) { - String raw = postWithApiKey(ziniaoProperties.getStaffListPath(), Map.of( + public List listStaff(String apiKey, Long companyId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getStaffListPath(), Map.of( "companyId", String.valueOf(companyId), "level", "", "isAccurate", "", @@ -75,8 +75,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public List listUserStores(Long companyId, Long userId) { - String raw = postWithApiKey(ziniaoProperties.getUserStoresPath(), Map.of( + public List listUserStores(String apiKey, Long companyId, Long userId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getUserStoresPath(), Map.of( "companyId", String.valueOf(companyId), "isAccurate", "", "limit", "100", @@ -88,8 +88,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public String getUserLoginToken(Long companyId, Long userId) { - String raw = postWithApiKey(ziniaoProperties.getUserLoginTokenPath(), Map.of( + public String getUserLoginToken(String apiKey, Long companyId, Long userId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getUserLoginTokenPath(), Map.of( "companyId", String.valueOf(companyId), "userId", String.valueOf(userId) ), "获取员工登录 token"); @@ -125,11 +125,10 @@ public class ZiniaoClientImpl implements ZiniaoClient { } - private String getWithApiKey(String path, String action) { - String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + private String getWithApiKey(String apiKey, String path, String action) { String raw = getRestClient().get() .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) - .headers(headers -> headers.setBearerAuth(apiKey)) + .headers(headers -> applyAuthorization(headers, apiKey)) .retrieve() .onStatus(HttpStatusCode::isError, (req, res) -> { // 保留响应体,交给后续 validateSuccess 统一解析 @@ -139,12 +138,11 @@ public class ZiniaoClientImpl implements ZiniaoClient { return raw; } - private String postWithApiKey(String path, Map body, String action) { - String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + private String postWithApiKey(String apiKey, String path, Map body, String action) { RestClient.RequestBodySpec request = getRestClient().post() .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .headers(headers -> { - headers.setBearerAuth(apiKey); + applyAuthorization(headers, apiKey); headers.setContentType(MediaType.APPLICATION_JSON); }); if (body != null) { @@ -182,6 +180,15 @@ public class ZiniaoClientImpl implements ZiniaoClient { return raw; } + private void applyAuthorization(org.springframework.http.HttpHeaders headers, String apiKey) { + String token = requireText(apiKey, "紫鸟 apiKey 未配置"); + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + headers.set("Authorization", token); + } else { + headers.setBearerAuth(token); + } + } + private void validateSuccess(String raw, String action, String path) { try { JsonNode root = objectMapper.readTree(raw); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java new file mode 100644 index 0000000..3466a31 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.ziniao.memory.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ZiniaoMemoryStoreMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java new file mode 100644 index 0000000..cc4ef88 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.ziniao.memory.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_ziniao_memory_store") +public class ZiniaoMemoryStoreEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String cacheType; + private String cacheKey; + private String payloadJson; + private LocalDateTime expiresAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java new file mode 100644 index 0000000..84c9f67 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java @@ -0,0 +1,143 @@ +package com.nanri.aiimage.modules.ziniao.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.ziniao.memory.mapper.ZiniaoMemoryStoreMapper; +import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ZiniaoMemoryStoreService { + + private final ZiniaoMemoryStoreMapper ziniaoMemoryStoreMapper; + private final ObjectMapper objectMapper; + + public Optional get(String cacheType, String cacheKey, Class valueType) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity == null) { + return Optional.empty(); + } + if (isExpired(entity)) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + return Optional.empty(); + } + try { + return Optional.ofNullable(objectMapper.readValue(entity.getPayloadJson(), valueType)); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟记忆存储失败"); + } + } + + public Optional get(String cacheType, String cacheKey, JavaType javaType) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity == null) { + return Optional.empty(); + } + if (isExpired(entity)) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + return Optional.empty(); + } + try { + @SuppressWarnings("unchecked") + T value = (T) objectMapper.readValue(entity.getPayloadJson(), javaType); + return Optional.ofNullable(value); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟记忆存储失败"); + } + } + + public Optional> getList(String cacheType, String cacheKey, Class elementType) { + JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, elementType); + return get(cacheType, cacheKey, type); + } + + @Transactional + public void put(String cacheType, String cacheKey, Object payload, Duration ttl) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空"); + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + throw new BusinessException("ttl 不合法"); + } + String payloadJson; + try { + payloadJson = objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + throw new BusinessException("写入紫鸟记忆存储失败"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiresAt = now.plusSeconds(ttl.getSeconds()); + ZiniaoMemoryStoreEntity entity = findOne(normalizedType, normalizedKey); + if (entity == null) { + entity = new ZiniaoMemoryStoreEntity(); + entity.setCacheType(normalizedType); + entity.setCacheKey(normalizedKey); + entity.setPayloadJson(payloadJson); + entity.setExpiresAt(expiresAt); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + ziniaoMemoryStoreMapper.insert(entity); + return; + } + entity.setPayloadJson(payloadJson); + entity.setExpiresAt(expiresAt); + entity.setUpdatedAt(now); + ziniaoMemoryStoreMapper.updateById(entity); + } + + @Transactional + public void delete(String cacheType, String cacheKey) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity != null) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + } + } + + @Transactional + public int deleteExpired(int limit) { + int safeLimit = Math.max(limit, 1); + List expired = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper() + .lt(ZiniaoMemoryStoreEntity::getExpiresAt, LocalDateTime.now()) + .orderByAsc(ZiniaoMemoryStoreEntity::getExpiresAt) + .last("LIMIT " + safeLimit)); + if (expired.isEmpty()) { + return 0; + } + int deleted = 0; + for (ZiniaoMemoryStoreEntity entity : expired) { + deleted += ziniaoMemoryStoreMapper.deleteById(entity.getId()); + } + return deleted; + } + + private ZiniaoMemoryStoreEntity findOne(String cacheType, String cacheKey) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空"); + return ziniaoMemoryStoreMapper.selectOne(new LambdaQueryWrapper() + .eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType) + .eq(ZiniaoMemoryStoreEntity::getCacheKey, normalizedKey) + .last("LIMIT 1")); + } + + private boolean isExpired(ZiniaoMemoryStoreEntity entity) { + return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now()); + } + + private String normalizeRequired(String value, String message) { + String normalized = Objects.toString(value, "").trim(); + if (normalized.isEmpty()) { + throw new BusinessException(message); + } + return normalized; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java index df01a7e..b81463b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java @@ -5,6 +5,7 @@ import lombok.Data; @Data public class ZiniaoSessionCacheDto { private String sessionId; + private String apiKey; private String accessToken; private String refreshToken; private String tokenType; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java new file mode 100644 index 0000000..b72b2e4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java @@ -0,0 +1,43 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ZiniaoApiKeyProvider { + + private final ShopKeyMapper shopKeyMapper; + + public List listApiKeys() { + return shopKeyMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(ShopKeyEntity::getId)) + .stream() + .map(ShopKeyEntity::getZiniaoToken) + .filter(token -> token != null && !token.isBlank()) + .map(String::trim) + .map(token -> token.regionMatches(true, 0, "Bearer ", 0, 7) ? token.substring(7).trim() : token) + .filter(token -> !token.isBlank()) + .distinct() + .toList(); + } + + public String getRequiredApiKey() { + List keys = listApiKeys(); + if (keys.isEmpty()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } + return keys.get(0); + } + + public boolean hasApiKey() { + Long total = shopKeyMapper.selectCount(new LambdaQueryWrapper()); + return total != null && total > 0; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index 5df8fdb..f4a3366 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -3,8 +3,10 @@ package com.nanri.aiimage.modules.ziniao.service; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.client.ZiniaoClient; +import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoApiKeyProvider; import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo; @@ -13,10 +15,12 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.util.UriComponentsBuilder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.List; @@ -24,8 +28,18 @@ import java.util.UUID; @Service @RequiredArgsConstructor +@Slf4j public class ZiniaoAuthService { + private static final String CACHE_TYPE_COMPANY_ID = "COMPANY_ID"; + private static final String CACHE_TYPE_STAFF_LIST = "STAFF_LIST"; + private static final String CACHE_TYPE_USER_STORES = "USER_STORES"; + private static final String CACHE_TYPE_SHOP_MATCH = "SHOP_MATCH"; + private static final Duration COMPANY_ID_CACHE_TTL = Duration.ofHours(12); + private static final Duration STAFF_LIST_CACHE_TTL = Duration.ofMinutes(30); + private static final Duration USER_STORES_CACHE_TTL = Duration.ofMinutes(30); + private static final Duration SHOP_MATCH_CACHE_TTL = Duration.ofMinutes(30); + public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { ensureEnabled(); String normalizedTarget = normalizeShopName(targetShopName); @@ -33,38 +47,64 @@ public class ZiniaoAuthService { return new StoreMatchResult(false, null, null, null, null, null); } - Long companyId = resolveCompanyId(); - List staff = ziniaoClient.listStaff(companyId); - - List userIds = new java.util.ArrayList<>(); - if (preferUserId != null && preferUserId > 0) { - userIds.add(preferUserId); - } - for (ZiniaoStaffItemVo item : staff) { - if (item != null && item.getUserId() != null && item.getUserId() > 0 && (userIds.isEmpty() || !userIds.contains(item.getUserId()))) { - userIds.add(item.getUserId()); + List apiKeys = getScanApiKeys(); + long startedAt = System.currentTimeMillis(); + int scannedKeys = 0; + for (String apiKey : apiKeys) { + if (isScanLimitExceeded(startedAt, scannedKeys)) { + throw new BusinessException("紫鸟 key 轮询超出扫描限制,请缩小范围或提高扫描上限"); } - } + scannedKeys++; - for (Long staffUserId : userIds) { - if (staffUserId == null || staffUserId <= 0) { - continue; - } - List stores; + Long companyId; try { - stores = ziniaoClient.listUserStores(companyId, staffUserId); + companyId = resolveCompanyId(apiKey); } catch (BusinessException ex) { - if (isSkippableUserStoresError(ex)) { + // key 无效/不可用时继续尝试下一个 key + if (isInvalidApiKeyError(ex)) { + log.info("[ziniao-match] skip invalid apiKey while resolving companyId, keyHash={}", shortKeyHash(apiKey)); continue; } throw ex; } - for (ZiniaoShopCacheDto store : stores) { - String storeName = normalizeShopName(store == null ? null : store.getShopName()); - if (!storeName.isBlank() && storeName.equals(normalizedTarget)) { - String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, ziniaoClient.getUserLoginToken(companyId, staffUserId)); - return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl); + StoreMatchResult cachedMatch = getCachedShopMatch(apiKey, companyId, normalizedTarget); + if (cachedMatch != null) { + log.info("[ziniao-match] hit shop-match cache, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}", + shortKeyHash(apiKey), companyId, normalizedTarget, cachedMatch.shopId(), cachedMatch.matchedUserId()); + String openStoreUrl = buildOpenStoreUrl(cachedMatch.shopId(), cachedMatch.matchedUserId(), + ziniaoClient.getUserLoginToken(apiKey, companyId, cachedMatch.matchedUserId())); + return new StoreMatchResult(true, cachedMatch.shopId(), cachedMatch.shopName(), cachedMatch.platform(), cachedMatch.matchedUserId(), openStoreUrl); + } + + List staff = getOrLoadStaff(apiKey, companyId); + List userIds = buildUserIds(staff, preferUserId); + for (Long staffUserId : userIds) { + if (staffUserId == null || staffUserId <= 0) { + continue; + } + List stores; + try { + stores = getOrLoadUserStores(apiKey, companyId, staffUserId); + } catch (BusinessException ex) { + if (isSkippableUserStoresError(ex)) { + continue; + } + throw ex; + } + + for (ZiniaoShopCacheDto store : stores) { + String storeName = normalizeShopName(store == null ? null : store.getShopName()); + if (!storeName.isBlank() && storeName.equals(normalizedTarget)) { + log.info("[ziniao-match] matched via upstream scan, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}", + shortKeyHash(apiKey), companyId, normalizedTarget, store.getShopId(), staffUserId); + cacheShopMatch(apiKey, companyId, normalizedTarget, + new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, null), + SHOP_MATCH_CACHE_TTL); + String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, + ziniaoClient.getUserLoginToken(apiKey, companyId, staffUserId)); + return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl); + } } } } @@ -98,6 +138,8 @@ public class ZiniaoAuthService { private final ZiniaoProperties ziniaoProperties; private final ZiniaoClient ziniaoClient; private final ZiniaoSessionCacheService ziniaoSessionCacheService; + private final ZiniaoMemoryStoreService ziniaoMemoryStoreService; + private final ZiniaoApiKeyProvider ziniaoApiKeyProvider; public ZiniaoSessionVo getSession(String sessionId, Long userId) { ensureEnabled(); @@ -122,15 +164,18 @@ public class ZiniaoAuthService { public ZiniaoStaffListVo listStaff() { ensureEnabled(); ZiniaoStaffListVo vo = new ZiniaoStaffListVo(); - Long companyId = resolveCompanyIdForStaff(); - vo.getItems().addAll(ziniaoClient.listStaff(companyId)); + String apiKey = resolveAvailableApiKey(); + Long companyId = resolveCompanyIdForStaff(apiKey); + vo.getItems().addAll(ziniaoClient.listStaff(apiKey, companyId)); return vo; } public ZiniaoShopListVo listShops(String sessionId, Long userId) { ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId); Long currentUserId = requireUserId(session.getCurrentUserId()); - List shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); + String apiKey = resolveSessionApiKey(session); + Long companyId = resolveCompanyId(apiKey); + List shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) { session.setDefaultShopId(shops.get(0).getShopId()); @@ -151,10 +196,12 @@ public class ZiniaoAuthService { public ZiniaoOpenShopVo openShop(ZiniaoOpenShopRequest request) { ZiniaoSessionCacheDto session = requireOrInitSession(request.getSessionId(), request.getUserId()); Long currentUserId = requireUserId(request.getUserId() != null ? request.getUserId() : session.getCurrentUserId()); - String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); + String apiKey = resolveSessionApiKey(session); + Long companyId = resolveCompanyId(apiKey); + String userToken = ziniaoClient.getUserLoginToken(apiKey, companyId, currentUserId); List shops = ziniaoSessionCacheService.getShops(session.getSessionId()); if (shops.isEmpty()) { - shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); + shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); } ZiniaoShopCacheDto targetShop = shops.stream() @@ -178,7 +225,8 @@ public class ZiniaoAuthService { public List listShopsForConfiguredUser() { ensureEnabled(); Long userId = parseConfiguredOpenStoreUserId(); - return ziniaoClient.listUserStores(resolveCompanyId(), userId); + String apiKey = resolveAvailableApiKey(); + return ziniaoClient.listUserStores(apiKey, resolveCompanyId(apiKey), userId); } public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) { @@ -187,7 +235,8 @@ public class ZiniaoAuthService { throw new BusinessException("店铺不存在"); } Long userId = parseConfiguredOpenStoreUserId(); - String loginToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId); + String apiKey = resolveAvailableApiKey(); + String loginToken = ziniaoClient.getUserLoginToken(apiKey, resolveCompanyId(apiKey), userId); return buildOpenStoreUrl(shop.getShopId(), userId, loginToken); } @@ -196,7 +245,9 @@ public class ZiniaoAuthService { ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto(); session.setSessionId(sessionId); session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); - session.setCompanyId(resolveCompanyId()); + String apiKey = resolveAvailableApiKey(); + session.setApiKey(apiKey); + session.setCompanyId(resolveCompanyId(apiKey)); session.setCurrentUserId(userId); session.setZiniaoUserId(String.valueOf(userId == null ? 0L : userId)); session.setNickname("API Key模式"); @@ -215,8 +266,12 @@ public class ZiniaoAuthService { if (userId != null && userId > 0) { session.setCurrentUserId(userId); session.setZiniaoUserId(String.valueOf(userId)); - ziniaoSessionCacheService.saveSession(session); } + // 旧 session 可能没有保存 apiKey,补齐后续请求所需 + if (session.getApiKey() == null || session.getApiKey().isBlank()) { + session.setApiKey(resolveAvailableApiKey()); + } + ziniaoSessionCacheService.saveSession(session); return session; } @@ -257,11 +312,13 @@ public class ZiniaoAuthService { if (!ziniaoProperties.isEnabled()) { throw new BusinessException("紫鸟集成未启用,请先配置环境变量"); } - requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + if (!ziniaoApiKeyProvider.hasApiKey()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } } - private Long resolveCompanyIdForStaff() { - return resolveCompanyId(); + private Long resolveCompanyIdForStaff(String apiKey) { + return resolveCompanyId(apiKey); } private Long requireUserId(Long userId) { @@ -271,15 +328,163 @@ public class ZiniaoAuthService { return userId; } - private Long resolveCompanyId() { + private Long resolveCompanyId(String apiKey) { if (ziniaoProperties.getCompanyId() != null && ziniaoProperties.getCompanyId() > 0) { return ziniaoProperties.getCompanyId(); } - return ziniaoClient.getCompanyIdByApiKey(); + + String normalizedApiKey = requireText(apiKey, "紫鸟 apiKey 未配置"); + String cacheKey = buildApiKeyHash(normalizedApiKey); + Long cached = ziniaoMemoryStoreService.get(CACHE_TYPE_COMPANY_ID, cacheKey, Long.class).orElse(null); + if (cached != null && cached > 0) { + log.info("[ziniao-company] hit cache, keyHash={}, companyId={}", shortKeyHash(apiKey), cached); + return cached; + } + log.info("[ziniao-company] cache miss, requesting upstream, keyHash={}", shortKeyHash(apiKey)); + Long companyId = ziniaoClient.getCompanyIdByApiKey(normalizedApiKey); + ziniaoMemoryStoreService.put(CACHE_TYPE_COMPANY_ID, cacheKey, companyId, COMPANY_ID_CACHE_TTL); + log.info("[ziniao-company] cached upstream result, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId); + return companyId; } - private Long requireCompanyId() { - return resolveCompanyId(); + + private List getOrLoadStaff(String apiKey, Long companyId) { + String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId; + List cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_STAFF_LIST, cacheKey, ZiniaoStaffItemVo.class) + .orElse(null); + if (cached != null) { + log.info("[ziniao-staff] hit cache, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, cached.size()); + return cached; + } + log.info("[ziniao-staff] cache miss, requesting upstream, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId); + List staff = ziniaoClient.listStaff(apiKey, companyId); + ziniaoMemoryStoreService.put(CACHE_TYPE_STAFF_LIST, cacheKey, staff, STAFF_LIST_CACHE_TTL); + log.info("[ziniao-staff] cached upstream result, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, staff.size()); + return staff; + } + + private List getOrLoadUserStores(String apiKey, Long companyId, Long userId) { + String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId + ":" + userId; + List cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_USER_STORES, cacheKey, ZiniaoShopCacheDto.class) + .orElse(null); + if (cached != null) { + log.info("[ziniao-stores] hit cache, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, cached.size()); + return cached; + } + log.info("[ziniao-stores] cache miss, requesting upstream, keyHash={}, companyId={}, userId={}", shortKeyHash(apiKey), companyId, userId); + List stores = ziniaoClient.listUserStores(apiKey, companyId, userId); + ziniaoMemoryStoreService.put(CACHE_TYPE_USER_STORES, cacheKey, stores, USER_STORES_CACHE_TTL); + log.info("[ziniao-stores] cached upstream result, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, stores.size()); + return stores; + } + + private StoreMatchResult getCachedShopMatch(String apiKey, Long companyId, String normalizedShopName) { + StoreMatchResult cached = ziniaoMemoryStoreService.get(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), StoreMatchResult.class) + .orElse(null); + if (cached == null || !cached.matched() || cached.shopId() == null || cached.matchedUserId() == null) { + return null; + } + return cached; + } + + private void cacheShopMatch(String apiKey, Long companyId, String normalizedShopName, StoreMatchResult result, Duration ttl) { + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), result, ttl); + } + + private String buildShopMatchCacheKey(String apiKey, Long companyId, String normalizedShopName) { + return buildApiKeyHash(apiKey) + ":" + companyId + ":" + normalizedShopName; + } + + private String buildApiKeyHash(String apiKey) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new BusinessException("生成紫鸟 apiKey 缓存键失败"); + } + } + + private String shortKeyHash(String apiKey) { + String hash = buildApiKeyHash(apiKey); + return hash.length() <= 12 ? hash : hash.substring(0, 12); + } + + private List buildUserIds(List staff, Long preferUserId) { + List userIds = new java.util.ArrayList<>(); + if (preferUserId != null && preferUserId > 0) { + userIds.add(preferUserId); + } + for (ZiniaoStaffItemVo item : staff) { + if (item != null && item.getUserId() != null && item.getUserId() > 0 && (userIds.isEmpty() || !userIds.contains(item.getUserId()))) { + userIds.add(item.getUserId()); + } + } + return userIds; + } + + private List getScanApiKeys() { + List keys = ziniaoApiKeyProvider.listApiKeys(); + if (keys.isEmpty()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } + Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys(); + if (maxKeys == null || maxKeys <= 0 || keys.size() <= maxKeys) { + return keys; + } + return keys.subList(0, maxKeys); + } + + private String resolveAvailableApiKey() { + BusinessException lastError = null; + List apiKeys = getScanApiKeys(); + for (String apiKey : apiKeys) { + try { + resolveCompanyId(apiKey); + return apiKey; + } catch (BusinessException ex) { + if (isInvalidApiKeyError(ex)) { + lastError = ex; + continue; + } + throw ex; + } + } + if (lastError != null) { + throw lastError; + } + throw new BusinessException("未找到可用的紫鸟 apiKey"); + } + + private String resolveSessionApiKey(ZiniaoSessionCacheDto session) { + if (session != null && session.getApiKey() != null && !session.getApiKey().isBlank()) { + return session.getApiKey().trim(); + } + return resolveAvailableApiKey(); + } + + private boolean isInvalidApiKeyError(BusinessException ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return false; + } + return message.contains("isv.invalid-api-key") + || message.contains("无效的apiKey参数") + || message.contains("非法的参数"); + } + + private boolean isScanLimitExceeded(long startedAt, int scannedKeys) { + Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys(); + if (maxKeys != null && maxKeys > 0 && scannedKeys >= maxKeys) { + return true; + } + Integer maxSeconds = ziniaoProperties.getKeyScanMaxSeconds(); + return maxSeconds != null && maxSeconds > 0 + && System.currentTimeMillis() - startedAt >= maxSeconds * 1000L; } private Long parseConfiguredOpenStoreUserId() { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 250819e..d5446c1 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -99,3 +99,5 @@ aiimage: shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30} connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5} read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15} + key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:50} + key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:15} diff --git a/backend-java/src/main/resources/db/V5__create_shop_keys.sql b/backend-java/src/main/resources/db/V5__create_shop_keys.sql new file mode 100644 index 0000000..71941b0 --- /dev/null +++ b/backend-java/src/main/resources/db/V5__create_shop_keys.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS biz_shop_key ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + ziniao_account_name VARCHAR(128) NOT NULL COMMENT '紫鸟账号名称', + ziniao_token VARCHAR(512) NOT NULL COMMENT '紫鸟令牌', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_created_at (created_at) +) COMMENT='店铺密钥管理表'; diff --git a/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql b/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql new file mode 100644 index 0000000..2c636d4 --- /dev/null +++ b/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql @@ -0,0 +1,27 @@ +SET @drop_shop_key_machine_no_index_sql := ( + SELECT CASE + WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP INDEX uk_machine_no' + ELSE 'SELECT 1' + END + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_shop_key' + AND INDEX_NAME = 'uk_machine_no' +); +PREPARE stmt FROM @drop_shop_key_machine_no_index_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_shop_key_machine_no_column_sql := ( + SELECT CASE + WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP COLUMN machine_no' + ELSE 'SELECT 1' + END + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_shop_key' + AND COLUMN_NAME = 'machine_no' +); +PREPARE stmt FROM @drop_shop_key_machine_no_column_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql b/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql new file mode 100644 index 0000000..9a2c60f --- /dev/null +++ b/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS biz_ziniao_memory_store ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + cache_type VARCHAR(64) NOT NULL COMMENT '缓存类型', + cache_key VARCHAR(255) NOT NULL COMMENT '缓存key', + payload_json LONGTEXT NOT NULL COMMENT '缓存JSON', + expires_at DATETIME NOT NULL COMMENT '过期时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_type_key (cache_type, cache_key), + KEY idx_expires_at (expires_at) +) COMMENT='紫鸟记忆存储(跨会话SQL缓存)'; diff --git a/backend/__pycache__/config.cpython-312.pyc b/backend/__pycache__/config.cpython-312.pyc index 71fb25c..3967d60 100644 Binary files a/backend/__pycache__/config.cpython-312.pyc and b/backend/__pycache__/config.cpython-312.pyc differ diff --git a/backend/blueprints/__pycache__/admin_api.cpython-312.pyc b/backend/blueprints/__pycache__/admin_api.cpython-312.pyc index f0b8657..e4b7967 100644 Binary files a/backend/blueprints/__pycache__/admin_api.cpython-312.pyc and b/backend/blueprints/__pycache__/admin_api.cpython-312.pyc differ diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index bc7c75f..5e7e477 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -617,6 +617,113 @@ def upload_version(): return jsonify({'success': False, 'error': str(e)}) +# ---------- 店铺密钥管理 ---------- + +@admin_api.route('/shop-keys') +@admin_required +def list_shop_keys(): + page = max(1, int(request.args.get('page', 1))) + page_size = min(100, max(1, int(request.args.get('page_size', 15)))) + result, error_response, status = _proxy_backend_java( + 'GET', + '/api/admin/shop-keys', + params={'page': page, 'pageSize': page_size}, + ) + if error_response is not None: + return error_response, status + payload = result.get('data') or {} + items = [ + { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + } + for item in (payload.get('items') or []) + ] + return jsonify({ + 'success': True, + 'items': items, + 'total': payload.get('total') or 0, + 'page': payload.get('page') or page, + 'page_size': payload.get('pageSize') or page_size, + }) + + +@admin_api.route('/shop-key', methods=['POST']) +@admin_required +def create_shop_key(): + data = request.get_json() or {} + payload = { + 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), + 'ziniaoToken': (data.get('ziniao_token') or '').strip(), + } + result, error_response, status = _proxy_backend_java( + 'POST', + '/api/admin/shop-keys', + json_data=payload, + ) + if error_response is not None: + return error_response, status + item = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': result.get('message') or '创建成功', + 'item': { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + }, + }) + + +@admin_api.route('/shop-key/', methods=['PUT']) +@admin_required +def update_shop_key(item_id): + data = request.get_json() or {} + payload = { + 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), + 'ziniaoToken': (data.get('ziniao_token') or '').strip(), + } + result, error_response, status = _proxy_backend_java( + 'PUT', + f'/api/admin/shop-keys/{item_id}', + json_data=payload, + ) + if error_response is not None: + return error_response, status + item = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': result.get('message') or '更新成功', + 'item': { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + }, + }) + + +@admin_api.route('/shop-key/', methods=['DELETE']) +@admin_required +def delete_shop_key(item_id): + result, error_response, status = _proxy_backend_java( + 'DELETE', + f'/api/admin/shop-keys/{item_id}', + ) + if error_response is not None: + return error_response, status + return jsonify({ + 'success': True, + 'msg': result.get('message') or '删除成功', + }) + + # ---------- 数据去重总数据 ---------- @admin_api.route('/dedupe-total-data') diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 4a27176..78d1c34 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -112,6 +112,7 @@
用户管理
栏目权限配置
数据去重总数据
+
店铺密钥管理
查看生成记录
版本管理
@@ -308,6 +309,42 @@ + +
+
+

新增店铺密钥

+
+
+ + +
+
+ + +
+ +
+

+
+
+

店铺密钥列表

+ + + + + + + + + + + + +
序号紫鸟账号名称紫鸟令牌创建时间修改时间操作
+ +
+
+
@@ -423,6 +460,27 @@
+ + + diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 082482d..3f0fdfc 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -29,7 +29,7 @@
文件名作为店铺名 -
例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;当前先不联动紫鸟
+
例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;并自动联动紫鸟进行店铺匹配
@@ -58,6 +58,14 @@ {{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }} + +
+
Python 队列推送结果
+
+ {{ queuePushResult }} +
+
{{ queuePayloadText }}
+
@@ -83,52 +91,139 @@ 删除品牌结果列表 -
+
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
-
    -
  • -
    - {{ item.sourceFilename || '-' }} -
    店铺名:{{ item.shopName || '-' }}
    -
    匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}
    -
    平台:{{ item.platform }}
    -
    国家数:{{ item.countryCount }}
    -
    去重后 {{ item.totalRows }} 条
    -
    - {{ formatPreview(item.previewRows) }} -
    -
    错误信息:{{ item.error }}
    -
    +
@@ -137,17 +232,20 @@