// ==UserScript==
// @name 小工单-工单快捷创建
// @namespace https://xiaogongdan.local/userscripts
// @version 1.0.3
// @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-quick-wo_base_url",
token: "xgd-quick-wo_token",
userLabel: "xgd-quick-wo_user_label",
loginAt: "xgd-quick-wo_login_at",
logs: "xgd_plugin_logs",
launcherPosition: "xgd_quick_wo_pos",
tokenSource: "xgd-quick-wo_token_source",
workTimeConfig: "xgd-quick-wo_work_time_config"
};
const DEFAULT_WORK_TIME = {
morning: { start: "07:00", end: "11:00" },
afternoon: { start: "11:30", end: "17:00" },
evening: { start: "", end: "" }
};
// ============================================================
// API ENDPOINTS
// ============================================================
const API = {
projectList: "/api/dytin/external/project/queryList3",
projectCreate: "/api/dytin/external/project/create",
projectUpdate: "/api/dytin/external/project/update",
productQuery: "/api/dytin/external/product/queryList2",
customFieldMeta: "/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-quick-wo-root";
const PLUGIN_META = {
id: "xgd-quick-wo",
title: "工单快捷创建",
route: WORK_ORDER_ROUTE
};
let pluginOpenBound = false;
// ============================================================
// STATUS TEXTS
// ============================================================
const statusText = { 0: "新建", 10: "执行中", 20: "已结束", 30: "已取消" };
// ============================================================
// ERROR HINTS
// ============================================================
const errorHints = {
"1005": "请填写计划开始/结束时间",
"1007": "计划开始时间不能大于计划结束时间",
"1010": "产品编号不存在",
"1011": "计划数不能小于0"
};
// ============================================================
// BUSINESS STATE
// ============================================================
const state = {
loading: false,
workOrders: [],
pageNum: 1,
pageSize: 20,
total: 0,
currentPage: "list",
detailRows: [{ spec: "", material: "", plannedNum: "" }],
submitting: false,
materialOptions: [],
specProducts: []
};
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(1100px, calc(100vw - 32px)); max-height: min(820px, 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(min(820px, calc(100vh - 32px)) - 52px); }
.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__
root.querySelector("#xgd-qw-btn-query").addEventListener("click", function () { onQuery(1); });
root.querySelector("#xgd-qw-prev").addEventListener("click", function () { if (state.pageNum > 1) onQuery(state.pageNum - 1); });
root.querySelector("#xgd-qw-next").addEventListener("click", function () { var totalPages = Math.ceil(state.total / state.pageSize); if (state.pageNum < totalPages) onQuery(state.pageNum + 1); });
root.querySelector("#xgd-qw-btn-add").addEventListener("click", function () { resetCreateForm(); switchPage("create"); });
root.querySelector("#xgd-qw-btn-adjust-time").addEventListener("click", function () { adjustWorkOrderTimes(); });
root.querySelector("#xgd-qw-add-row").addEventListener("click", function () { addDetailRow(); });
root.querySelector("#xgd-qw-cancel").addEventListener("click", function () { switchPage("list"); });
root.querySelector("#xgd-qw-submit").addEventListener("click", function () { onSubmit(); });
root.querySelector("#xgd-qw-spec").addEventListener("input", function () {
// 同步表头的产品规格到所有空的明细行
var headerSpec = this.value;
state.detailRows.forEach(function (row) {
if (!row.spec || row.spec === "") {
row.spec = headerSpec;
}
});
// 如果当前有明细行在显示,重新渲染以同步显示
if (state.currentPage === "create") {
renderDetailRows();
}
});
root.querySelector("#xgd-qw-spec").addEventListener("blur", function () { loadMaterialOptions(); });
root.querySelector("#xgd-qw-toggle-wt").addEventListener("click", function () { toggleWorkTimePanel(); });
root.querySelector("#xgd-qw-save-wt").addEventListener("click", function () { saveWorkTimeConfigFromUI(); });
loadWorkTimeConfigToUI();
// __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__
function getWorkTimeConfig() {
try {
var saved = GM_getValue(STORE.workTimeConfig, "");
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(DEFAULT_WORK_TIME));
}
function toggleWorkTimePanel() {
var panel = q("#xgd-qw-wt-panel");
var btn = q("#xgd-qw-toggle-wt");
if (!panel || !btn) return;
if (panel.style.display === "none") {
panel.style.display = "block";
btn.textContent = "收起";
} else {
panel.style.display = "none";
btn.textContent = "展开";
}
}
function loadWorkTimeConfigToUI() {
var config = getWorkTimeConfig();
var mStart = q("#xgd-wt-m-start");
var mEnd = q("#xgd-wt-m-end");
var aStart = q("#xgd-wt-a-start");
var aEnd = q("#xgd-wt-a-end");
var eStart = q("#xgd-wt-e-start");
var eEnd = q("#xgd-wt-e-end");
if (mStart) mStart.value = config.morning.start;
if (mEnd) mEnd.value = config.morning.end;
if (aStart) aStart.value = config.afternoon.start;
if (aEnd) aEnd.value = config.afternoon.end;
if (eStart) eStart.value = config.evening.start;
if (eEnd) eEnd.value = config.evening.end;
}
function saveWorkTimeConfigFromUI() {
var config = {
morning: { start: (q("#xgd-wt-m-start") || {}).value || "", end: (q("#xgd-wt-m-end") || {}).value || "" },
afternoon: { start: (q("#xgd-wt-a-start") || {}).value || "", end: (q("#xgd-wt-a-end") || {}).value || "" },
evening: { start: (q("#xgd-wt-e-start") || {}).value || "", end: (q("#xgd-wt-e-end") || {}).value || "" }
};
GM_setValue(STORE.workTimeConfig, JSON.stringify(config));
showToast("工作时间配置已保存");
}
function parseTimeToMinutes(timeStr) {
var parts = timeStr.split(":");
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
}
function getWorkPeriods() {
var config = getWorkTimeConfig();
var periods = [];
if (config.morning.start && config.morning.end) {
periods.push({ start: parseTimeToMinutes(config.morning.start), end: parseTimeToMinutes(config.morning.end) });
}
if (config.afternoon.start && config.afternoon.end) {
periods.push({ start: parseTimeToMinutes(config.afternoon.start), end: parseTimeToMinutes(config.afternoon.end) });
}
if (config.evening.start && config.evening.end) {
periods.push({ start: parseTimeToMinutes(config.evening.start), end: parseTimeToMinutes(config.evening.end) });
}
return periods;
}
function getDailyWorkMinutes() {
var periods = getWorkPeriods();
var total = 0;
periods.forEach(function (p) { total += p.end - p.start; });
return total;
}
function adjustToWorkTime(date) {
var periods = getWorkPeriods();
var d = new Date(date);
if (periods.length === 0) return d;
var minutes = d.getHours() * 60 + d.getMinutes();
for (var i = 0; i < periods.length; i++) {
var p = periods[i];
if (minutes >= p.start && minutes < p.end) return d;
if (minutes < p.start) {
d.setHours(Math.floor(p.start / 60), p.start % 60, 0, 0);
return d;
}
}
d.setDate(d.getDate() + 1);
d.setHours(Math.floor(periods[0].start / 60), periods[0].start % 60, 0, 0);
return d;
}
function addWorkHours(startDate, totalHours) {
var periods = getWorkPeriods();
var d = new Date(startDate);
if (periods.length === 0) return d;
var remainingMinutes = totalHours * 60;
var maxIterations = 1000;
var iteration = 0;
while (remainingMinutes > 0 && iteration < maxIterations) {
iteration++;
var dayMinutes = d.getHours() * 60 + d.getMinutes();
for (var i = 0; i < periods.length; i++) {
var p = periods[i];
if (dayMinutes >= p.start && dayMinutes < p.end) {
var available = p.end - dayMinutes;
var use = Math.min(available, remainingMinutes);
d.setMinutes(d.getMinutes() + use);
remainingMinutes -= use;
dayMinutes = d.getHours() * 60 + d.getMinutes();
if (remainingMinutes <= 0) return d;
}
}
d.setDate(d.getDate() + 1);
d.setHours(Math.floor(periods[0].start / 60), periods[0].start % 60, 0, 0);
}
return d;
}
function buildQueryBody(pageNum) {
var now = new Date();
var sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return {
createdAtGte: formatDateTime(sevenDaysAgo),
createdAtLte: formatDateTime(now),
page: { pageNum: pageNum, pageSize: state.pageSize }
};
}
function formatDateTime(date) {
var y = date.getFullYear();
var m = String(date.getMonth() + 1).padStart(2, "0");
var d = String(date.getDate()).padStart(2, "0");
var h = String(date.getHours()).padStart(2, "0");
var min = String(date.getMinutes()).padStart(2, "0");
var s = String(date.getSeconds()).padStart(2, "0");
return y + "-" + m + "-" + d + " " + h + ":" + min + ":" + s;
}
async function onQuery(pageNum) {
if (state.loading) return;
if (!hasUsableAuth()) { switchTab("login"); return; }
try {
state.loading = true;
setStatus("use", "正在查询...");
var result = await apiPost(API.projectList, buildQueryBody(pageNum));
var data = result.data || [];
var page = result.page || {};
state.workOrders = data;
state.pageNum = page.pageNum || pageNum;
state.total = page.total || 0;
renderList();
updatePager();
setStatus("use", "查询完成,共 " + state.total + " 条记录");
} catch (e) {
setStatus("use", e.message, true);
} finally {
state.loading = false;
}
}
function renderList() {
var tbody = q("#xgd-qw-list-body");
if (!tbody) return;
if (state.workOrders.length === 0) {
tbody.innerHTML = '| 暂无数据 |
';
return;
}
var html = "";
state.workOrders.forEach(function (item) {
var status = statusText[item.status] || item.status || "-";
html += "";
html += "| " + escapeHtml(item.projectCode || "-") + " | ";
html += "" + escapeHtml(item.productName || "-") + " | ";
html += "" + escapeHtml(item.productSpecification || "-") + " | ";
html += "" + escapeHtml(item.planAmount || "-") + " | ";
html += '' + escapeHtml(status) + " | ";
html += "" + escapeHtml(item.planStartTime || "-") + " | ";
html += "" + escapeHtml(item.planEndTime || "-") + " | ";
html += "" + escapeHtml(item.createTime || "-") + " | ";
html += "
";
});
tbody.innerHTML = html;
}
function updatePager() {
var pager = q("#xgd-qw-pager");
var prevBtn = q("#xgd-qw-prev");
var nextBtn = q("#xgd-qw-next");
var pageInfo = q("#xgd-qw-page-info");
if (!pager) return;
var totalPages = Math.ceil(state.total / state.pageSize) || 1;
if (state.total > 0) {
pager.style.display = "flex";
pageInfo.textContent = "第 " + state.pageNum + " / " + totalPages + " 页";
prevBtn.disabled = state.pageNum <= 1;
nextBtn.disabled = state.pageNum >= totalPages;
} else {
pager.style.display = "none";
}
}
function switchPage(pageName) {
state.currentPage = pageName;
var listPage = q("#xgd-qw-page-list");
var createPage = q("#xgd-qw-page-create");
if (listPage) listPage.style.display = pageName === "list" ? "block" : "none";
if (createPage) createPage.style.display = pageName === "create" ? "block" : "none";
if (pageName === "create") {
renderDetailRows();
}
}
function renderDetailRows() {
var tbody = q("#xgd-qw-detail-body");
var thead = q("#xgd-qw-detail-head");
if (!tbody) return;
var hasMatched = state.detailRows.some(function (row) { return row.productCode; });
var html = "";
if (hasMatched) {
if (thead) {
thead.innerHTML = '| 序号 | 产品规格 | 材质 | 计划数 | 产品编号 | 制造车间 | 日产量 | 计划开始 | 计划结束 |
';
}
state.detailRows.forEach(function (row, index) {
html += "";
html += "| " + (index + 1) + " | ";
html += "" + escapeHtml(row.spec || "-") + " | ";
html += "" + escapeHtml(row.material || "-") + " | ";
html += "" + escapeHtml(row.plannedNum || "-") + " | ";
html += "" + escapeHtml(row.productCode || "-") + " | ";
html += "" + escapeHtml(row.workshop || "-") + " | ";
html += "" + escapeHtml(row.dailyOutput || "-") + " | ";
html += "" + escapeHtml(row.planStartTime || "-") + " | ";
html += "" + escapeHtml(row.planEndTime || "-") + " | ";
html += "
";
});
} else {
if (thead) {
thead.innerHTML = '| 序号 | 产品规格 | 材质 | 计划数 | 操作 |
';
}
var optionsHtml = '';
state.materialOptions.forEach(function (opt) {
optionsHtml += '';
});
state.detailRows.forEach(function (row, index) {
html += "";
html += "| " + (index + 1) + " | ";
html += ' | ';
html += ' | ';
html += ' | ';
html += ' | ';
html += "
";
});
html += '';
}
tbody.innerHTML = html || '| 暂无明细行,请点击「+ 添加行」 |
';
if (!hasMatched) {
// datalist handles search natively
}
bindDetailEvents();
}
function bindDetailEvents() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
root.querySelectorAll(".xgd-qw-spec-row").forEach(function (input) {
input.addEventListener("input", function () {
var idx = parseInt(this.getAttribute("data-index"), 10);
state.detailRows[idx].spec = this.value;
// 同步到表头的产品规格
var headerSpec = q("#xgd-qw-spec");
if (headerSpec && this.value) {
headerSpec.value = this.value;
}
});
input.addEventListener("blur", function () {
loadMaterialOptionsForRow();
});
});
root.querySelectorAll(".xgd-qw-material").forEach(function (input) {
input.addEventListener("input", function () {
var idx = parseInt(this.getAttribute("data-index"), 10);
state.detailRows[idx].material = this.value;
});
});
root.querySelectorAll(".xgd-qw-planned-num").forEach(function (input) {
input.addEventListener("input", function () {
var idx = parseInt(this.getAttribute("data-index"), 10);
state.detailRows[idx].plannedNum = this.value;
});
});
root.querySelectorAll(".xgd-qw-del-row").forEach(function (btn) {
btn.addEventListener("click", function () {
var idx = parseInt(this.getAttribute("data-index"), 10);
state.detailRows.splice(idx, 1);
if (state.detailRows.length === 0) {
var headerSpec = (q("#xgd-qw-spec") || {}).value || "";
state.detailRows.push({ spec: headerSpec, material: "", plannedNum: "" });
}
renderDetailRows();
});
});
}
function addDetailRow() {
var headerSpec = (q("#xgd-qw-spec") || {}).value || "";
state.detailRows.push({ spec: headerSpec, material: "", plannedNum: "" });
renderDetailRows();
}
function resetCreateForm() {
var specInput = q("#xgd-qw-spec");
if (specInput) specInput.value = "";
state.detailRows = [{ spec: "", material: "", plannedNum: "" }];
state.materialOptions = [];
state.specProducts = [];
}
async function queryProductsBySpec(spec) {
var products = [];
var seen = {};
var pageNum = 1;
var hasMore = true;
try {
while (hasMore) {
var result = await apiPost(API.productQuery, {
productSpecification: spec,
page: { pageNum: pageNum, pageSize: 100 }
});
var list = (result.data && result.data.data) || [];
var page = result.data && result.data.page;
list.forEach(function (p) {
if (!seen[p.productCode]) {
seen[p.productCode] = true;
products.push(p);
}
});
// 检查是否还有更多数据
if (page && page.total) {
hasMore = products.length < page.total && list.length === 100;
} else {
hasMore = list.length === 100;
}
pageNum++;
// 最多查询10页,防止无限循环
if (pageNum > 10) break;
}
appendLog("query-products-complete", {
spec: spec,
totalProducts: products.length,
pages: pageNum - 1
});
} catch (e) {
appendLog("query-products-error", { spec: spec, error: e.message, pageNum: pageNum });
}
return products;
}
async function queryProductByCode(productCode) {
try {
var result = await apiPost(API.productQuery, {
productCode: productCode,
page: { pageNum: 1, pageSize: 100 }
});
var list = (result.data && result.data.data) || [];
appendLog("product-query-by-code", { productCode: productCode, resultCount: list.length, results: list.map(function (p) { return { code: p.productCode, name: p.productName, spec: p.productSpecification }; }) });
var exact = list.find(function (p) { return p.productCode === productCode; });
return exact || null;
} catch (e) {
appendLog("product-query-by-code-error", { productCode: productCode, error: e.message });
return null;
}
}
async function loadMaterialOptions() {
var spec = (q("#xgd-qw-spec") || {}).value || "";
if (!spec.trim()) return;
spec = spec.trim();
try {
setStatus("use", "正在加载材质选项...");
var products = await queryProductsBySpec(spec);
appendLog("load-material-debug", {
inputSpec: spec,
totalProducts: products.length,
firstProducts: products.slice(0, 3).map(function(p) {
return {
code: p.productCode,
name: p.productName,
spec: p.productSpecification,
hasCustomFields: !!p.customFieldValues,
customFieldNames: p.customFieldValues ? p.customFieldValues.map(function(cf) { return cf.name; }) : []
};
})
});
// 不过滤,直接使用API返回的产品(API本身是模糊查询)
var materials = [];
products.forEach(function (p) {
// 优先从自定义字段中提取材质
if (p.customFieldValues) {
p.customFieldValues.forEach(function (cf) {
if (cf.name === "材质" && cf.value && materials.indexOf(cf.value) === -1) {
materials.push(cf.value);
}
});
}
// 如果没有自定义字段"材质",尝试从产品编号中提取(格式:材质-规格)
if (materials.length === 0 && p.productCode && p.productCode.indexOf("-") !== -1) {
var codeMaterial = p.productCode.split("-")[0];
if (codeMaterial && materials.indexOf(codeMaterial) === -1) {
materials.push(codeMaterial);
}
}
});
state.materialOptions = materials.sort();
state.specProducts = products;
renderDetailRows();
if (materials.length === 0) {
setStatus("use", "未找到该规格对应的材质,请检查产品规格是否正确,或产品是否配置了「材质」自定义字段", true);
} else {
setStatus("use", "已加载 " + materials.length + " 种材质选项");
}
} catch (e) {
appendLog("load-material-error", { spec: spec, error: e.message });
setStatus("use", "加载材质选项失败:" + e.message, true);
}
}
async function loadMaterialOptionsForRow() {
var specs = [];
state.detailRows.forEach(function (row) {
var s = (row.spec || "").trim();
if (s && specs.indexOf(s) === -1) specs.push(s);
});
if (specs.length === 0) return;
try {
setStatus("use", "正在加载材质选项...");
var allProducts = [];
var seen = {};
for (var i = 0; i < specs.length; i++) {
var spec = specs[i];
var products = await queryProductsBySpec(spec);
appendLog("load-material-row-debug", {
inputSpec: spec,
totalProducts: products.length,
firstProducts: products.slice(0, 3).map(function(p) {
return {
code: p.productCode,
name: p.productName,
spec: p.productSpecification,
hasCustomFields: !!p.customFieldValues,
customFieldNames: p.customFieldValues ? p.customFieldValues.map(function(cf) { return cf.name; }) : []
};
})
});
products.forEach(function (p) {
if (!seen[p.productCode]) {
seen[p.productCode] = true;
allProducts.push(p);
}
});
}
var materials = [];
allProducts.forEach(function (p) {
// 优先从自定义字段中提取材质
if (p.customFieldValues) {
p.customFieldValues.forEach(function (cf) {
if (cf.name === "材质" && cf.value && materials.indexOf(cf.value) === -1) {
materials.push(cf.value);
}
});
}
// 如果没有自定义字段"材质",尝试从产品编号中提取(格式:材质-规格)
if (materials.length === 0 && p.productCode && p.productCode.indexOf("-") !== -1) {
var codeMaterial = p.productCode.split("-")[0];
if (codeMaterial && materials.indexOf(codeMaterial) === -1) {
materials.push(codeMaterial);
}
}
});
state.materialOptions = materials.sort();
state.specProducts = allProducts;
renderDetailRows();
if (materials.length === 0) {
setStatus("use", "未找到该规格对应的材质,请检查产品规格是否正确,或产品是否配置了「材质」自定义字段", true);
} else {
setStatus("use", "已加载 " + materials.length + " 种材质选项");
}
} catch (e) {
appendLog("load-material-row-error", { specs: specs, error: e.message });
setStatus("use", "加载材质选项失败:" + e.message, true);
}
}
async function onSubmit() {
if (state.submitting) return;
if (!hasUsableAuth()) { switchTab("login"); return; }
var spec = (q("#xgd-qw-spec") || {}).value || "";
var validRows = state.detailRows.filter(function (row) { return row.spec && row.material && row.plannedNum; });
if (validRows.length === 0) { showToast("请至少填写一行完整的明细(产品规格、材质和计划数)"); return; }
state.submitting = true;
setStatus("use", "正在匹配产品...");
try {
var matchedRows = [];
var unmatchedItems = [];
var productCache = {};
for (var ri = 0; ri < validRows.length; ri++) {
var row = validRows[ri];
var material = row.material.trim();
var rowSpec = (row.spec || "").trim();
var matchedProduct = null;
var productCode = material + "-" + rowSpec;
if (productCache[productCode] !== undefined) {
matchedProduct = productCache[productCode];
} else {
var byCode = await queryProductByCode(productCode);
if (byCode) matchedProduct = byCode;
productCache[productCode] = matchedProduct;
}
if (matchedProduct) {
appendLog("product-matched", { material: material, spec: rowSpec, matchedCode: matchedProduct.productCode, matchedName: matchedProduct.productName });
var workshop = "", dailyOutput = 0;
if (matchedProduct.customFieldValues) {
matchedProduct.customFieldValues.forEach(function (cf) {
if (cf.name === "制造车间") workshop = cf.value || "";
if (cf.name === "日产量") dailyOutput = parseFloat(cf.value) || 0;
});
}
var planNum = parseFloat(row.plannedNum) || 0;
var leadTime = dailyOutput > 0 ? parseFloat((planNum / dailyOutput).toFixed(3)) : 1;
matchedRows.push({ spec: rowSpec, material: material, plannedNum: row.plannedNum, productCode: matchedProduct.productCode, productName: matchedProduct.productName, workshop: workshop, dailyOutput: dailyOutput, leadTime: leadTime });
} else {
appendLog("product-not-matched", { material: material, spec: rowSpec, productCode: productCode });
unmatchedItems.push(productCode);
}
}
if (unmatchedItems.length > 0) { showToast("以下产品编号不存在:" + unmatchedItems.join("、")); setStatus("use", "部分产品不存在", true); state.submitting = false; return; }
setStatus("use", "正在查询车间排产情况...");
var workshopMaxEndTime = await getWorkshopMaxEndTime(matchedRows);
var now = new Date();
var workshopNextStart = {};
matchedRows.forEach(function (row) {
var maxEnd = workshopMaxEndTime[row.workshop];
var startTime;
if (maxEnd) {
startTime = adjustToWorkTime(new Date(maxEnd));
} else if (workshopNextStart[row.workshop]) {
startTime = adjustToWorkTime(new Date(workshopNextStart[row.workshop]));
} else {
startTime = adjustToWorkTime(new Date(now));
}
row.planStartTime = formatDateTime(startTime);
var dailyWorkMinutes = getDailyWorkMinutes();
var dailyWorkHours = dailyWorkMinutes / 60;
var workDays = row.leadTime > 0 ? row.leadTime : 1;
var totalWorkHours = workDays * dailyWorkHours;
var endTime = addWorkHours(startTime, totalWorkHours);
row.planEndTime = formatDateTime(endTime);
workshopNextStart[row.workshop] = row.planEndTime;
});
state.detailRows = matchedRows.map(function (row) {
return { spec: row.spec, material: row.material, plannedNum: row.plannedNum, productCode: row.productCode, productName: row.productName, workshop: row.workshop, dailyOutput: row.dailyOutput, leadTime: row.leadTime, planStartTime: row.planStartTime, planEndTime: row.planEndTime };
});
renderDetailRows();
setStatus("use", "产品匹配成功,正在逐个创建工单...");
var createdCount = 0;
for (var i = 0; i < matchedRows.length; i++) {
var row = matchedRows[i];
setStatus("use", "正在创建第 " + (i + 1) + "/" + matchedRows.length + " 个工单:" + row.material);
try {
await apiPost(API.projectCreate, { productCode: row.productCode, plannedNum: String(row.plannedNum), planStartTime: row.planStartTime, planEndTime: row.planEndTime });
createdCount++;
} catch (createErr) {
showToast("创建工单失败(" + row.material + "):" + createErr.message);
break;
}
}
if (createdCount === matchedRows.length) {
showToast("全部创建成功,共 " + createdCount + " 个工单");
setStatus("use", "全部创建成功,共 " + createdCount + " 个工单");
} else {
setStatus("use", "部分创建成功:" + createdCount + "/" + matchedRows.length, true);
}
} catch (e) {
setStatus("use", e.message, true);
showToast("操作失败:" + e.message);
} finally {
state.submitting = false;
}
}
async function getWorkshopMaxEndTime(matchedRows) {
var result = {};
var workshops = [];
matchedRows.forEach(function (row) { if (row.workshop && workshops.indexOf(row.workshop) === -1) workshops.push(row.workshop); });
if (workshops.length === 0) return result;
var now = new Date();
var futureDate = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000);
var allWorkOrders = [];
var statuses = ["0", "10"];
for (var s = 0; s < statuses.length; s++) {
var pageNum = 1;
var hasMore = true;
while (hasMore) {
try {
var woResult = await apiPost(API.projectList, { status: statuses[s], planStartTimeGte: formatDateTime(now), planStartTimeLte: formatDateTime(futureDate), page: { pageNum: pageNum, pageSize: 100 } });
var workOrders = woResult.data || [];
allWorkOrders = allWorkOrders.concat(workOrders);
var page = woResult.page || {};
var totalPages = Math.ceil((page.total || 0) / 100);
hasMore = pageNum < totalPages && workOrders.length === 100;
pageNum++;
} catch (e) { hasMore = false; }
}
}
var productCodes = [];
allWorkOrders.forEach(function (wo) { if (wo.productCode && productCodes.indexOf(wo.productCode) === -1) productCodes.push(wo.productCode); });
var productWorkshopMap = {};
for (var i = 0; i < productCodes.length; i++) {
try {
var prodResult = await apiPost(API.productQuery, { productCode: productCodes[i], page: { pageNum: 1, pageSize: 1 } });
var productList = (prodResult.data && prodResult.data.data) || [];
if (productList.length > 0 && productList[0].customFieldValues) {
var wf = productList[0].customFieldValues.find(function (cf) { return cf.name === "制造车间"; });
if (wf && wf.value) productWorkshopMap[productCodes[i]] = wf.value;
}
} catch (e) { /* ignore */ }
}
workshops.forEach(function (workshop) {
var maxEndTime = null;
allWorkOrders.forEach(function (wo) {
if (productWorkshopMap[wo.productCode] === workshop && wo.planEndTime) {
if (!maxEndTime || wo.planEndTime > maxEndTime) maxEndTime = wo.planEndTime;
}
});
if (maxEndTime) result[workshop] = maxEndTime;
});
return result;
}
// 调整工单时间到作息时间内
async function adjustWorkOrderTimes() {
if (!hasUsableAuth()) { switchTab("login"); return; }
if (state.workOrders.length === 0) {
showToast("请先查询工单列表");
return;
}
var config = getWorkTimeConfig();
var periods = getWorkPeriods();
if (periods.length === 0) {
showToast("请先配置工作时间");
return;
}
// 获取第一个时间段(上午)和第二个时间段(下午)
var morningPeriod = periods[0];
var afternoonPeriod = periods.length > 1 ? periods[1] : null;
if (!morningPeriod || !afternoonPeriod) {
showToast("工作时间配置不完整,需要上午和下午时间段");
return;
}
var adjustedCount = 0;
var failedCount = 0;
setStatus("use", "正在调整工单时间...");
for (var i = 0; i < state.workOrders.length; i++) {
var wo = state.workOrders[i];
if (!wo.planStartTime || !wo.planEndTime) continue;
try {
var startTime = new Date(wo.planStartTime);
var endTime = new Date(wo.planEndTime);
// 检查是否跨天(遇到0点)
var startDay = startTime.getDate();
var endDay = endTime.getDate();
var isCrossDay = startDay !== endDay;
var newStartTime, newEndTime;
if (isCrossDay) {
// 跨天情况:开始时间取上午上班时间,结束时间取下午下班时间
newStartTime = new Date(startTime);
newStartTime.setHours(Math.floor(morningPeriod.start / 60), morningPeriod.start % 60, 0, 0);
newEndTime = new Date(endTime);
newEndTime.setHours(Math.floor(afternoonPeriod.end / 60), afternoonPeriod.end % 60, 0, 0);
} else {
// 不跨天:按原逻辑调整
newStartTime = adjustTimeToWorkHours(startTime, morningPeriod, afternoonPeriod);
newEndTime = adjustTimeToWorkHours(endTime, morningPeriod, afternoonPeriod);
}
// 如果时间有变化,才调用更新接口
if (newStartTime.getTime() !== startTime.getTime() || newEndTime.getTime() !== endTime.getTime()) {
await apiPost(API.projectUpdate, {
projectCode: wo.projectCode,
planStartTime: formatDateTime(newStartTime),
planEndTime: formatDateTime(newEndTime)
});
adjustedCount++;
}
} catch (e) {
appendLog("adjust-time-error", { projectCode: wo.projectCode, error: e.message });
failedCount++;
}
}
if (failedCount > 0) {
setStatus("use", "调整完成:成功 " + adjustedCount + " 条,失败 " + failedCount + " 条", true);
showToast("调整完成,部分失败请查看日志");
} else {
setStatus("use", "调整完成:成功 " + adjustedCount + " 条");
showToast("时间调整成功");
// 重新查询列表
await onQuery(state.pageNum);
}
}
// 调整单个时间到作息时间内
function adjustTimeToWorkHours(date, morningPeriod, afternoonPeriod) {
var d = new Date(date);
var minutes = d.getHours() * 60 + d.getMinutes();
// 早于早上上班时间 → 改为上班时间
if (minutes < morningPeriod.start) {
d.setHours(Math.floor(morningPeriod.start / 60), morningPeriod.start % 60, 0, 0);
return d;
}
// 在上午工作时间内 → 保持不变
if (minutes >= morningPeriod.start && minutes < morningPeriod.end) {
return d;
}
// 在中午休息时间 → 加上午休间隔时间
if (minutes >= morningPeriod.end && minutes < afternoonPeriod.start) {
var lunchBreakMinutes = afternoonPeriod.start - morningPeriod.end;
var newMinutes = minutes + lunchBreakMinutes;
d.setHours(Math.floor(newMinutes / 60), newMinutes % 60, 0, 0);
return d;
}
// 在下午工作时间内 → 保持不变
if (minutes >= afternoonPeriod.start && minutes < afternoonPeriod.end) {
return d;
}
// 晚于下午下班时间 → 改为下班时间
if (minutes >= afternoonPeriod.end) {
d.setHours(Math.floor(afternoonPeriod.end / 60), afternoonPeriod.end % 60, 0, 0);
return d;
}
return d;
}
// __BUSINESS_API_CALLS_END__
// ============================================================
// BOOTSTRAP
// ============================================================
boot();
})();