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