diff --git a/plugins/address-parser/CHANGELOG.md b/plugins/address-parser/CHANGELOG.md index 7bf56592d..80d6334fb 100644 --- a/plugins/address-parser/CHANGELOG.md +++ b/plugins/address-parser/CHANGELOG.md @@ -1,5 +1,10 @@ # 更新日志 +## 0.1.1 - 2026-09-01 + +- 适配 ZTools 3.2:CSV 导出后可直接拖出文件;仅允许最近生成的普通 CSV 单次外拖,旧版继续使用保存对话框。 +- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时显示升级提示,仅浏览器开发预览放行。 + ## 0.1.0 - 2026-07-29 ### 新增 diff --git a/plugins/address-parser/README.md b/plugins/address-parser/README.md index 7dad57146..d8051147d 100644 --- a/plugins/address-parser/README.md +++ b/plugins/address-parser/README.md @@ -2,6 +2,12 @@ 面向电商、客服、行政和物流场景的 ZTools 插件。把聊天消息、订单备注或收货清单粘贴进来,即可批量提取姓名、电话、省、市、区县和详细地址,检查缺失项,并导出 Excel/WPS 可直接打开的 CSV 表格。 +## ZTools 兼容性 + +- ZTools 3.2.0:导出的 CSV 可从“拖出 CSV”按钮拖至外部应用。 +- ZTools 2.4–3.1:保留保存对话框导出流程。 +- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 + ## 功能 - 本地规则解析:支持中国大陆手机号(含 `+86` 和空格/连字符)、常见座机、34 个省级行政区、地级市/自治州/地区/盟以及区县。 @@ -96,4 +102,5 @@ address-parser/ - 不发送网络请求,不保存输入历史。 - preload 仅暴露 `saveCsv(content, suggestedName)`,不向页面暴露 `fs` 或任意文件写入能力。 - 导出内容限制为 20 MB,保存位置必须由用户在系统对话框中选择。 +- 文件外拖仅接受最近 5 分钟内由插件成功保存的 CSV,校验真实普通文件后单次授权,不能拖出任意绝对路径。 - CSV 对以 `= + - @` 开头的单元格增加文本前缀,降低表格软件公式注入风险。 diff --git a/plugins/address-parser/app.js b/plugins/address-parser/app.js index 478ce0609..61d2b088b 100644 --- a/plugins/address-parser/app.js +++ b/plugins/address-parser/app.js @@ -1,6 +1,20 @@ (function () { "use strict"; + function isSupportedHost() { + if (!window.ztools) return true; + try { + return window.addressParserBridge?.hostCompatibility?.().supported === true; + } catch (_) { + return false; + } + } + + if (!isSupportedHost()) { + document.querySelector("main").innerHTML = '

需要升级 ZTools

当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用收货地址智能解析。

