// ==UserScript== // @name 小工单-批量新增工单 // @namespace https://xiaogongdan.local/userscripts // @version 1.0.0 // @description 移动端批量新增工单,按产品规格+材质匹配产品,自动带出工期并批量生成工单 // @match https://liteweb.blacklake.cn/* // @match https://liteweb-dingtalk.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() { return location.hostname.includes("dingtalk") ? "https://liteweb-dingtalk.blacklake.cn" : 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`
`; 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(); })();