// ==UserScript==
// @name 小工单-批量新增工单
// @namespace https://xiaogongdan.local/userscripts
// @version 1.0.1
// @description 移动端批量新增工单,按产品规格+材质匹配产品,自动带出工期并批量生成工单
// @match https://liteweb.blacklake.cn/*
// @match https://liteweb-dingtalk.blacklake.cn/*
// @match https://liteapp.blacklake.cn/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @connect *
// ==/UserScript==
(function () {
"use strict";
// ============================================================
// SHARED AUTH STORE (跨插件共享)
// ============================================================
const SHARED_AUTH_STORE = {
baseUrl: "xgd_shared_page_auth_base_url",
token: "xgd_shared_page_auth_token",
userLabel: "xgd_shared_page_auth_user_label",
loginAt: "xgd_shared_page_auth_login_at",
source: "xgd_shared_page_auth_source"
};
// ============================================================
// PLUGIN STORE KEYS
// ============================================================
const STORE = {
baseUrl: "xgd_batch_create_base_url",
token: "xgd_batch_create_token",
userLabel: "xgd_batch_create_user_label",
loginAt: "xgd_batch_create_login_at",
logs: "xgd_plugin_logs",
launcherPosition: "xgd_batch_create_pos",
tokenSource: "xgd_batch_create_token_source"
};
// ============================================================
// API ENDPOINTS
// ============================================================
const API = {
productQuery: "/api/dytin/external/product/queryList2",
projectCreate: "/api/dytin/external/project/create",
customFieldQuery: "/api/dytin/external/customField/queryCustomFieldMetadata"
};
// ============================================================
// PLUGIN METADATA
// ============================================================
const SUCCESS_CODE = "01000000";
const GREEN = "#24af76";
const DEFAULT_BASE_URL = "https://liteweb.blacklake.cn";
const WORK_ORDER_ROUTE = "/productionManagement/workOrder";
const ROOT_ID = "xgd-batch-create-root";
const PLUGIN_META = {
id: "xgd-batch-create",
title: "批量新增",
route: WORK_ORDER_ROUTE
};
let pluginOpenBound = false;
// ============================================================
// STATUS TEXTS
// ============================================================
const statusText = { 0: "新建", 10: "执行中", 20: "已结束", 30: "已取消" };
// ============================================================
// ERROR HINTS
// ============================================================
const errorHints = {
"1003": "产品编号不能为空",
"1004": "没有计划数",
"1005": "没有计划开始时间或计划结束时间",
"1006": "计划开始时间和计划结束时间超出范围",
"1007": "计划开始时间大于计划结束时间",
"1010": "产品编号不存在",
"1011": "计划数不能小于0",
"1013": "计划数小数点不能超过6位",
"1022": "产品规格在系统内不存在"
};
// ============================================================
// BUSINESS STATE
// ============================================================
const state = {
loading: false,
submitting: false,
allProducts: [],
productsLoaded: false,
fieldsValidated: false,
workTime: { amStart: "8:00", amEnd: "11:00", pmStart: "13:30", pmEnd: "17:00" },
rows: [{ id: 1, spec: "", material: "", plannedNum: "", salesOrderNum: "", matchedProduct: null, matchStatus: "" }],
nextRowId: 2
};
const html = String.raw;
hookPageTokenCapture();
// ============================================================
// AUTH LAYER (VERBATIM)
// ============================================================
function getConfig() {
const savedBaseUrl = GM_getValue(STORE.baseUrl, "");
return {
baseUrl: (savedBaseUrl || defaultBaseUrl()).replace(/\/$/, ""),
token: GM_getValue(STORE.token, "")
};
}
function defaultBaseUrl() {
if (location.hostname.includes("dingtalk")) return "https://liteweb-dingtalk.blacklake.cn";
if (location.hostname.includes("liteapp")) return "https://liteapp.blacklake.cn";
return DEFAULT_BASE_URL;
}
function hookPageTokenCapture() {
const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
if (!pageWindow) return;
pageWindow.__xgdPageAuthListeners = pageWindow.__xgdPageAuthListeners || [];
if (!pageWindow.__xgdPageAuthListeners.some((listener) => listener && listener.__pluginId === PLUGIN_META.id)) {
const listener = function (auth) { saveDetectedPageAuth(auth); };
listener.__pluginId = PLUGIN_META.id;
pageWindow.__xgdPageAuthListeners.push(listener);
}
if (pageWindow.__xgdPageTokenHooked) return;
pageWindow.__xgdPageTokenHooked = true;
const publishPageAuth = function (auth) {
try {
pageWindow.__xgdSharedPageAuth = Object.assign({}, auth, { time: Date.now() });
} catch (error) {}
try {
(pageWindow.__xgdPageAuthListeners || []).forEach((listener) => {
try { listener(auth); } catch (error) {}
});
} catch (error) {}
};
try {
const rawFetch = pageWindow.fetch;
if (typeof rawFetch === "function") {
pageWindow.fetch = function (input, init) {
try {
const headers = new Headers((init && init.headers) || (input && input.headers) || {});
const token = headers.get("X-AUTH") || headers.get("X-Auth") || headers.get("x-auth");
if (token) publishPageAuth({ token, userLabel: readCurrentPageUserLabel() || "当前网页登录态", source: "fetch-header" });
} catch (error) {}
return rawFetch.apply(this, arguments);
};
}
} catch (error) {}
try {
const rawSetRequestHeader = pageWindow.XMLHttpRequest && pageWindow.XMLHttpRequest.prototype.setRequestHeader;
if (typeof rawSetRequestHeader === "function") {
pageWindow.XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
try {
if (/^x-auth$/i.test(name || "") && value) {
publishPageAuth({ token: value, userLabel: readCurrentPageUserLabel() || "当前网页登录态", source: "xhr-header" });
}
} catch (error) {}
return rawSetRequestHeader.apply(this, arguments);
};
}
} catch (error) {}
}
function normalizeAuthToken(value) {
return String(value || "").trim().replace(/^Bearer\s+/i, "").replace(/^["']|["']$/g, "");
}
function isValidAuthToken(value) {
const token = normalizeAuthToken(value);
return /^eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
}
function readCapturedHeaderAuth() {
const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const pageShared = pageWindow && pageWindow.__xgdSharedPageAuth;
if (pageShared && /header$/i.test(pageShared.source || "") && isValidAuthToken(pageShared.token)) {
return { token: normalizeAuthToken(pageShared.token), userLabel: pageShared.userLabel || "当前网页登录态", source: pageShared.source };
}
const sharedToken = normalizeAuthToken(GM_getValue(SHARED_AUTH_STORE.token, ""));
const sharedSource = GM_getValue(SHARED_AUTH_STORE.source, "");
if (/header$/i.test(sharedSource || "") && isValidAuthToken(sharedToken)) {
return { token: sharedToken, userLabel: GM_getValue(SHARED_AUTH_STORE.userLabel, "") || "当前网页登录态", source: sharedSource };
}
const token = normalizeAuthToken(GM_getValue(STORE.token, ""));
const source = GM_getValue(STORE.tokenSource, "");
if (!/header$/i.test(source || "") || !isValidAuthToken(token)) return null;
return { token, userLabel: GM_getValue(STORE.userLabel, "") || "当前网页登录态", source };
}
function readCurrentPageAuth() {
hookPageTokenCapture();
const capturedHeaderAuth = readCapturedHeaderAuth();
if (capturedHeaderAuth) return capturedHeaderAuth;
const candidates = [];
const pushCandidate = (key, value) => {
const token = normalizeAuthToken(value);
if (isValidAuthToken(token)) candidates.push({ key, value: token });
};
try {
[window.localStorage, window.sessionStorage].filter(Boolean).forEach((storage) => {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
const value = storage.getItem(key) || "";
if (/token|auth/i.test(key || "")) pushCandidate(key, value);
try {
const parsed = JSON.parse(value);
Object.keys(parsed || {}).forEach((field) => {
const fieldValue = parsed[field];
if (/token|auth/i.test(field) && typeof fieldValue === "string") {
pushCandidate(key + "." + field, fieldValue);
}
});
} catch (error) {}
}
});
} catch (error) {}
const tokenCandidate = candidates[0];
if (!tokenCandidate) return null;
return { token: tokenCandidate.value, userLabel: readCurrentPageUserLabel() || "当前网页登录态", source: tokenCandidate.key };
}
function readCurrentPageUserLabel() {
try {
const nodes = Array.prototype.slice.call(document.querySelectorAll('[class*="user"], [class*="account"], [class*="name"], [class*="avatar"]'));
const text = nodes.map((node) => (node.textContent || "").trim()).find((value) => value && value.length <= 20);
return text || "";
} catch (error) {
return "";
}
}
function saveDetectedPageAuth(auth) {
if (!auth || !isValidAuthToken(auth.token)) return;
const token = normalizeAuthToken(auth.token);
const userLabel = auth.userLabel || "当前网页登录态";
const source = auth.source || "page";
GM_setValue(STORE.baseUrl, defaultBaseUrl());
GM_setValue(STORE.token, token);
GM_setValue(STORE.userLabel, userLabel);
GM_setValue(STORE.loginAt, Date.now());
GM_setValue(STORE.tokenSource, source);
GM_setValue(SHARED_AUTH_STORE.baseUrl, defaultBaseUrl());
GM_setValue(SHARED_AUTH_STORE.token, token);
GM_setValue(SHARED_AUTH_STORE.userLabel, userLabel);
GM_setValue(SHARED_AUTH_STORE.loginAt, Date.now());
GM_setValue(SHARED_AUTH_STORE.source, source);
try {
const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
if (pageWindow) pageWindow.__xgdSharedPageAuth = { token, userLabel, source, time: Date.now() };
} catch (error) {}
}
function syncCurrentPageAuth() {
const pageAuth = readCurrentPageAuth();
if (pageAuth && pageAuth.token) saveDetectedPageAuth(pageAuth);
return pageAuth;
}
function hasUsableAuth() {
const pageAuth = readCurrentPageAuth();
const cachedToken = normalizeAuthToken(GM_getValue(STORE.token, ""));
if (cachedToken && !isValidAuthToken(cachedToken)) GM_setValue(STORE.token, "");
return Boolean((pageAuth && pageAuth.token) || isValidAuthToken(cachedToken));
}
async function ensureAuthToken() {
const pageAuth = readCurrentPageAuth();
if (pageAuth && pageAuth.token) {
saveDetectedPageAuth(pageAuth);
return pageAuth.token;
}
const cachedToken = normalizeAuthToken(GM_getValue(STORE.token, ""));
if (isValidAuthToken(cachedToken)) return cachedToken;
if (cachedToken) GM_setValue(STORE.token, "");
throw new Error("未识别到当前页面登录态,请确认小工单网页已登录。");
}
function clearAuthToken() {
GM_setValue(STORE.token, "");
GM_setValue(STORE.loginAt, "");
refreshAuthView();
}
// ============================================================
// API LAYER (VERBATIM)
// ============================================================
function gmPost(url, body, token) {
return new Promise(function (resolve, reject) {
const headers = { "Content-Type": "application/json" };
if (token) headers["X-AUTH"] = token;
GM_xmlhttpRequest({
method: "POST",
url: url,
headers: headers,
data: JSON.stringify(body || {}),
timeout: 30000,
onload: function (response) {
try {
const text = response.responseText || "{}";
const data = JSON.parse(text);
if (response.status < 200 || response.status >= 300) {
appendLog("http-error", { url: url, status: response.status, response: data });
reject(new Error(formatHttpError(response.status, data, text)));
return;
}
resolve(data);
} catch (error) {
reject(new Error("响应解析失败:" + error.message));
}
},
ontimeout: function () {
appendLog("timeout", { url: url });
reject(new Error("请求超时,请检查网络或接口白名单。"));
},
onerror: function (error) {
appendLog("network-error", { url: url, error: error });
reject(new Error("网络请求失败:" + (error.error || "请检查跨域授权或网络")));
}
});
});
}
async function apiPost(path, body) {
const config = getConfig();
const token = await ensureAuthToken();
const result = await gmPost(config.baseUrl + path, body, token);
if (isAuthExpiredResult(result)) {
const pageAuth = readCurrentPageAuth();
const retryToken = pageAuth && pageAuth.token ? pageAuth.token : "";
if (retryToken) {
if (pageAuth && pageAuth.token) saveDetectedPageAuth(pageAuth);
const retryResult = await gmPost(config.baseUrl + path, body, retryToken);
if (retryResult && retryResult.code === SUCCESS_CODE) return retryResult;
if (!isAuthExpiredResult(retryResult)) return throwApiError(path, body, retryResult);
}
clearAuthToken();
appendLog("auth-expired", { path: path, result: result });
throw new Error("插件登录态已失效,请刷新小工单页面后重试。");
}
if (!result || result.code !== SUCCESS_CODE) {
return throwApiError(path, body, result);
}
return result;
}
function throwApiError(path, body, result) {
const hint = errorHints[result && result.code] ? "\n" + errorHints[result.code] : "";
appendLog("api-error", { path: path, body: body, result: result });
throw new Error((result && result.code ? result.code : "API") + " " + (result && (result.msg || result.message) ? (result.msg || result.message) : "接口调用失败") + hint);
}
function isAuthExpiredResult(result) {
const code = result && String(result.code || "");
const subCode = result && String(result.subCode || "");
const message = result && String(result.msg || result.message || "");
return code === "401" || code === "01000004" || subCode === "USER_VERIFICATION_ERROR" || /未授权|重新登录|登录已失效|token/i.test(message);
}
function formatHttpError(status, data, fallbackText) {
const msg = data && data.msg ? data.msg : fallbackText.slice(0, 120);
return "HTTP " + status + ": " + msg;
}
// ============================================================
// CORE CSS (VERBATIM)
// ============================================================
function css() {
return html`
#${ROOT_ID} * { box-sizing: border-box; }
#${ROOT_ID} { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
#xgd-plan-query-mask {
position: fixed; inset: 0; z-index: 2147483647; display: none;
align-items: center; justify-content: center; background: rgba(17, 24, 39, .24);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#xgd-plan-query-modal {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
box-shadow: 0 24px 60px rgba(15, 23, 42, .2); overflow: hidden; color: #1f2937;
}
.xgd-head { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid #edf0f2; }
.xgd-title { font-size: 16px; font-weight: 650; }
.xgd-head-actions { display: flex; align-items: center; gap: 8px; position: relative; }
.xgd-account-wrap { position: relative; display: none; }
.xgd-account-btn { display: inline-flex; align-items: center; justify-content: center; height: 30px; border: 1px solid #d7dde3; border-radius: 6px; background: #fff; color: #344054; padding: 0 10px; cursor: pointer; font-size: 12px; max-width: 180px; }
.xgd-account-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.xgd-account-menu { display: none; position: absolute; right: 0; top: 36px; width: 132px; padding: 6px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; box-shadow: 0 12px 28px rgba(15,23,42,.14); z-index: 2; }
.xgd-account-menu button { display: flex; align-items: center; justify-content: center; width: 100%; height: 30px; border: 0; border-radius: 5px; background: #fff; color: #344054; cursor: pointer; font-size: 12px; }
.xgd-account-menu button:hover { background: #f6f8fa; }
.xgd-account-menu .danger { color: #d92d20; }
.xgd-account-menu .primary-text { color: #24af76; }
.xgd-toast { position: fixed; left: 50%; top: 86px; transform: translateX(-50%); z-index: 2147483647; display: none; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border-radius: 6px; background: #fff; color: #344054; border: 1px solid #ff9f1a; border-left: 4px solid #ff8a00; font-size: 14px; box-shadow: 0 12px 32px rgba(15,23,42,.18); }
.xgd-close { width: 32px; height: 32px; border: 0; background: transparent; cursor: pointer; font-size: 22px; color: #667085; }
.xgd-body { padding: 14px 18px 18px; overflow: auto; max-height: calc(100vh - 84px); }
.xgd-panel { display: none; }
.xgd-panel.active { display: block; }
.xgd-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 10px; align-items: end; }
.xgd-field { grid-column: span 3; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.xgd-field.wide { grid-column: span 6; }
.xgd-field label { color: #667085; font-size: 12px; }
.xgd-input, .xgd-select { height: 34px; border: 1px solid #d7dde3; border-radius: 6px; padding: 0 10px; font-size: 13px; outline: none; background: #fff; min-width: 0; }
.xgd-input:focus, .xgd-select:focus { border-color: ${GREEN}; box-shadow: 0 0 0 3px rgba(36,175,118,.12); }
.xgd-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.xgd-btn { height: 34px; border-radius: 6px; border: 1px solid #d7dde3; background: #fff; color: #344054; padding: 0 13px; cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; }
.xgd-btn.primary { border-color: ${GREEN}; background: ${GREEN}; color: #fff; }
.xgd-btn:disabled { opacity: .55; cursor: not-allowed; }
.xgd-status { margin-top: 12px; min-height: 22px; color: #667085; font-size: 13px; white-space: pre-wrap; }
.xgd-status.error { color: #d92d20; }
.xgd-section-label { grid-column: span 12; color: #98a2b3; font-size: 12px; margin-top: 2px; }
.xgd-table-wrap { border: 1px solid #edf0f2; border-radius: 6px; overflow: auto; max-height: 320px; margin-top: 12px; }
.xgd-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 1040px; }
.xgd-table th { background: #f7f8fa; color: #667085; font-weight: 600; text-align: left; height: 34px; border-bottom: 1px solid #edf0f2; padding: 0 10px; white-space: nowrap; }
.xgd-table td { border-bottom: 1px solid #edf0f2; padding: 8px 10px; vertical-align: top; }
.xgd-chip { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: #eaf8f2; color: ${GREEN}; font-size: 12px; }
.xgd-empty { color: #98a2b3; padding: 18px; text-align: center; }
.xgd-pager { display: flex; gap: 8px; align-items: center; margin-top: 10px; color: #667085; font-size: 13px; }
.xgd-help { margin-top: 10px; border: 1px solid #edf0f2; border-radius: 6px; padding: 12px; line-height: 1.7; color: #475467; font-size: 13px; }
@media (max-width: 720px) {
.xgd-field, .xgd-field.wide { grid-column: span 12; }
#xgd-plan-query-modal { width: calc(100vw - 18px); }
}
`;
}
// ============================================================
// MOUNT (VERBATIM)
// ============================================================
function mount() {
if (!document.body) return;
if (isWorkOrderRoute()) {
if (!document.getElementById(ROOT_ID)) createPluginRoot();
ensurePluginRegistered();
}
ensureXgdPluginHub().render();
}
function createPluginRoot() {
const root = document.createElement("div");
root.id = ROOT_ID;
root.innerHTML = html`
${escapeHtml(PLUGIN_META.title)}
未检测到小工单登录态,请先登录小工单网页。
`;
document.body.appendChild(root);
bindEvents(root);
// __BIND_EVENTS_BEGIN__
(function () {
var searchTimer = null;
function debounceSearch(rowId, specValue) {
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
var filtered = filterProductsBySpec(specValue);
showProductDropdown(rowId, filtered);
}, 300);
}
/* 初始化:加载产品数据 */
async function initPlugin() {
try {
await validateCustomFields();
await loadAllProducts();
renderRows();
setStatus("use", "已加载 " + state.allProducts.length + " 个产品,可以开始新增工单");
} catch (err) {
setStatus("use", "初始化失败:" + normalizeErrorMessage(err), true);
}
}
/* 添加行 */
root.querySelector("#xgd-bc-add-row").addEventListener("click", function () {
if (state.rows.length >= 10) { showToast("最多支持 10 行"); return; }
state.rows.push({ id: state.nextRowId++, spec: "", material: "", plannedNum: "", salesOrderNum: "", matchedProduct: null, matchStatus: "" });
renderRows();
});
/* 工作时间折叠 */
root.querySelector("#xgd-bc-wt-toggle").addEventListener("click", function () {
var panel = q("#xgd-bc-wt-panel");
var arrow = q("#xgd-bc-wt-arrow");
if (panel.style.display === "none") {
panel.style.display = "block";
arrow.textContent = "▲";
} else {
panel.style.display = "none";
arrow.textContent = "▼";
}
});
/* 工作时间输入同步 */
["xgd-bc-wt-as", "xgd-bc-wt-ae", "xgd-bc-wt-ps", "xgd-bc-wt-pe"].forEach(function (id) {
var key = { "xgd-bc-wt-as": "amStart", "xgd-bc-wt-ae": "amEnd", "xgd-bc-wt-ps": "pmStart", "xgd-bc-wt-pe": "pmEnd" }[id];
var el = root.querySelector("#" + id);
if (el) el.addEventListener("change", function () { state.workTime[key] = el.value; });
});
/* 事件委托:规格输入、材质选择、产品选择、计划数、销售订单数、删除 */
root.querySelector("#xgd-bc-rows").addEventListener("input", function (e) {
var card = e.target.closest(".xgd-bc-card");
if (!card) return;
var rowId = parseInt(card.dataset.rowId);
var row = getRowData(rowId);
if (!row) return;
if (e.target.classList.contains("xgd-bc-spec")) {
row.spec = e.target.value;
row.matchedProduct = null;
row.matchStatus = "";
updateMaterialDropdown(rowId);
debounceSearch(rowId, row.spec);
} else if (e.target.classList.contains("xgd-bc-pn")) {
row.plannedNum = e.target.value;
} else if (e.target.classList.contains("xgd-bc-so")) {
row.salesOrderNum = e.target.value;
}
});
root.querySelector("#xgd-bc-rows").addEventListener("change", function (e) {
var card = e.target.closest(".xgd-bc-card");
if (!card) return;
var rowId = parseInt(card.dataset.rowId);
var row = getRowData(rowId);
if (!row) return;
if (e.target.classList.contains("xgd-bc-mat")) {
row.material = e.target.value;
if (!row.spec || !row.material) { row.matchedProduct = null; row.matchStatus = ""; renderRows(); return; }
var matches = matchProduct(row.spec, row.material);
if (matches.length === 1) {
row.matchedProduct = matches[0];
row.matchStatus = "";
} else if (matches.length > 1) {
row.matchStatus = "匹配到 " + matches.length + " 个产品,请在上方搜索框精确选择";
showProductDropdown(rowId, matches);
row.matchedProduct = null;
} else {
row.matchStatus = "未找到匹配的产品";
row.matchedProduct = null;
}
renderRows();
} else if (e.target.classList.contains("xgd-bc-pn")) {
row.plannedNum = e.target.value;
} else if (e.target.classList.contains("xgd-bc-so")) {
row.salesOrderNum = e.target.value;
}
});
root.querySelector("#xgd-bc-rows").addEventListener("click", function (e) {
var card = e.target.closest(".xgd-bc-card");
if (!card) return;
var rowId = parseInt(card.dataset.rowId);
/* 删除行 */
if (e.target.classList.contains("xgd-bc-del")) {
state.rows = state.rows.filter(function (r) { return r.id !== rowId; });
if (state.rows.length === 0) {
state.rows.push({ id: state.nextRowId++, spec: "", material: "", plannedNum: "", salesOrderNum: "", matchedProduct: null, matchStatus: "" });
}
renderRows();
return;
}
/* 选择产品下拉项 */
var ddItem = e.target.closest(".xgd-bc-dd-item");
if (ddItem) {
var productCode = ddItem.dataset.code;
var product = state.allProducts.find(function (p) { return p.productCode === productCode; });
if (product) {
var row = getRowData(rowId);
if (row) {
row.matchedProduct = product;
row.spec = product.productSpecification || row.spec;
row.material = readCustomValue(product, "材质") || row.material;
row.matchStatus = "";
renderRows();
}
}
}
});
/* 点击外部关闭下拉 */
document.addEventListener("click", function (e) {
if (!e.target.closest(".xgd-bc-card")) hideAllDropdowns();
});
/* 提交 */
root.querySelector("#xgd-bc-submit").addEventListener("click", onSubmit);
/* 启动初始化 */
if (hasUsableAuth()) initPlugin();
})();
// __BIND_EVENTS_END__
}
function bindEvents(root) {
root.querySelector(".xgd-close").addEventListener("click", closeModal);
root.querySelector("#xgd-plan-query-mask").addEventListener("click", function (event) {
if (event.target.id === "xgd-plan-query-mask") closeModal();
});
root.querySelector("#xgd-account-btn").addEventListener("click", toggleAccountMenu);
root.querySelector("#xgd-refresh-session").addEventListener("click", onRefreshAuth);
root.querySelector("#xgd-download-log").addEventListener("click", downloadLogs);
root.querySelector("#xgd-logout").addEventListener("click", onLogout);
document.addEventListener("click", closeAccountMenuOnOutside);
root.querySelector("#xgd-retry-auth").addEventListener("click", onRefreshAuth);
}
function openModal() {
q("#xgd-plan-query-mask").style.display = "flex";
refreshAuthView();
syncCurrentPageAuth();
switchTab(hasUsableAuth() ? "use" : "login");
}
function closeModal() {
q("#xgd-plan-query-mask").style.display = "none";
ensureXgdPluginHub().render();
}
function switchTab(name) {
document.querySelectorAll("#" + ROOT_ID + " .xgd-panel").forEach(function (panel) {
panel.classList.toggle("active", panel.dataset.panel === name);
});
}
function toggleAccountMenu(event) {
event.stopPropagation();
const menu = q("#xgd-account-menu");
if (menu) menu.style.display = menu.style.display === "block" ? "none" : "block";
}
function closeAccountMenuOnOutside(event) {
const wrap = q("#xgd-account-wrap");
const menu = q("#xgd-account-menu");
if (wrap && menu && !wrap.contains(event.target)) menu.style.display = "none";
}
function closeAccountMenu() {
const menu = q("#xgd-account-menu");
if (menu) menu.style.display = "none";
}
function onLogout() {
closeAccountMenu();
GM_setValue(STORE.token, "");
GM_setValue(STORE.userLabel, "");
GM_setValue(STORE.loginAt, "");
refreshAuthView();
switchTab("login");
setStatus("login", "已退出登录,请重新登录小工单网页。");
setStatus("use", "");
}
async function onRefreshAuth() {
closeAccountMenu();
setStatus("login", "");
setStatus("use", "");
try {
setStatus(hasUsableAuth() ? "use" : "login", "正在刷新登录态...");
const pageAuth = syncCurrentPageAuth();
if (pageAuth && pageAuth.token) {
refreshAuthView();
switchTab("use");
setStatus("use", "已使用当前网页登录态。");
return;
}
switchTab("login");
setStatus("login", "未识别到当前页面登录态,请确认小工单网页已登录。", true);
} catch (error) {
clearAuthToken();
switchTab("login");
setStatus("login", normalizeErrorMessage(error), true);
}
}
function normalizeErrorMessage(error) {
const message = typeof error === "string" ? error : error && error.message;
return message && message !== "undefined" && message !== "null" ? message : "插件执行失败,请稍后重试。";
}
function setStatus(area, message, isError) {
const el = q(area === "login" ? "#xgd-login-status" : "#xgd-use-status");
if (el) {
el.textContent = message || "";
el.classList.toggle("error", Boolean(isError));
}
}
function refreshAuthView() {
const token = GM_getValue(STORE.token, "");
const accountWrap = q("#xgd-account-wrap");
const accountName = q("#xgd-account-name");
if (!accountWrap || !accountName) return;
if (token) {
accountWrap.style.display = "block";
accountName.textContent = GM_getValue(STORE.userLabel, "对接账号") || "对接账号";
} else {
accountWrap.style.display = "none";
closeAccountMenu();
}
}
// ============================================================
// LOGGING (VERBATIM)
// ============================================================
function appendLog(type, detail) {
const logs = readLogs();
logs.push({
time: new Date().toISOString(),
plugin: PLUGIN_META.title,
url: location.href,
type: type,
detail: detail
});
GM_setValue(STORE.logs, JSON.stringify(logs.slice(-200)));
}
function readLogs() {
try { return JSON.parse(GM_getValue(STORE.logs, "[]")); } catch (error) { return []; }
}
function showToast(message) {
const toast = q(".xgd-toast");
if (!toast) return;
toast.innerHTML = '⚠' + escapeHtml(message);
toast.style.display = "block";
clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.style.display = "none"; }, 4200);
}
function downloadLogs() {
closeAccountMenu();
const payload = {
exportedAt: new Date().toISOString(),
location: location.href,
user: GM_getValue(STORE.userLabel, ""),
logs: readLogs()
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "xgd-plugin-log-" + Date.now() + ".json";
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function escapeHtml(value) {
return String(value === undefined || value === null ? "" : value)
.replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
function q(selector) {
return document.querySelector("#" + ROOT_ID + " " + selector);
}
function isWorkOrderRoute() {
var route = currentRoutePath();
var normalized = WORK_ORDER_ROUTE.replace(/\/+$/, "");
return route === normalized || route.startsWith(normalized + "/");
}
function currentRoutePath() {
var hashPath = "";
if (location.hash) {
hashPath = location.hash.replace(/^#/, "").split("?")[0];
if (hashPath && !hashPath.startsWith("/")) hashPath = "/" + hashPath;
}
if (hashPath) return hashPath.replace(/\/+$/, "") || "/";
return (location.pathname || "/").replace(/\/+$/, "") || "/";
}
// ============================================================
// LAUNCHER (VERBATIM)
// ============================================================
let xgdLauncherPlugin = null;
function ensureXgdPluginHub() {
function ensureRoot() {
if (!xgdLauncherPlugin) return null;
const rootId = "xgd-plugin-launcher-" + xgdLauncherPlugin.id;
let root = document.getElementById(rootId);
if (!root) {
root = document.createElement("div");
root.id = rootId;
root.innerHTML = html`
`;
document.body.appendChild(root);
}
const button = root.querySelector(".xgd-plugin-launcher-button");
let dragging = false;
let moved = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
applyLauncherPosition();
function isCurrentRoute() {
const route = currentRoutePath();
return route === xgdLauncherPlugin.route || route.startsWith(xgdLauncherPlugin.route + "/");
}
function render() {
button.textContent = xgdLauncherPlugin.title;
root.style.display = isCurrentRoute() ? "block" : "none";
applyLauncherPosition();
requestLauncherLayout();
}
function applyLauncherPosition() {
let position = null;
try { position = JSON.parse(GM_getValue(STORE.launcherPosition, "null")); } catch (error) { position = null; }
if (position && Number.isFinite(position.left) && Number.isFinite(position.top)) {
root.dataset.customPosition = "true";
placeLauncher(position.left, position.top);
} else {
root.dataset.customPosition = "false";
placeDefaultLauncher(0);
}
}
function layoutVisibleLaunchers() {
const roots = Array.prototype.slice.call(document.querySelectorAll('[id^="xgd-plugin-launcher-"]'))
.filter((item) => item.style.display !== "none")
.sort((left, right) => left.id.localeCompare(right.id));
if (roots.length <= 1) return;
roots.forEach((item, index) => {
if (item.dataset.customPosition === "true") return;
const itemButton = item.querySelector(".xgd-plugin-launcher-button");
if (!itemButton) return;
itemButton.style.left = "auto";
itemButton.style.top = "auto";
itemButton.style.right = "24px";
itemButton.style.bottom = (86 + index * 48) + "px";
});
const placed = [];
roots.forEach((item, index) => {
const itemButton = item.querySelector(".xgd-plugin-launcher-button");
if (!itemButton) return;
let rect = itemButton.getBoundingClientRect();
let left = rect.left;
let top = rect.top;
let attempts = 0;
while (placed.some((placedRect) => isLauncherOverlap({ left, top, width: rect.width, height: rect.height }, placedRect)) && attempts < roots.length + 4) {
top = Math.max(8, top - 48);
attempts += 1;
}
if (placed.some((placedRect) => isLauncherOverlap({ left, top, width: rect.width, height: rect.height }, placedRect))) {
itemButton.style.left = "auto";
itemButton.style.top = "auto";
itemButton.style.right = "24px";
itemButton.style.bottom = (86 + index * 48) + "px";
rect = itemButton.getBoundingClientRect();
left = rect.left;
top = rect.top;
} else if (top !== rect.top) {
placeLauncher(left, top);
}
placed.push({ left, top, width: rect.width, height: rect.height });
});
}
function isLauncherOverlap(a, b) {
return a.left < b.left + b.width + 8 && a.left + a.width + 8 > b.left && a.top < b.top + b.height + 8 && a.top + a.height + 8 > b.top;
}
function requestLauncherLayout() {
setTimeout(function () {
document.dispatchEvent(new CustomEvent("xgd-plugin-launchers-layout"));
layoutVisibleLaunchers();
}, 0);
}
function placeDefaultLauncher(index) {
button.style.left = "auto";
button.style.top = "auto";
button.style.right = "24px";
button.style.bottom = (86 + index * 48) + "px";
}
function placeLauncher(left, top) {
button.style.left = left + "px";
button.style.top = top + "px";
button.style.right = "auto";
button.style.bottom = "auto";
}
if (root.dataset.bound !== "true") {
root.dataset.bound = "true";
button.addEventListener("pointerdown", function (event) {
dragging = true; moved = false;
const rect = button.getBoundingClientRect();
dragOffsetX = event.clientX - rect.left;
dragOffsetY = event.clientY - rect.top;
if (button.setPointerCapture) button.setPointerCapture(event.pointerId);
});
button.addEventListener("pointermove", function (event) {
if (!dragging) return;
const left = Math.min(Math.max(8, event.clientX - dragOffsetX), window.innerWidth - button.offsetWidth - 8);
const top = Math.min(Math.max(8, event.clientY - dragOffsetY), window.innerHeight - button.offsetHeight - 8);
if (Math.abs(left - button.getBoundingClientRect().left) > 3 || Math.abs(top - button.getBoundingClientRect().top) > 3) moved = true;
placeLauncher(left, top);
});
button.addEventListener("pointerup", function (event) {
if (!dragging) return;
dragging = false;
if (button.releasePointerCapture) button.releasePointerCapture(event.pointerId);
if (moved) {
const rect = button.getBoundingClientRect();
root.dataset.customPosition = "true";
GM_setValue(STORE.launcherPosition, JSON.stringify({ left: rect.left, top: rect.top }));
requestLauncherLayout();
event.stopPropagation();
}
});
button.addEventListener("click", function (event) {
event.stopPropagation();
if (moved) { moved = false; return; }
document.dispatchEvent(new CustomEvent("xgd-plugin-open", { detail: { id: xgdLauncherPlugin.id } }));
});
window.addEventListener("hashchange", render);
window.addEventListener("popstate", render);
document.addEventListener("xgd-plugin-launchers-layout", layoutVisibleLaunchers);
}
return { render: render };
}
return {
register: function (plugin) {
xgdLauncherPlugin = plugin;
const root = ensureRoot();
if (root) root.render();
},
render: function () {
const root = ensureRoot();
if (root) root.render();
}
};
}
function ensurePluginRegistered() {
const hub = ensureXgdPluginHub();
if (!pluginOpenBound) {
pluginOpenBound = true;
document.addEventListener("xgd-plugin-open", function (event) {
if (event.detail && event.detail.id === PLUGIN_META.id) openModal();
});
}
hub.register(PLUGIN_META);
}
// ============================================================
// BOOT (VERBATIM)
// ============================================================
function boot() {
const tryMount = function () {
if (!document.body) return;
try { mount(); } catch (error) {
appendLog("plugin-failure", { message: normalizeErrorMessage(error) });
showToast("插件出现错误,请点击右上角下载日志,并发送给您的实施经理。");
}
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", tryMount);
else tryMount();
window.addEventListener("hashchange", tryMount);
window.addEventListener("popstate", tryMount);
["pushState", "replaceState"].forEach(function (method) {
const original = history[method];
history[method] = function () {
const result = original.apply(this, arguments);
setTimeout(tryMount, 0);
return result;
};
});
[0, 300, 800, 1500, 3000, 5000].forEach(function (delay) {
setTimeout(tryMount, delay);
});
}
// ============================================================
// BUSINESS API CALLS
// ============================================================
// __BUSINESS_API_CALLS_BEGIN__
/* ---------- 自定义字段校验 ---------- */
async function validateCustomFields() {
if (state.fieldsValidated) return;
var productMeta = await apiPost(API.customFieldQuery, { targetType: 1, page: { pageNum: 1, pageSize: 50 } });
var woMeta = await apiPost(API.customFieldQuery, { targetType: 3, page: { pageNum: 1, pageSize: 50 } });
var pNames = (productMeta.data || []).map(function (f) { return f.name; });
var wNames = (woMeta.data || []).map(function (f) { return f.name; });
var missing = [];
if (pNames.indexOf("材质") === -1) missing.push("产品.材质");
if (pNames.indexOf("工期") === -1) missing.push("产品.工期");
if (wNames.indexOf("销售订单数") === -1) missing.push("工单.销售订单数");
if (missing.length > 0) {
showToast("自定义字段不存在:" + missing.join("、") + ",请先在系统中创建");
throw new Error("自定义字段缺失:" + missing.join("、"));
}
state.fieldsValidated = true;
}
/* ---------- 加载所有产品 ---------- */
async function loadAllProducts() {
if (state.productsLoaded) return state.allProducts;
var all = []; var pageNum = 1; var pageSize = 100;
while (true) {
var result = await apiPost(API.productQuery, { page: { pageNum: pageNum, pageSize: pageSize } });
var data = (result.data && result.data.data) || result.data || [];
if (!Array.isArray(data)) data = [];
all = all.concat(data);
var total = (result.data && result.data.total) || 0;
if (all.length >= total || data.length < pageSize) break;
pageNum++;
}
state.allProducts = all;
state.productsLoaded = true;
return all;
}
function readCustomValue(product, fieldName) {
var vals = product.customFieldValues || [];
for (var i = 0; i < vals.length; i++) {
if (vals[i].name === fieldName) return vals[i].value || "";
}
return "";
}
function filterProductsBySpec(spec) {
if (!spec) return state.allProducts;
var lower = spec.toLowerCase();
return state.allProducts.filter(function (p) {
return (p.productSpecification || "").toLowerCase().indexOf(lower) !== -1 ||
(p.productName || "").toLowerCase().indexOf(lower) !== -1;
});
}
function getMaterialOptions(products) {
var set = {};
products.forEach(function (p) {
var m = readCustomValue(p, "材质");
if (m) set[m] = true;
});
return Object.keys(set).sort();
}
function matchProduct(spec, material) {
if (!spec || !material) return [];
return state.allProducts.filter(function (p) {
return (p.productSpecification || "") === spec && readCustomValue(p, "材质") === material;
});
}
/* ---------- 行渲染 ---------- */
function renderRows() {
var container = q("#xgd-bc-rows");
if (!container) return;
container.innerHTML = state.rows.map(function (row, idx) { return buildRowHtml(row, idx); }).join("");
var countEl = q("#xgd-bc-row-count");
if (countEl) countEl.textContent = "(" + state.rows.length + "/10)";
var addBtn = q("#xgd-bc-add-row");
if (addBtn) addBtn.style.display = state.rows.length >= 10 ? "none" : "";
}
function buildRowHtml(row, idx) {
var matched = row.matchedProduct;
var productInfo = matched
? '' +
'
✓ ' + escapeHtml(matched.productName || matched.productCode) + '
' +
'
工期:' + escapeHtml(readCustomValue(matched, "工期") || "-") + ' 天 | 编号:' + escapeHtml(matched.productCode) + '
'
: (row.matchStatus ? '' + escapeHtml(row.matchStatus) + '
' : '');
var ddStyle = "display:none;position:absolute;left:0;right:0;top:100%;z-index:100;background:#fff;border:1px solid #d7dde3;border-radius:6px;max-height:200px;overflow-y:auto;box-shadow:0 8px 20px rgba(0,0,0,.12);margin-top:2px;";
return '' +
'
' +
'#' + (idx + 1) + '' +
(state.rows.length > 1 ? '' : '') +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
' +
productInfo +
'
' +
'
';
}
function updateMaterialDropdown(rowId) {
var card = q('.xgd-bc-card[data-row-id="' + rowId + '"]');
if (!card) return;
var row = state.rows.find(function (r) { return r.id === rowId; });
if (!row) return;
var sel = card.querySelector(".xgd-bc-mat");
if (!sel) return;
var products = row.spec ? filterProductsBySpec(row.spec) : state.allProducts;
var options = getMaterialOptions(products);
var currentVal = row.material;
sel.innerHTML = '' +
options.map(function (m) { return ''; }).join("");
}
function showProductDropdown(rowId, products) {
var card = q('.xgd-bc-card[data-row-id="' + rowId + '"]');
if (!card) return;
var dd = card.querySelector(".xgd-bc-dd");
if (!dd) return;
if (!products || products.length === 0) {
dd.innerHTML = '无匹配产品
';
dd.style.display = "block";
return;
}
var displayProducts = products.slice(0, 50);
dd.innerHTML = displayProducts.map(function (p) {
var mat = readCustomValue(p, "材质") || "-";
var dur = readCustomValue(p, "工期") || "-";
var spec = p.productSpecification || "-";
return '' +
'
' + escapeHtml(p.productName || p.productCode) + '
' +
'
规格:' + escapeHtml(spec) + ' | 材质:' + escapeHtml(mat) + ' | 工期:' + escapeHtml(dur) + '天
' +
'
';
}).join("");
dd.style.display = "block";
}
function hideAllDropdowns() {
document.querySelectorAll("#" + ROOT_ID + " .xgd-bc-dd").forEach(function (dd) { dd.style.display = "none"; });
}
function getRowData(rowId) {
return state.rows.find(function (r) { return r.id === rowId; });
}
/* ---------- 工作时间与工期计算 ---------- */
function padZ(n) { return n < 10 ? "0" + n : "" + n; }
function formatDateTime(d) {
return d.getFullYear() + "-" + padZ(d.getMonth() + 1) + "-" + padZ(d.getDate()) + " " +
padZ(d.getHours()) + ":" + padZ(d.getMinutes()) + ":" + padZ(d.getSeconds());
}
function parseTimeToMinutes(timeStr) {
var parts = (timeStr || "0:0").split(":");
return parseInt(parts[0] || 0) * 60 + parseInt(parts[1] || 0);
}
function getWorkPeriods() {
var wt = state.workTime;
return [
{ startMin: parseTimeToMinutes(wt.amStart), endMin: parseTimeToMinutes(wt.amEnd) },
{ startMin: parseTimeToMinutes(wt.pmStart), endMin: parseTimeToMinutes(wt.pmEnd) }
];
}
function calculateEndTime(startTime, durationDays) {
if (durationDays <= 0) return new Date(startTime);
var workHoursPerDay = 0;
var periods = getWorkPeriods();
periods.forEach(function (p) { workHoursPerDay += (p.endMin - p.startMin) / 60; });
if (workHoursPerDay <= 0) workHoursPerDay = 8;
var totalWorkHours = durationDays * workHoursPerDay;
var current = new Date(startTime);
var remainingHours = totalWorkHours;
var maxIter = 1000;
while (remainingHours > 0.001 && maxIter-- > 0) {
var dayStartMin = current.getHours() * 60 + current.getMinutes();
var processed = false;
for (var i = 0; i < periods.length; i++) {
var period = periods[i];
if (dayStartMin >= period.endMin) continue;
var effectiveStart = Math.max(dayStartMin, period.startMin);
var availableHours = (period.endMin - effectiveStart) / 60;
if (availableHours <= 0) continue;
if (remainingHours <= availableHours + 0.001) {
var addMinutes = Math.round(remainingHours * 60);
var endMin = effectiveStart + addMinutes;
current.setHours(Math.floor(endMin / 60), endMin % 60, 0, 0);
remainingHours = 0;
} else {
remainingHours -= availableHours;
var nextPeriod = i + 1 < periods.length ? periods[i + 1] : null;
if (nextPeriod) {
dayStartMin = nextPeriod.startMin;
current.setHours(Math.floor(dayStartMin / 60), dayStartMin % 60, 0, 0);
} else {
current.setDate(current.getDate() + 1);
current.setHours(Math.floor(periods[0].startMin / 60), periods[0].startMin % 60, 0, 0);
}
}
processed = true;
break;
}
if (!processed) {
current.setDate(current.getDate() + 1);
current.setHours(Math.floor(periods[0].startMin / 60), periods[0].startMin % 60, 0, 0);
}
}
return current;
}
/* ---------- 批量创建工单 ---------- */
async function onSubmit() {
if (state.submitting) return;
var validRows = state.rows.filter(function (r) { return r.matchedProduct && r.plannedNum > 0; });
if (validRows.length === 0) {
showToast("请至少填写一行完整信息(产品规格、材质、计划数)");
return;
}
state.submitting = true;
setStatus("use", "正在批量创建工单 (0/" + validRows.length + ")...");
var submitBtn = q("#xgd-bc-submit");
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = "创建中..."; }
var successCount = 0; var failCount = 0; var results = [];
try {
for (var i = 0; i < validRows.length; i++) {
var row = validRows[i];
var product = row.matchedProduct;
var duration = parseFloat(readCustomValue(product, "工期")) || 0;
var now = new Date();
var endTime = duration > 0 ? calculateEndTime(now, duration) : now;
var customFields = [];
if (row.salesOrderNum) {
customFields.push({ name: "销售订单数", value: String(row.salesOrderNum) });
}
var body = {
productCode: product.productCode,
plannedNum: String(row.plannedNum),
planStartTime: formatDateTime(now),
planEndTime: formatDateTime(endTime)
};
if (customFields.length > 0) body.workOrderCustomFieldsValue = customFields;
try {
var result = await apiPost(API.projectCreate, body);
successCount++;
var data = Array.isArray(result.data) ? result.data[0] : result.data;
results.push("#" + (i + 1) + " ✓ " + (data ? data.projectCode : "创建成功"));
} catch (err) {
failCount++;
results.push("#" + (i + 1) + " ✗ " + normalizeErrorMessage(err));
}
setStatus("use", "正在批量创建工单 (" + (i + 1) + "/" + validRows.length + ")...");
}
var summary = "创建完成:成功 " + successCount + " 条" + (failCount > 0 ? ",失败 " + failCount + " 条" : "");
setStatus("use", summary + "\n" + results.join("\n"), failCount > 0);
if (failCount === 0) showToast("成功创建 " + successCount + " 条工单");
else showToast(summary);
} catch (err) {
setStatus("use", "批量创建失败:" + normalizeErrorMessage(err), true);
} finally {
state.submitting = false;
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "批量生成工单"; }
}
}
// __BUSINESS_API_CALLS_END__
// ============================================================
// BOOTSTRAP
// ============================================================
boot();
})();