'; + return; + } + const core = window.AddressParserCore; const csv = window.AddressCsv; const sourceInput = document.getElementById("source-input"); @@ -8,6 +22,7 @@ const sampleButton = document.getElementById("sample-button"); const clearButton = document.getElementById("clear-button"); const exportButton = document.getElementById("export-button"); + const dragExportButton = document.getElementById("drag-export-button"); const resultBody = document.getElementById("result-body"); const emptyState = document.getElementById("empty-state"); const tableWrap = document.getElementById("table-wrap"); @@ -35,6 +50,7 @@ let activeFilter = "all"; let currentPage = 1; let toastTimer = null; + let lastExportPath = ""; function getSplitMode() { const checked = document.querySelector('input[name="split-mode"]:checked'); @@ -198,6 +214,11 @@ if (window.addressParserBridge && typeof window.addressParserBridge.saveCsv === "function") { const result = await window.addressParserBridge.saveCsv(content, fileName); if (result && result.canceled) return; + lastExportPath = result.path || ""; + if (lastExportPath && window.addressParserBridge.canStartDrag()) { + dragExportButton.hidden = false; + dragExportButton.draggable = true; + } showToast("CSV 已导出:" + (result.path || fileName), false); } else { downloadInBrowser(content, fileName); @@ -259,6 +280,13 @@ sampleButton.addEventListener("click", function () { sourceInput.value = sampleText; sourceInput.focus(); }); clearButton.addEventListener("click", clearAll); exportButton.addEventListener("click", exportCsv); + dragExportButton.addEventListener("dragstart", function (event) { + event.preventDefault(); + if (!lastExportPath) return; + window.addressParserBridge.startDrag(lastExportPath).catch(function (error) { + showToast(error && error.message ? error.message : String(error), true); + }); + }); sourceInput.addEventListener("keydown", function (event) { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); diff --git a/plugins/address-parser/core/file-drag-grants.cjs b/plugins/address-parser/core/file-drag-grants.cjs new file mode 100644 index 000000000..3eae1cc4f --- /dev/null +++ b/plugins/address-parser/core/file-drag-grants.cjs @@ -0,0 +1,62 @@ +"use strict"; + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +function createFileDragGrantStore(options) { + const fs = options.fs; + const path = options.path; + const now = options.now || Date.now; + const ttlMs = options.ttlMs || DEFAULT_TTL_MS; + const requiredExtension = String(options.requiredExtension || "").toLowerCase(); + const grants = new Map(); + + function canonicalFile(filePath) { + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + throw new Error("拖出的导出路径无效。"); + } + let canonical; + try { + canonical = fs.realpathSync(filePath); + if (!fs.statSync(canonical).isFile()) throw new Error("not a regular file"); + } catch (_) { + throw new Error("拖出的导出路径无效或文件已不存在。"); + } + if (requiredExtension && path.extname(canonical).toLowerCase() !== requiredExtension) { + throw new Error("拖出的导出文件类型无效。"); + } + return canonical; + } + + function pruneExpired() { + const current = now(); + for (const [filePath, expiresAt] of grants) { + if (expiresAt <= current) grants.delete(filePath); + } + } + + return { + grant(filePath) { + const canonical = canonicalFile(filePath); + pruneExpired(); + grants.set(canonical, now() + ttlMs); + return canonical; + }, + + consume(filePaths) { + const values = Array.isArray(filePaths) ? filePaths : [filePaths]; + if (!values.length) throw new Error("拖出的导出路径无效。"); + pruneExpired(); + const canonical = values.map(canonicalFile); + if (new Set(canonical).size !== canonical.length) { + throw new Error("拖出的导出路径包含重复文件。"); + } + if (canonical.some(filePath => !grants.has(filePath))) { + throw new Error("只能拖出刚刚由插件导出的 CSV 文件。"); + } + canonical.forEach(filePath => grants.delete(filePath)); + return canonical; + } + }; +} + +module.exports = { DEFAULT_TTL_MS, createFileDragGrantStore }; diff --git a/plugins/address-parser/index.html b/plugins/address-parser/index.html index a549d614e..8c4b46d95 100644 --- a/plugins/address-parser/index.html +++ b/plugins/address-parser/index.html @@ -69,7 +69,7 @@

逐项核对后导出

- +
diff --git a/plugins/address-parser/package.json b/plugins/address-parser/package.json index 301d5d148..ee36eca2a 100644 --- a/plugins/address-parser/package.json +++ b/plugins/address-parser/package.json @@ -1,11 +1,11 @@ { "name": "ztools-address-parser", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "ZTools shipping address parser and CSV exporter.", "scripts": { "test": "node --test tests/*.test.cjs", - "check": "node --check app.js && node --check preload.js && node --check core/address-parser.js && node --check core/csv.js && node --check core/exporter.cjs && npm test" + "check": "node --check app.js && node --check preload.js && node --check core/address-parser.js && node --check core/csv.js && node --check core/exporter.cjs && node --check core/file-drag-grants.cjs && npm test" }, "engines": { "node": ">=16.17.0" diff --git a/plugins/address-parser/plugin.json b/plugins/address-parser/plugin.json index 346bf53d0..c01b860dd 100644 --- a/plugins/address-parser/plugin.json +++ b/plugins/address-parser/plugin.json @@ -3,7 +3,7 @@ "title": "收货地址智能解析", "description": "批量提取收货人、电话、省市区和详细地址,检查缺失字段并导出 CSV 表格。", "author": "harris", - "version": "0.1.0", + "version": "0.1.1", "main": "index.html", "preload": "preload.js", "logo": "logo.svg", diff --git a/plugins/address-parser/preload.js b/plugins/address-parser/preload.js index 4b223c282..3aafb723d 100644 --- a/plugins/address-parser/preload.js +++ b/plugins/address-parser/preload.js @@ -3,8 +3,40 @@ const fs = require("fs"); const path = require("path"); const { createExportService } = require("./core/exporter.cjs"); +const { createFileDragGrantStore } = require("./core/file-drag-grants.cjs"); -const saveCsv = createExportService({ +const MINIMUM_VERSION = "2.4.0"; + +function parseVersion(value) { + if (typeof value !== "string") return null; + const match = value.match(/^\s*v?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][0-9A-Za-z.-]+)?\s*$/); + if (!match) return null; + const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)]; + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function isPrereleaseVersion(value) { + return typeof value === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(value); +} + +function hostCompatibility() { + const ztools = window.ztools; + if (!ztools) return { version: "", supported: true }; + let getAppVersion; + try { getAppVersion = ztools.getAppVersion; } catch (_) { return { version: "", supported: false }; } + if (typeof getAppVersion !== "function") return { version: "", supported: false }; + let version; + try { version = getAppVersion.call(ztools); } catch (_) { return { version: "", supported: false }; } + const current = parseVersion(version); + const minimum = parseVersion(MINIMUM_VERSION); + const atMinimum = Boolean(current && minimum) && current.every((part, index) => part === minimum[index]); + const supported = Boolean(current && minimum) && !( + atMinimum && isPrereleaseVersion(version) + ) && (current[0] > minimum[0] || (current[0] === minimum[0] && (current[1] > minimum[1] || (current[1] === minimum[1] && current[2] >= minimum[2])))); + return { version: typeof version === "string" ? version : "", supported }; +} + +const saveCsvFile = createExportService({ fs, path, showSaveDialog(options) { @@ -19,4 +51,27 @@ const saveCsv = createExportService({ } }); -window.addressParserBridge = Object.freeze({ saveCsv }); +const dragGrants = createFileDragGrantStore({ + fs, + path, + requiredExtension: ".csv" +}); + +async function saveCsv(content, suggestedName) { + const result = await saveCsvFile(content, suggestedName); + if (!result.canceled && result.path) dragGrants.grant(result.path); + return result; +} + +window.addressParserBridge = Object.freeze({ + saveCsv, + hostCompatibility, + canStartDrag() { return typeof window.ztools?.startDrag === "function"; }, + async startDrag(paths) { + if (typeof window.ztools?.startDrag !== "function") throw new Error("请升级到 ZTools 3.2.0 以拖出文件。"); + const values = dragGrants.consume(paths); + await Promise.resolve(window.ztools.startDrag(values.length === 1 ? values[0] : values)); + } +}); + +module.exports = { MINIMUM_VERSION, parseVersion, hostCompatibility }; diff --git a/plugins/address-parser/styles.css b/plugins/address-parser/styles.css index 6e228c373..f551927ca 100644 --- a/plugins/address-parser/styles.css +++ b/plugins/address-parser/styles.css @@ -79,6 +79,7 @@ kbd { padding: 2px 5px; border: 1px solid var(--line); border-bottom-width: 2px; .stats .stat-complete { border-left-color: var(--verified); background: var(--verified-soft); } .stats .stat-missing { border-left-color: var(--parcel); background: var(--parcel-soft); } .result-toolbar { padding: 10px 20px; background: #fbfcfd; border-bottom: 1px solid var(--line); } +.export-actions { display: flex; gap: 8px; align-items: center; } .filter-tabs { display: flex; gap: 3px; } .filter-tab { padding: 7px 12px; border: 0; border-radius: 6px; background: transparent; color: var(--ink-soft); cursor: pointer; font-size: 12px; font-weight: 700; } .filter-tab.is-active { background: var(--ink); color: white; } diff --git a/plugins/address-parser/tests/file-drag-grants.test.cjs b/plugins/address-parser/tests/file-drag-grants.test.cjs new file mode 100644 index 000000000..1fada1115 --- /dev/null +++ b/plugins/address-parser/tests/file-drag-grants.test.cjs @@ -0,0 +1,63 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { createFileDragGrantStore } = require("../core/file-drag-grants.cjs"); + +test("only a freshly exported CSV can be dragged and the grant is single-use", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "address-parser-drag-")); + const output = path.join(root, "result.csv"); + const arbitrary = path.join(root, "arbitrary.csv"); + fs.writeFileSync(arbitrary, "untrusted"); + const dragged = []; + const previousWindow = global.window; + global.window = { + ztools: { + getPath: () => root, + showSaveDialog: () => output, + startDrag: value => dragged.push(value) + } + }; + delete require.cache[require.resolve("../preload.js")]; + + try { + require("../preload.js"); + await assert.rejects(global.window.addressParserBridge.startDrag(arbitrary), /刚刚由插件导出/); + + const result = await global.window.addressParserBridge.saveCsv("name,address\nA,B", "result.csv"); + assert.equal(result.canceled, false); + await global.window.addressParserBridge.startDrag(result.path); + assert.deepEqual(dragged, [fs.realpathSync(output)]); + + await assert.rejects(global.window.addressParserBridge.startDrag(result.path), /刚刚由插件导出/); + } finally { + delete require.cache[require.resolve("../preload.js")]; + global.window = previousWindow; + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("CSV drag grants expire", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "address-parser-drag-expiry-")); + const output = path.join(root, "result.csv"); + fs.writeFileSync(output, "ok"); + let currentTime = 100; + const grants = createFileDragGrantStore({ + fs, + path, + now: () => currentTime, + ttlMs: 50, + requiredExtension: ".csv" + }); + + try { + grants.grant(output); + currentTime = 151; + assert.throws(() => grants.consume(output), /刚刚由插件导出/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/plugins/address-parser/tests/host-compatibility.test.cjs b/plugins/address-parser/tests/host-compatibility.test.cjs new file mode 100644 index 000000000..bc3fe6e14 --- /dev/null +++ b/plugins/address-parser/tests/host-compatibility.test.cjs @@ -0,0 +1,38 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); + +test("preload version gate only bypasses an explicit browser preview", () => { + const previousWindow = global.window; + global.window = {}; + try { + const { parseVersion, hostCompatibility } = require("../preload.js"); + assert.deepEqual(parseVersion("3.2"), [3, 2, 0]); + assert.deepEqual(parseVersion("v3.2.0-beta.1"), [3, 2, 0]); + assert.equal(parseVersion("ZTools 3.2.0"), null); + assert.equal(hostCompatibility().supported, true); + + global.window.ztools = {}; + assert.equal(hostCompatibility().supported, false); + const throwingGetter = {}; + Object.defineProperty(throwingGetter, "getAppVersion", { get() { throw new Error("unavailable"); } }); + global.window.ztools = throwingGetter; + assert.equal(hostCompatibility().supported, false); + global.window.ztools = { getAppVersion: () => { throw new Error("unavailable"); } }; + assert.equal(hostCompatibility().supported, false); + for (const version of ["", "unknown", 320]) { + global.window.ztools = { getAppVersion: () => version }; + assert.equal(hostCompatibility().supported, false); + } + global.window.ztools = { getAppVersion: () => "2.3.9" }; + assert.equal(hostCompatibility().supported, false); + global.window.ztools = { getAppVersion: () => "2.4.0-beta.1" }; + assert.equal(hostCompatibility().supported, false); + for (const version of ["2.4", "3.1.9", "3.2.0"]) { + global.window.ztools = { getAppVersion: () => version }; + assert.equal(hostCompatibility().supported, true); + } + } finally { + global.window = previousWindow; + } +});