From 913f93fabd4f7d6eba8b70da4c677ef1bc99805f Mon Sep 17 00:00:00 2001 From: zhouyonggao <1971162852@qq.com> Date: Wed, 17 Dec 2025 09:40:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E9=87=8D=E6=9E=84=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=BC=95=E5=85=A5=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除对vue及element-plus脚本的defer属性,确保按顺序加载 - 拆分原有配置与功能脚本为多个模块文件 - 引入config、utils、api、fields、state等模块化脚本 - 替换主入口脚本为重构后的 main.refactored.js - 优化脚本加载顺序,提高代码可维护性与扩展性 --- web/index.html | 12 +- web/main.refactored.js | 909 +++++++++++++++++++++++++++++++++++++++++ web/modules/api.js | 429 +++++++++++++++++++ web/modules/config.js | 321 +++++++++++++++ web/modules/fields.js | 462 +++++++++++++++++++++ web/modules/state.js | 263 ++++++++++++ web/modules/utils.js | 187 +++++++++ 7 files changed, 2580 insertions(+), 3 deletions(-) create mode 100644 web/main.refactored.js create mode 100644 web/modules/api.js create mode 100644 web/modules/config.js create mode 100644 web/modules/fields.js create mode 100644 web/modules/state.js create mode 100644 web/modules/utils.js diff --git a/web/index.html b/web/index.html index 345f95e..2c5a219 100644 --- a/web/index.html +++ b/web/index.html @@ -310,8 +310,14 @@ - - - + + + + + + + + + diff --git a/web/main.refactored.js b/web/main.refactored.js new file mode 100644 index 0000000..7e31495 --- /dev/null +++ b/web/main.refactored.js @@ -0,0 +1,909 @@ +/** + * 营销系统数据导出工具 - 主入口 + * @description 使用模块化架构重构,提升可读性和扩展性 + */ + +const { createApp, reactive } = Vue; + +// ==================== 模块引用 ==================== +const { CONSTANTS, getDatasourceOptions, getDatasourceLabel, getMainTable, getOrderTypeOptions, getDefaultOrderType, getSceneOptions, getSceneLabel, getOrderTypeLabel, VISIBILITY_OPTIONS, FORMAT_OPTIONS, getDefaultFields } = window.AppConfig; +const { showMessage, formatDateTime, getMonthRange, clampDialogWidth, parseWidth, calculateJobProgress } = window.AppUtils; +const { fieldsManager, TreeUtils } = window.FieldsModule; +const { createAppState, ValidationRules } = window.StateModule; +const Api = window.ApiService; + +// ==================== Vue 应用 ==================== +const app = createApp({ + setup() { + // ==================== 状态初始化 ==================== + const state = reactive(createAppState()); + + // ==================== 计算属性 ==================== + const hasUserId = Vue.computed(() => !!Api.getUserId()); + const currentUserId = Vue.computed(() => { + const userId = Api.getUserId(); + return userId ? Number(userId) : null; + }); + + // ==================== 字段元数据管理 ==================== + const metaTableLabels = Vue.ref({}); + const recommendedMeta = Vue.ref([]); + + /** + * 加载字段元数据 + * @param {string} datasource - 数据源 + * @param {number} orderType - 订单类型 + * @returns {Promise} 推荐字段列表 + */ + const loadFieldsMetadata = async (datasource, orderType) => { + try { + const { tables, recommended } = await Api.fetchFieldsMetadata(datasource, orderType); + fieldsManager.updateMetadata(tables, recommended); + metaTableLabels.value = fieldsManager.tableLabels; + recommendedMeta.value = recommended; + return recommended; + } catch (error) { + console.error('加载字段元数据失败:', error); + fieldsManager.updateMetadata([], []); + metaTableLabels.value = {}; + recommendedMeta.value = []; + return []; + } + }; + + // ==================== 树形选择器数据 ==================== + const fieldTreeData = Vue.computed(() => { + return fieldsManager.buildFieldTree( + state.form.datasource, + state.form.orderType + ); + }); + + const editFieldTreeData = Vue.computed(() => { + return fieldsManager.buildFieldTree( + state.edit.datasource, + state.edit.orderType, + state.edit.main_table + ); + }); + + // ==================== 选项配置 ==================== + const datasourceOptions = getDatasourceOptions(); + const visibilityOptions = VISIBILITY_OPTIONS; + const formatOptions = FORMAT_OPTIONS; + + const sceneOptions = Vue.computed(() => getSceneOptions(state.form.datasource)); + const editSceneOptions = Vue.computed(() => getSceneOptions(state.edit.datasource)); + + /** + * 获取订单类型选项 + * @param {string} datasource - 数据源 + * @returns {Array} 订单类型选项 + */ + const orderTypeOptionsFor = (datasource) => getOrderTypeOptions(datasource); + + /** + * 获取数据源标签 + * @param {string} value - 数据源值 + * @returns {string} 数据源标签 + */ + const dsLabel = (value) => getDatasourceLabel(value); + + // ==================== 树形选择器引用 ==================== + const createFieldsTree = Vue.ref(null); + const editFieldsTree = Vue.ref(null); + + /** + * 树形选择器复选框变化处理 + * @param {string} kind - 操作类型: 'create' | 'edit' + */ + const onTreeCheck = (kind) => { + if (kind !== 'create' && kind !== 'edit') { + console.warn('onTreeCheck: 无效的 kind 参数', kind); + return; + } + + const tree = kind === 'create' ? createFieldsTree.value : editFieldsTree.value; + if (!tree) return; + + const checkedKeys = tree.getCheckedKeys(); + const treeData = kind === 'create' ? fieldTreeData.value : editFieldTreeData.value; + const paths = TreeUtils.checkedKeysToLeafPaths(checkedKeys, treeData); + + if (kind === 'create') { + state.form.fieldsSel = paths; + } else { + state.edit.fieldsSel = paths; + } + }; + + /** + * 设置树形选择器选中状态 + * @param {string} kind - 操作类型 + * @param {Array} values - 选中值 + */ + const setTreeChecked = (kind, values) => { + const tree = kind === 'create' ? createFieldsTree.value : editFieldsTree.value; + if (!tree || !values || !Array.isArray(values)) return; + + const treeData = kind === 'create' ? fieldTreeData.value : editFieldTreeData.value; + const keys = TreeUtils.pathsToNodeKeys(values, treeData); + + Vue.nextTick(() => { + setTimeout(() => { + try { + if (keys.length > 0 && tree) { + tree.setCheckedKeys(keys); + } + } catch (error) { + console.warn('设置树形选择器选中状态失败:', error); + } + }, CONSTANTS.TREE_RENDER_DELAY); + }); + }; + + // 监听字段选择变化 + Vue.watch(() => state.form.fieldsSel, (newVal) => { + if (newVal?.length > 0) { + Vue.nextTick(() => setTreeChecked('create', newVal)); + } + }); + + Vue.watch(() => state.edit.fieldsSel, (newVal) => { + if (newVal?.length > 0) { + Vue.nextTick(() => setTreeChecked('edit', newVal)); + } + }); + + // ==================== 表单验证规则 ==================== + const createRules = ValidationRules.createTemplateRules(); + const editRules = ValidationRules.createEditRules(); + const exportRules = ValidationRules.createExportRules(); + + // ==================== 表单引用 ==================== + const createFormRef = Vue.ref(null); + const editFormRef = Vue.ref(null); + const exportFormRef = Vue.ref(null); + + // ==================== 选项数据 ==================== + const creatorOptions = Vue.ref([]); + const ymtCreatorOptions = Vue.ref([]); + const resellerOptions = Vue.ref([]); + const planOptions = Vue.ref([]); + const ymtMerchantOptions = Vue.ref([]); + const ymtActivityOptions = Vue.ref([]); + + /** + * 加载创建者列表 + */ + const loadCreators = async () => { + try { + creatorOptions.value = await Api.fetchCreators(); + } catch (error) { + console.error('加载创建者列表失败:', error); + creatorOptions.value = []; + } + }; + + /** + * 加载易码通用户列表 + */ + const loadYmtCreators = async () => { + try { + const userId = Api.getUserId(); + ymtCreatorOptions.value = await Api.fetchYmtUsers(userId || undefined); + } catch (error) { + console.error('加载易码通用户列表失败:', error); + ymtCreatorOptions.value = []; + } + }; + + /** + * 加载分销商列表 + */ + const loadResellers = async () => { + const ids = state.exportForm.creatorIds; + if (!ids?.length) { + resellerOptions.value = []; + return; + } + try { + resellerOptions.value = await Api.fetchResellers(ids); + } catch (error) { + console.error('加载分销商列表失败:', error); + resellerOptions.value = []; + } + }; + + /** + * 加载易码通客户列表 + */ + const loadYmtMerchants = async () => { + const userId = state.exportForm.ymtCreatorId; + if (!userId) { + ymtMerchantOptions.value = []; + return; + } + try { + ymtMerchantOptions.value = await Api.fetchYmtMerchants(userId); + } catch (error) { + console.error('加载客户列表失败:', error); + ymtMerchantOptions.value = []; + } + }; + + /** + * 加载易码通活动列表 + */ + const loadYmtActivities = async () => { + const merchantId = state.exportForm.ymtMerchantId; + if (!merchantId) { + ymtActivityOptions.value = []; + return; + } + try { + ymtActivityOptions.value = await Api.fetchYmtActivities(merchantId); + } catch (error) { + console.error('加载活动列表失败:', error); + ymtActivityOptions.value = []; + } + }; + + /** + * 加载计划列表 + */ + const loadPlans = async () => { + const resellerId = state.exportForm.resellerId; + if (!resellerId) { + planOptions.value = []; + return; + } + try { + planOptions.value = await Api.fetchPlans(resellerId); + } catch (error) { + console.error('加载计划列表失败:', error); + planOptions.value = []; + } + }; + + // ==================== 导出相关计算属性 ==================== + const hasCreators = Vue.computed(() => state.exportForm.creatorIds?.length > 0); + const hasReseller = Vue.computed(() => !!state.exportForm.resellerId); + const hasPlan = Vue.computed(() => !!state.exportForm.planId); + const hasKeyBatch = Vue.computed(() => !!state.exportForm.keyBatchId); + const hasCodeBatch = Vue.computed(() => !!state.exportForm.codeBatchId); + + const exportType = Vue.computed(() => { + const filters = state.exportTpl?.filters; + if (!filters) return null; + if (filters.type_eq != null) return Number(filters.type_eq); + if (Array.isArray(filters.type_in) && filters.type_in.length === 1) { + return Number(filters.type_in[0]); + } + return null; + }); + + const exportTypeList = Vue.computed(() => { + const filters = state.exportTpl?.filters; + if (!filters) return []; + if (Array.isArray(filters.type_in) && filters.type_in.length) { + return filters.type_in.map(Number); + } + if (filters.type_eq != null) return [Number(filters.type_eq)]; + return []; + }); + + const isOrder = Vue.computed(() => { + const mainTable = state.exportTpl?.main_table; + return mainTable === 'order' || mainTable === 'order_info'; + }); + + const exportTitle = Vue.computed(() => { + let title = '执行导出'; + const mainTable = state.exportTpl?.main_table; + if (mainTable) { + title += ' - ' + getSceneLabel(mainTable); + if (mainTable === 'order' || mainTable === 'order_info') { + const datasource = state.exportTpl?.datasource; + const labels = exportTypeList.value + .map(type => getOrderTypeLabel(datasource, type)) + .filter(Boolean); + if (labels.length) { + title += ' - 订单类型:' + labels.join('、'); + } + } + } + return title; + }); + + // ==================== 模板管理 ==================== + /** + * 加载模板列表 + */ + const loadTemplates = async () => { + try { + state.templates = await Api.fetchTemplates(); + } catch (error) { + showMessage('加载模板失败', 'error'); + state.templates = []; + } + }; + + /** + * 创建模板 + */ + const createTemplate = async () => { + const formRef = createFormRef.value; + const valid = formRef ? await formRef.validate().catch(() => false) : true; + if (!valid) { + showMessage('请完善必填项', 'error'); + return; + } + + let fields = []; + const { datasource, main_table, fieldsSel, fieldsRaw, orderType, name, file_format, visibility } = state.form; + + if (fieldsSel?.length) { + fields = fieldsManager.convertPathsToFields(fieldsSel, datasource, main_table); + } else { + const recommended = recommendedMeta.value; + if (recommended?.length) { + fields = recommended; + } else { + fields = getDefaultFields(datasource); + } + } + + const payload = { + name, + datasource, + main_table: getMainTable(datasource), + fields, + filters: { type_eq: Number(orderType) }, + file_format, + visibility, + owner_id: Api.getUserId() ? Number(Api.getUserId()) : 0 + }; + + try { + await Api.createTemplate(payload); + showMessage('创建成功'); + state.createVisible = false; + loadTemplates(); + } catch (error) { + showMessage(error.message || '创建失败', 'error'); + } + }; + + /** + * 打开编辑对话框 + * @param {Object} row - 模板行数据 + */ + const openEdit = async (row) => { + state.edit.id = row.id; + try { + const template = await Api.fetchTemplateDetail(row.id); + + state.edit.name = template.name || row.name || ''; + state.edit.datasource = template.datasource || row.datasource || 'marketing'; + state.edit.main_table = template.main_table || row.main_table || 'order'; + state.edit.file_format = template.file_format || row.file_format || 'xlsx'; + state.edit.visibility = template.visibility || row.visibility || 'private'; + + const filters = template.filters || {}; + if (filters.type_eq != null) { + state.edit.orderType = Number(filters.type_eq); + } else if (Array.isArray(filters.type_in) && filters.type_in.length === 1) { + state.edit.orderType = Number(filters.type_in[0]); + } else { + state.edit.orderType = getDefaultOrderType(state.edit.datasource); + } + + const fields = Array.isArray(template.fields) ? template.fields : []; + state.editVisible = true; + + await Vue.nextTick(); + await loadFieldsMetadata(state.edit.datasource, state.edit.orderType); + await Vue.nextTick(); + + const mainTable = state.edit.main_table || 'order'; + const paths = fieldsManager.deduplicatePaths( + fieldsManager.convertFieldsToPaths(fields, state.edit.datasource, mainTable) + ); + state.edit.fieldsSel = paths; + + await Vue.nextTick(); + setTimeout(() => setTreeChecked('edit', paths), CONSTANTS.TREE_EDIT_RENDER_DELAY); + } catch (error) { + console.error('加载模板详情失败:', error); + state.edit.name = row.name; + state.edit.datasource = row.datasource || 'marketing'; + state.edit.main_table = row.main_table || 'order'; + state.edit.file_format = row.file_format || 'xlsx'; + state.edit.visibility = row.visibility || 'private'; + state.edit.orderType = getDefaultOrderType(state.edit.datasource); + state.edit.fieldsSel = []; + state.editVisible = true; + } + }; + + /** + * 保存编辑 + */ + const saveEdit = async () => { + const formRef = editFormRef.value; + const valid = formRef ? await formRef.validate().catch(() => false) : true; + if (!valid) { + showMessage('请完善必填项', 'error'); + return; + } + + const { id, name, datasource, main_table, fieldsSel, visibility, file_format, orderType } = state.edit; + + let fields = []; + if (fieldsSel?.length) { + fields = fieldsManager.convertPathsToFields(fieldsSel, datasource, main_table || 'order'); + } else { + fields = getDefaultFields(datasource); + } + + if (!fields.length) { + showMessage('请至少选择一个字段', 'error'); + return; + } + + const payload = { + name, + visibility, + file_format, + fields, + filters: { type_eq: Number(orderType || 1) }, + main_table: 'order' + }; + + try { + await Api.updateTemplate(id, payload); + showMessage('保存成功'); + state.editVisible = false; + loadTemplates(); + } catch (error) { + showMessage(error.message || '保存失败', 'error'); + } + }; + + /** + * 删除模板 + * @param {number} id - 模板 ID + */ + const removeTemplate = async (id) => { + try { + await Api.deleteTemplate(id); + showMessage('删除成功'); + loadTemplates(); + } catch (error) { + showMessage(error.message || '删除失败', 'error'); + } + }; + + // ==================== 导出任务 ==================== + /** + * 打开导出对话框 + * @param {Object} row - 模板行数据 + */ + const openExport = async (row) => { + state.exportForm.tplId = row.id; + + try { + const template = await Api.fetchTemplateDetail(row.id); + state.exportTpl = template; + } catch (error) { + console.error('加载模板详情失败:', error); + state.exportTpl = { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' }; + } + + state.exportForm.datasource = state.exportTpl.datasource || row.datasource || 'marketing'; + state.exportForm.file_format = state.exportTpl.file_format || row.file_format || 'xlsx'; + + if (state.exportForm.datasource === 'marketing') { + loadCreators(); + const userId = Api.getUserId(); + if (userId) { + const parts = String(userId).split(',').map(s => s.trim()).filter(Boolean); + state.exportForm.creatorIds = parts.length > 1 + ? parts.map(Number) + : [Number(userId)]; + } + } + + if (state.exportForm.datasource === 'ymt') { + await loadYmtCreators(); + await loadYmtMerchants(); + await loadYmtActivities(); + const userId = Api.getUserId(); + if (userId) { + const first = String(userId).split(',').map(s => s.trim()).filter(Boolean)[0]; + if (first) { + state.exportForm.ymtCreatorId = Number(first); + } + } + } + + if (!state.exportForm.dateRange?.length) { + state.exportForm.dateRange = getMonthRange(-1); + } + + state.exportVisible = true; + }; + + /** + * 提交导出任务 + */ + const submitExport = async () => { + const formRef = exportFormRef.value; + const valid = formRef ? await formRef.validate().catch(() => false) : true; + if (!valid) { + showMessage('请完善必填项', 'error'); + return; + } + + state.exportSubmitting = true; + showMessage('估算中', 'info'); + + try { + const { tplId, dateRange, datasource, file_format, planId, resellerId, voucherChannelActivityId, creatorIds, creatorIdsRaw, ymtCreatorId, ymtMerchantId, ymtActivityId } = state.exportForm; + + const filters = {}; + const typeValue = exportType.value; + if (typeValue != null) { + filters.type_eq = Number(typeValue); + } + + if (dateRange?.length === 2) { + filters.create_time_between = [dateRange[0], dateRange[1]]; + } + + if (planId) filters.plan_id_eq = Number(planId); + if (resellerId) filters.reseller_id_eq = Number(resellerId); + + if (voucherChannelActivityId) { + const type = exportType.value; + if ((datasource === 'marketing' && type === 2) || (datasource === 'ymt' && type === 3)) { + filters.order_voucher_channel_activity_id_eq = voucherChannelActivityId; + } + } + + if (creatorIds?.length) { + filters.creator_in = creatorIds.map(Number); + } else if (creatorIdsRaw) { + const arr = String(creatorIdsRaw).split(',').map(s => s.trim()).filter(Boolean); + if (arr.length) filters.creator_in = arr; + } + + if (datasource === 'ymt') { + if (String(ymtCreatorId).trim()) { + filters.creator_in = [Number(ymtCreatorId)]; + } + if (String(ymtMerchantId).trim()) { + filters.reseller_id_eq = Number(ymtMerchantId); + } + if (String(ymtActivityId).trim()) { + filters.plan_id_eq = Number(ymtActivityId); + } + } + + const payload = { + template_id: Number(tplId), + requested_by: 1, + permission: {}, + options: {}, + filters, + file_format, + datasource + }; + + const result = await Api.createExportJob(payload); + const jobId = result?.data?.id ?? result?.id; + + state.exportVisible = false; + + if (jobId) { + state.jobsTplId = Number(tplId); + state.jobsVisible = true; + loadJobs(1); + startJobsPolling(); + } else { + showMessage('任务创建返回异常', 'error'); + } + } catch (error) { + showMessage(error.message || '导出失败', 'error'); + } finally { + state.exportSubmitting = false; + } + }; + + // ==================== 任务管理 ==================== + let jobsPollTimer = null; + + /** + * 加载任务列表 + * @param {number} [page] - 页码 + */ + const loadJobs = async (page) => { + page = page || state.jobsPage; + try { + const { items, total, page: currentPage } = await Api.fetchJobs({ + page, + pageSize: state.jobsPageSize, + templateId: state.jobsTplId + }); + state.jobs = items; + state.jobsTotal = total; + state.jobsPage = currentPage; + } catch (error) { + console.error('加载任务列表失败:', error); + state.jobs = []; + } + }; + + /** + * 检查是否所有任务都已完成 + */ + const checkAndStopPollingIfComplete = () => { + const hasRunningJob = state.jobs.some( + job => job.status === 'queued' || job.status === 'running' + ); + if (!hasRunningJob && state.jobs.length > 0) { + stopJobsPolling(); + } + }; + + /** + * 启动任务轮询 + */ + const startJobsPolling = () => { + if (jobsPollTimer) return; + jobsPollTimer = setInterval(async () => { + if (state.jobsVisible) { + await loadJobs(state.jobsPage); + checkAndStopPollingIfComplete(); + } + }, CONSTANTS.JOBS_POLL_INTERVAL); + }; + + /** + * 停止任务轮询 + */ + const stopJobsPolling = () => { + if (jobsPollTimer) { + clearInterval(jobsPollTimer); + jobsPollTimer = null; + } + }; + + /** + * 打开任务列表 + * @param {Object} row - 模板行数据 + */ + const openJobs = (row) => { + state.jobsTplId = row.id; + state.jobsVisible = true; + loadJobs(1); + startJobsPolling(); + }; + + /** + * 关闭任务列表 + */ + const closeJobs = () => { + state.jobsVisible = false; + stopJobsPolling(); + }; + + /** + * 计算任务进度 + * @param {Object} row - 任务行数据 + * @returns {string} 进度描述 + */ + const jobPercent = (row) => calculateJobProgress(row); + + /** + * 下载文件 + * @param {number} id - 任务 ID + */ + const download = (id) => { + window.open(Api.getDownloadUrl(id), '_blank'); + }; + + /** + * 打开 SQL 预览 + * @param {number} id - 任务 ID + */ + const openSQL = async (id) => { + try { + state.sqlExplainDesc = ''; + state.sqlText = await Api.fetchJobSql(id); + + try { + const job = await Api.fetchJobDetail(id); + state.sqlExplainDesc = job?.eval_desc || job?.eval_status || ''; + } catch { + const row = state.jobs.find(r => Number(r.id) === Number(id)); + state.sqlExplainDesc = row?.eval_desc || row?.eval_status || ''; + } + + state.sqlVisible = true; + } catch (error) { + console.error('加载SQL失败:', error); + state.sqlText = ''; + state.sqlExplainDesc = ''; + state.sqlVisible = false; + showMessage('加载SQL失败', 'error'); + } + }; + + // ==================== 对话框尺寸管理 ==================== + /** + * 调整对话框尺寸 + * @param {string} kind - 对话框类型 + * @param {number} delta - 变化量 + */ + const resizeDialog = (kind, delta) => { + if (kind === 'create') { + const current = parseWidth(state.createWidth, CONSTANTS.DIALOG_DEFAULT_WIDTH); + const next = clampDialogWidth(current + delta); + state.createWidth = next; + localStorage.setItem('tplDialogWidth', next); + } else if (kind === 'edit') { + const current = parseWidth(state.editWidth, CONSTANTS.DIALOG_EDIT_DEFAULT_WIDTH); + const next = clampDialogWidth(current + delta); + state.editWidth = next; + localStorage.setItem('tplEditDialogWidth', next); + } + }; + + // ==================== 监听器 ==================== + // 数据源变化 + Vue.watch(() => state.form.datasource, async (datasource) => { + state.form.fieldsSel = []; + state.form.main_table = getMainTable(datasource); + state.form.orderType = getDefaultOrderType(datasource); + await loadFieldsMetadata(datasource, state.form.orderType); + }); + + // 订单类型变化 + Vue.watch(() => state.form.orderType, async () => { + state.form.fieldsSel = []; + await loadFieldsMetadata(state.form.datasource, state.form.orderType); + }); + + // 编辑数据源变化 + Vue.watch(() => state.edit.datasource, async (datasource) => { + state.edit.fieldsSel = []; + state.edit.main_table = getMainTable(datasource); + if (!state.edit.orderType) { + state.edit.orderType = getDefaultOrderType(datasource); + } + await loadFieldsMetadata(datasource, state.edit.orderType); + }); + + // 编辑订单类型变化 + Vue.watch(() => state.edit.orderType, async () => { + state.edit.fieldsSel = []; + await loadFieldsMetadata(state.edit.datasource, state.edit.orderType); + }); + + // 导出筛选条件变化 + Vue.watch(() => state.exportForm.creatorIds, () => { + state.exportForm.resellerId = null; + state.exportForm.planId = null; + state.exportForm.keyBatchId = null; + state.exportForm.codeBatchId = null; + loadResellers(); + }); + + Vue.watch(() => state.exportForm.ymtCreatorId, () => { + state.exportForm.ymtMerchantId = null; + state.exportForm.ymtActivityId = null; + loadYmtMerchants(); + }); + + Vue.watch(() => state.exportForm.ymtMerchantId, () => { + state.exportForm.ymtActivityId = null; + loadYmtActivities(); + }); + + Vue.watch(() => state.exportForm.resellerId, () => { + state.exportForm.planId = null; + state.exportForm.keyBatchId = null; + state.exportForm.codeBatchId = null; + loadPlans(); + }); + + Vue.watch(() => state.exportForm.planId, () => { + state.exportForm.keyBatchId = null; + state.exportForm.codeBatchId = null; + }); + + Vue.watch(() => state.exportForm.keyBatchId, () => { + state.exportForm.codeBatchId = null; + }); + + // ==================== 初始化 ==================== + loadTemplates(); + loadFieldsMetadata(state.form.datasource, state.form.orderType); + + // 组件销毁时清理 + Vue.onUnmounted(() => { + stopJobsPolling(); + }); + + // ==================== 返回 ==================== + return { + ...Vue.toRefs(state), + // 选项配置 + visibilityOptions, + formatOptions, + datasourceOptions, + sceneOptions, + editSceneOptions, + // 模板管理 + loadTemplates, + createTemplate, + openEdit, + saveEdit, + removeTemplate, + // 导出管理 + openExport, + submitExport, + // 任务管理 + loadJobs, + openJobs, + closeJobs, + download, + openSQL, + jobPercent, + // 对话框 + resizeDialog, + // 验证规则 + createRules, + editRules, + exportRules, + // 表单引用 + createFormRef, + editFormRef, + exportFormRef, + // 树形选择器 + createFieldsTree, + editFieldsTree, + fieldTreeData, + editFieldTreeData, + onTreeCheck, + setTreeChecked, + // 工具函数 + dsLabel, + orderTypeOptionsFor, + fmtDT: formatDateTime, + // 计算属性 + exportType, + isOrder, + exportTitle, + hasUserId, + currentUserId, + hasCreators, + hasReseller, + hasPlan, + hasKeyBatch, + hasCodeBatch, + // 选项数据 + creatorOptions, + ymtCreatorOptions, + ymtMerchantOptions, + ymtActivityOptions, + resellerOptions, + planOptions + }; + } +}); + +app.use(ElementPlus); +app.mount('#app'); diff --git a/web/modules/api.js b/web/modules/api.js new file mode 100644 index 0000000..633020f --- /dev/null +++ b/web/modules/api.js @@ -0,0 +1,429 @@ +/** + * API 服务层 - 统一处理 HTTP 请求 + * @module api + */ + +/** + * 获取 API 基础地址 + * @returns {string} API 基础 URL + */ +const getApiBase = () => { + if (window.__API_BASE__ && String(window.__API_BASE__).trim()) { + return String(window.__API_BASE__).trim(); + } + return typeof location !== 'undefined' ? location.origin : 'http://localhost:8077'; +}; + +const API_BASE = getApiBase(); + +/** + * 从 URL 参数中获取用户 ID + * @returns {string} 用户 ID,不存在返回空字符串 + */ +const getUserId = () => { + const params = new URLSearchParams(window.location.search || ''); + const value = params.get('userId') || params.get('userid') || params.get('user_id'); + return value && String(value).trim() ? String(value).trim() : ''; +}; + +/** + * 从 URL 参数中获取商户 ID + * @returns {string} 商户 ID,不存在返回空字符串 + */ +const getMerchantId = () => { + const params = new URLSearchParams(window.location.search || ''); + const value = params.get('merchantId') || params.get('merchantid') || params.get('merchant_id'); + return value && String(value).trim() ? String(value).trim() : ''; +}; + +/** + * 构建用户相关的查询字符串 + * @returns {string} 查询字符串,如 '?userId=1&merchantId=2' + */ +const buildUserQueryString = () => { + const userId = getUserId(); + const merchantId = getMerchantId(); + const parts = []; + if (userId) parts.push('userId=' + encodeURIComponent(userId)); + if (merchantId) parts.push('merchantId=' + encodeURIComponent(merchantId)); + return parts.length ? ('?' + parts.join('&')) : ''; +}; + +/** + * 解析 API 响应数据 + * @param {Object} data - 响应数据对象 + * @returns {Array} 解析后的数组数据 + */ +const parseArrayResponse = (data) => { + if (Array.isArray(data?.data)) return data.data; + if (Array.isArray(data)) return data; + return []; +}; + +/** + * 解析分页响应数据 + * @param {Object} data - 响应数据对象 + * @returns {{items: Array, total: number, page: number}} 分页数据 + */ +const parsePaginatedResponse = (data) => { + const payload = data?.data || data || {}; + return { + items: Array.isArray(payload.items) ? payload.items : (Array.isArray(payload) ? payload : []), + total: Number(payload.total || 0), + page: Number(payload.page || 1) + }; +}; + +/** + * 通用 GET 请求 + * @param {string} endpoint - API 端点 + * @param {Object} [options] - 请求选项 + * @param {boolean} [options.withUserQuery=false] - 是否附加用户查询参数 + * @param {Object} [options.params] - URL 查询参数 + * @returns {Promise} 响应数据 + */ +const get = async (endpoint, options = {}) => { + const { withUserQuery = false, params = {} } = options; + + let url = API_BASE + endpoint; + const queryParams = new URLSearchParams(params); + + if (withUserQuery) { + const userId = getUserId(); + const merchantId = getMerchantId(); + if (userId) queryParams.set('userId', userId); + if (merchantId) queryParams.set('merchantId', merchantId); + } + + const queryString = queryParams.toString(); + if (queryString) { + url += (endpoint.includes('?') ? '&' : '?') + queryString; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`请求失败: ${response.status} ${response.statusText}`); + } + return response.json(); +}; + +/** + * 通用 POST 请求 + * @param {string} endpoint - API 端点 + * @param {Object} body - 请求体 + * @param {Object} [options] - 请求选项 + * @param {boolean} [options.withUserQuery=false] - 是否附加用户查询参数 + * @returns {Promise} 响应数据 + */ +const post = async (endpoint, body, options = {}) => { + const { withUserQuery = false } = options; + + let url = API_BASE + endpoint; + if (withUserQuery) { + url += buildUserQueryString(); + } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `请求失败: ${response.status}`); + } + return response.json(); +}; + +/** + * 通用 PATCH 请求 + * @param {string} endpoint - API 端点 + * @param {Object} body - 请求体 + * @returns {Promise} 响应数据 + */ +const patch = async (endpoint, body) => { + const url = API_BASE + endpoint; + const response = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `请求失败: ${response.status}`); + } + return response.json(); +}; + +/** + * 通用 DELETE 请求 + * @param {string} endpoint - API 端点 + * @param {Object} [options] - 请求选项 + * @param {boolean} [options.soft=false] - 是否软删除 + * @returns {Promise} 响应数据 + */ +const del = async (endpoint, options = {}) => { + const { soft = false } = options; + let url = API_BASE + endpoint; + if (soft) { + url += (endpoint.includes('?') ? '&' : '?') + 'soft=1'; + } + + const response = await fetch(url, { method: 'DELETE' }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `请求失败: ${response.status}`); + } + return response.json(); +}; + +// ==================== 模板相关 API ==================== + +/** + * 加载模板列表 + * @returns {Promise} 模板数组 + */ +const fetchTemplates = async () => { + const data = await get('/api/templates', { withUserQuery: true }); + return parseArrayResponse(data); +}; + +/** + * 获取模板详情 + * @param {number|string} id - 模板 ID + * @returns {Promise} 模板详情 + */ +const fetchTemplateDetail = async (id) => { + const data = await get(`/api/templates/${id}`); + return data?.data || {}; +}; + +/** + * 创建模板 + * @param {Object} payload - 模板数据 + * @returns {Promise} 创建结果 + */ +const createTemplate = async (payload) => { + return post('/api/templates', payload, { withUserQuery: true }); +}; + +/** + * 更新模板 + * @param {number|string} id - 模板 ID + * @param {Object} payload - 更新数据 + * @returns {Promise} 更新结果 + */ +const updateTemplate = async (id, payload) => { + return patch(`/api/templates/${id}`, payload); +}; + +/** + * 删除模板(软删除) + * @param {number|string} id - 模板 ID + * @returns {Promise} 删除结果 + */ +const deleteTemplate = async (id) => { + return del(`/api/templates/${id}`, { soft: true }); +}; + +// ==================== 导出任务相关 API ==================== + +/** + * 加载导出任务列表 + * @param {Object} params - 查询参数 + * @param {number} params.page - 页码 + * @param {number} params.pageSize - 每页数量 + * @param {number} [params.templateId] - 模板 ID + * @returns {Promise<{items: Array, total: number, page: number}>} 分页数据 + */ +const fetchJobs = async ({ page, pageSize, templateId }) => { + const params = { + page: String(page), + page_size: String(pageSize) + }; + if (templateId) { + params.template_id = String(templateId); + } + const data = await get('/api/exports', { params, withUserQuery: true }); + return parsePaginatedResponse(data); +}; + +/** + * 获取任务详情 + * @param {number|string} id - 任务 ID + * @returns {Promise} 任务详情 + */ +const fetchJobDetail = async (id) => { + const data = await get(`/api/exports/${id}`, { withUserQuery: true }); + return data?.data || {}; +}; + +/** + * 获取任务 SQL + * @param {number|string} id - 任务 ID + * @returns {Promise} SQL 语句 + */ +const fetchJobSql = async (id) => { + const data = await get(`/api/exports/${id}/sql`); + return data?.data?.final_sql || data?.final_sql || data?.data?.sql || data?.sql || ''; +}; + +/** + * 创建导出任务 + * @param {Object} payload - 任务数据 + * @returns {Promise} 创建结果 + */ +const createExportJob = async (payload) => { + return post('/api/exports', payload, { withUserQuery: true }); +}; + +/** + * 获取下载地址 + * @param {number|string} id - 任务 ID + * @returns {string} 下载 URL + */ +const getDownloadUrl = (id) => { + return `${API_BASE}/api/exports/${id}/download`; +}; + +// ==================== 元数据相关 API ==================== + +/** + * 加载字段元数据 + * @param {string} datasource - 数据源 + * @param {number} orderType - 订单类型 + * @returns {Promise} 元数据对象 { tables, recommended } + */ +const fetchFieldsMetadata = async (datasource, orderType) => { + const params = { + datasource: datasource, + order_type: String(orderType || 0) + }; + const data = await get('/api/metadata/fields', { params }); + + const tables = Array.isArray(data?.data?.tables) + ? data.data.tables + : (Array.isArray(data?.tables) ? data.tables : []); + + const recommended = Array.isArray(data?.data?.recommended) + ? data.data.recommended + : (Array.isArray(data?.recommended) ? data.recommended : []); + + return { tables, recommended }; +}; + +// ==================== 创建者/分销商等选项 API ==================== + +/** + * 加载创建者列表(营销系统) + * @returns {Promise>} 选项数组 + */ +const fetchCreators = async () => { + const data = await get('/api/creators'); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: it.name || String(it.id), value: Number(it.id) })); +}; + +/** + * 加载易码通用户列表 + * @param {string} [query] - 搜索关键词 + * @returns {Promise>} 选项数组 + */ +const fetchYmtUsers = async (query) => { + const params = { limit: '2000' }; + if (query) params.q = query; + const data = await get('/api/ymt/users', { params }); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: it.name || String(it.id), value: Number(it.id) })); +}; + +/** + * 加载分销商列表 + * @param {number[]} creatorIds - 创建者 ID 数组 + * @returns {Promise>} 选项数组 + */ +const fetchResellers = async (creatorIds) => { + if (!creatorIds?.length) return []; + const data = await get('/api/resellers', { params: { creator: creatorIds.join(',') } }); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: it.name || String(it.id), value: Number(it.id) })); +}; + +/** + * 加载计划列表 + * @param {number} resellerId - 分销商 ID + * @returns {Promise>} 选项数组 + */ +const fetchPlans = async (resellerId) => { + if (!resellerId) return []; + const data = await get('/api/plans', { params: { reseller: String(resellerId) } }); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: `${it.id} - ${it.title || ''}`, value: Number(it.id) })); +}; + +/** + * 加载易码通客户列表 + * @param {number} userId - 用户 ID + * @returns {Promise>} 选项数组 + */ +const fetchYmtMerchants = async (userId) => { + if (!userId) return []; + const data = await get('/api/ymt/merchants', { params: { user_id: String(userId), limit: '2000' } }); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: `${it.id} - ${it.name || ''}`, value: Number(it.id) })); +}; + +/** + * 加载易码通活动列表 + * @param {number} merchantId - 客户 ID + * @returns {Promise>} 选项数组 + */ +const fetchYmtActivities = async (merchantId) => { + if (!merchantId) return []; + const data = await get('/api/ymt/activities', { params: { merchant_id: String(merchantId), limit: '2000' } }); + const arr = parseArrayResponse(data); + return arr.map(it => ({ label: `${it.id} - ${it.name || ''}`, value: Number(it.id) })); +}; + +// 导出模块 +window.ApiService = { + // 基础方法 + API_BASE, + getUserId, + getMerchantId, + buildUserQueryString, + get, + post, + patch, + del, + + // 模板 + fetchTemplates, + fetchTemplateDetail, + createTemplate, + updateTemplate, + deleteTemplate, + + // 任务 + fetchJobs, + fetchJobDetail, + fetchJobSql, + createExportJob, + getDownloadUrl, + + // 元数据 + fetchFieldsMetadata, + + // 选项数据 + fetchCreators, + fetchYmtUsers, + fetchResellers, + fetchPlans, + fetchYmtMerchants, + fetchYmtActivities +}; diff --git a/web/modules/config.js b/web/modules/config.js new file mode 100644 index 0000000..10340be --- /dev/null +++ b/web/modules/config.js @@ -0,0 +1,321 @@ +/** + * 配置模块 - 集中管理常量、映射和配置 + * @module config + */ + +// ==================== 系统常量 ==================== + +/** + * 系统常量配置 + */ +const CONSTANTS = { + // 轮询间隔(毫秒) + JOBS_POLL_INTERVAL: 1000, + // 树形选择器渲染延迟(毫秒) + TREE_RENDER_DELAY: 100, + // 编辑模式树渲染延迟(毫秒) + TREE_EDIT_RENDER_DELAY: 300, + // 对话框最小宽度 + DIALOG_MIN_WIDTH: 500, + // 对话框最大宽度 + DIALOG_MAX_WIDTH: 1400, + // 默认对话框宽度 + DIALOG_DEFAULT_WIDTH: 900, + // 默认编辑对话框宽度 + DIALOG_EDIT_DEFAULT_WIDTH: 900 +}; + +// ==================== 数据源配置 ==================== + +/** + * 数据源定义 + * @type {Object} + */ +const DATASOURCE_CONFIG = { + marketing: { + label: '营销系统', + mainTable: 'order', + orderTypes: [ + { label: '直充卡密', value: 1 }, + { label: '立减金', value: 2 }, + { label: '红包', value: 3 } + ], + defaultOrderType: 1 + }, + ymt: { + label: '易码通', + mainTable: 'order_info', + orderTypes: [ + { label: '直充卡密', value: 2 }, + { label: '立减金', value: 3 }, + { label: '红包', value: 1 } + ], + defaultOrderType: 2 + } +}; + +/** + * 获取数据源选项列表 + * @returns {Array<{label: string, value: string}>} + */ +const getDatasourceOptions = () => { + return Object.entries(DATASOURCE_CONFIG).map(([value, config]) => ({ + label: config.label, + value + })); +}; + +/** + * 获取数据源标签 + * @param {string} datasource - 数据源标识 + * @returns {string} 数据源标签 + */ +const getDatasourceLabel = (datasource) => { + return DATASOURCE_CONFIG[datasource]?.label || datasource || ''; +}; + +/** + * 获取数据源的主表名 + * @param {string} datasource - 数据源标识 + * @returns {string} 主表名 + */ +const getMainTable = (datasource) => { + return DATASOURCE_CONFIG[datasource]?.mainTable || 'order'; +}; + +/** + * 获取订单类型选项 + * @param {string} datasource - 数据源标识 + * @returns {Array<{label: string, value: number}>} 订单类型选项 + */ +const getOrderTypeOptions = (datasource) => { + return DATASOURCE_CONFIG[datasource]?.orderTypes || []; +}; + +/** + * 获取默认订单类型 + * @param {string} datasource - 数据源标识 + * @returns {number} 默认订单类型 + */ +const getDefaultOrderType = (datasource) => { + return DATASOURCE_CONFIG[datasource]?.defaultOrderType || 1; +}; + +/** + * 获取订单类型标签 + * @param {string} datasource - 数据源标识 + * @param {number} typeValue - 类型值 + * @returns {string} 类型标签 + */ +const getOrderTypeLabel = (datasource, typeValue) => { + const types = DATASOURCE_CONFIG[datasource]?.orderTypes || []; + const found = types.find(t => t.value === typeValue); + return found?.label || ''; +}; + +// ==================== 表标签映射 ==================== + +/** + * 默认表标签映射(当后端未返回时使用) + */ +const DEFAULT_TABLE_LABELS = { + order: '订单主表', + order_info: '订单主表', + order_detail: '订单详情', + order_cash: '红包订单', + order_voucher: '立减金订单', + order_digit: '直充卡密订单', + plan: '活动计划', + key_batch: 'key批次', + code_batch: '兑换码批次', + voucher: '立减金', + voucher_batch: '立减金批次', + merchant_key_send: '开放平台发放记录', + merchant: '客户', + activity: '活动', + goods_voucher_batch: '立减金批次表', + goods_voucher_subject_config: '立减金主体配置' +}; + +// ==================== 表关联配置 ==================== + +/** + * 营销系统表关联结构 + * 定义了从主表到各子表的路径关系 + */ +const MARKETING_TABLE_RELATIONS = { + order: { path: [] }, + order_detail: { path: ['order_detail'] }, + plan: { path: ['plan'] }, + key_batch: { path: ['plan', 'key_batch'] }, + code_batch: { path: ['plan', 'key_batch', 'code_batch'] }, + order_voucher: { path: ['order_voucher'] }, + voucher: { path: ['order_voucher', 'voucher'] }, + voucher_batch: { path: ['order_voucher', 'voucher', 'voucher_batch'] }, + order_cash: { path: ['order_cash'] }, + merchant_key_send: { path: ['merchant_key_send'] } +}; + +/** + * 易码通表关联结构 + */ +const YMT_TABLE_RELATIONS = { + order: { path: [] }, + order_info: { path: [] }, + merchant: { path: ['merchant'] }, + activity: { path: ['activity'] }, + order_digit: { path: ['order_digit'] }, + order_voucher: { path: ['order_voucher'] }, + order_cash: { path: ['order_cash'] }, + goods_voucher_batch: { path: ['goods_voucher_batch'] }, + goods_voucher_subject_config: { path: ['goods_voucher_subject_config'] } +}; + +/** + * 获取表关联配置 + * @param {string} datasource - 数据源标识 + * @returns {Object} 表关联配置 + */ +const getTableRelations = (datasource) => { + return datasource === 'ymt' ? YMT_TABLE_RELATIONS : MARKETING_TABLE_RELATIONS; +}; + +// ==================== 默认字段配置 ==================== + +/** + * 各数据源的默认字段列表 + */ +const DEFAULT_FIELDS = { + marketing: [ + 'order.order_number', + 'order.creator', + 'order.out_trade_no', + 'order.type', + 'order.status', + 'order.contract_price', + 'order.num', + 'order.total', + 'order.pay_amount', + 'order.create_time' + ], + ymt: [ + 'order_info.order_number', + 'order_info.creator', + 'order_info.out_trade_no', + 'order_info.type', + 'order_info.status', + 'order_info.contract_price', + 'order_info.num', + 'order_info.pay_amount', + 'order_info.create_time' + ] +}; + +/** + * 获取默认字段列表 + * @param {string} datasource - 数据源标识 + * @returns {string[]} 默认字段列表 + */ +const getDefaultFields = (datasource) => { + return DEFAULT_FIELDS[datasource] || DEFAULT_FIELDS.marketing; +}; + +// ==================== 表单选项配置 ==================== + +/** + * 可见性选项 + */ +const VISIBILITY_OPTIONS = [ + { label: '个人', value: 'private' }, + { label: '公共', value: 'public' } +]; + +/** + * 文件格式选项 + */ +const FORMAT_OPTIONS = [ + { label: 'XLSX', value: 'xlsx' }, + { label: 'CSV', value: 'csv' } +]; + +/** + * 场景选项(根据数据源动态生成) + * @param {string} datasource - 数据源标识 + * @returns {Array<{label: string, value: string}>} + */ +const getSceneOptions = (datasource) => { + const mainTable = getMainTable(datasource); + return [{ label: '订单数据', value: mainTable }]; +}; + +/** + * 获取场景标签 + * @param {string} scene - 场景值 + * @returns {string} 场景标签 + */ +const getSceneLabel = (scene) => { + if (scene === 'order' || scene === 'order_info') return '订单'; + return scene || ''; +}; + +// ==================== 订单类型与子表映射 ==================== + +/** + * 营销系统订单类型对应的子表 + */ +const MARKETING_TYPE_TABLES = { + 1: ['plan', 'key_batch', 'code_batch', 'merchant_key_send'], // 直充卡密 + 2: ['order_voucher', 'voucher', 'voucher_batch', 'plan', 'key_batch', 'code_batch'], // 立减金 + 3: ['order_cash', 'plan', 'key_batch', 'code_batch'] // 红包 +}; + +/** + * 易码通订单类型对应的子表 + */ +const YMT_TYPE_TABLES = { + 1: ['order_cash'], // 红包 + 2: ['order_digit'], // 直充卡密 + 3: ['order_voucher', 'goods_voucher_batch', 'goods_voucher_subject_config'] // 立减金 +}; + +/** + * 获取订单类型对应的可用子表 + * @param {string} datasource - 数据源标识 + * @param {number} orderType - 订单类型 + * @returns {string[]} 可用子表列表 + */ +const getAvailableSubTables = (datasource, orderType) => { + if (datasource === 'ymt') { + return YMT_TYPE_TABLES[orderType] || Object.values(YMT_TYPE_TABLES).flat(); + } + return MARKETING_TYPE_TABLES[orderType] || Object.values(MARKETING_TYPE_TABLES).flat(); +}; + +// 导出模块 +window.AppConfig = { + CONSTANTS, + DATASOURCE_CONFIG, + DEFAULT_TABLE_LABELS, + VISIBILITY_OPTIONS, + FORMAT_OPTIONS, + DEFAULT_FIELDS, + + // 数据源相关 + getDatasourceOptions, + getDatasourceLabel, + getMainTable, + getOrderTypeOptions, + getDefaultOrderType, + getOrderTypeLabel, + + // 表相关 + getTableRelations, + getAvailableSubTables, + + // 场景相关 + getSceneOptions, + getSceneLabel, + + // 默认值 + getDefaultFields +}; diff --git a/web/modules/fields.js b/web/modules/fields.js new file mode 100644 index 0000000..ec7a18b --- /dev/null +++ b/web/modules/fields.js @@ -0,0 +1,462 @@ +/** + * 字段处理模块 - 字段路径转换、树结构构建 + * @module fields + */ + +// 依赖:AppConfig + +/** + * 字段管理器类 + * 管理字段元数据、树结构构建和路径转换 + */ +class FieldsManager { + constructor() { + // 字段映射:{ tableName: [{ value, label }] } + this.fieldsMap = {}; + // 表标签映射:{ tableName: label } + this.tableLabels = {}; + // 推荐字段列表 + this.recommendedFields = []; + } + + /** + * 更新字段元数据 + * @param {Array} tables - 表定义数组 + * @param {Array} recommended - 推荐字段数组 + */ + updateMetadata(tables, recommended = []) { + const map = {}; + const labels = {}; + + tables.forEach(table => { + const fields = Array.isArray(table.fields) ? table.fields : []; + const visibleFields = fields.filter(f => !f.hidden); + map[table.table] = visibleFields.map(f => ({ + value: f.field, + label: f.label + })); + if (table.label) { + labels[table.table] = table.label; + } + }); + + this.fieldsMap = map; + this.tableLabels = labels; + this.recommendedFields = recommended; + } + + /** + * 获取表标签 + * @param {string} table - 表名 + * @returns {string} 表标签 + */ + getTableLabel(table) { + return this.tableLabels[table] + || window.AppConfig?.DEFAULT_TABLE_LABELS[table] + || table; + } + + /** + * 获取指定表的字段列表 + * @param {string} table - 表名 + * @returns {Array<{value: string, label: string}>} 字段列表 + */ + getTableFields(table) { + return this.fieldsMap[table] || []; + } + + /** + * 检查表中是否存在某字段 + * @param {string} table - 表名 + * @param {string} field - 字段名 + * @returns {boolean} 是否存在 + */ + hasField(table, field) { + const fields = this.fieldsMap[table] || []; + return fields.some(f => f.value === field); + } + + /** + * 获取所有表名 + * @returns {string[]} 表名列表 + */ + getAllTables() { + return Object.keys(this.fieldsMap); + } + + /** + * 构建树节点 + * @param {string} table - 表名 + * @param {Array} children - 子节点 + * @returns {Object} 树节点 + */ + buildTreeNode(table, children = []) { + return { + value: table, + label: this.getTableLabel(table), + children + }; + } + + /** + * 构建易码通订单子节点 + * @param {number} orderType - 订单类型 + * @returns {Array} 子节点数组 + */ + buildYmtOrderChildren(orderType) { + const orderFields = this.getTableFields('order'); + const children = orderFields.map(f => ({ value: f.value, label: f.label })); + + // 添加公共子表 + children.push(this.buildTreeNode('merchant', this.getTableFields('merchant'))); + children.push(this.buildTreeNode('activity', this.getTableFields('activity'))); + + // 根据订单类型添加特定子表 + if (orderType === 2) { + // 直充卡密 + children.push(this.buildTreeNode('order_digit', this.getTableFields('order_digit'))); + } else if (orderType === 3) { + // 立减金 + children.push(this.buildTreeNode('order_voucher', this.getTableFields('order_voucher'))); + children.push(this.buildTreeNode('goods_voucher_batch', this.getTableFields('goods_voucher_batch'))); + children.push(this.buildTreeNode('goods_voucher_subject_config', this.getTableFields('goods_voucher_subject_config'))); + } else if (orderType === 1) { + // 红包 + children.push(this.buildTreeNode('order_cash', this.getTableFields('order_cash'))); + } else { + // 全部类型 + children.push(this.buildTreeNode('order_voucher', this.getTableFields('order_voucher'))); + children.push(this.buildTreeNode('order_cash', this.getTableFields('order_cash'))); + children.push(this.buildTreeNode('order_digit', this.getTableFields('order_digit'))); + children.push(this.buildTreeNode('goods_voucher_batch', this.getTableFields('goods_voucher_batch'))); + children.push(this.buildTreeNode('goods_voucher_subject_config', this.getTableFields('goods_voucher_subject_config'))); + } + + return children; + } + + /** + * 构建营销系统订单子节点 + * @param {number} orderType - 订单类型 + * @returns {Array} 子节点数组 + */ + buildMarketingOrderChildren(orderType) { + const orderFields = this.getTableFields('order'); + const children = orderFields.map(f => ({ value: f.value, label: f.label })); + + // 订单详情 + children.push(this.buildTreeNode('order_detail', this.getTableFields('order_detail'))); + + // 构建计划节点(包含 key_batch -> code_batch 嵌套) + const planChildren = [ + ...this.getTableFields('plan').map(f => ({ value: f.value, label: f.label })), + this.buildTreeNode('key_batch', [ + ...this.getTableFields('key_batch').map(f => ({ value: f.value, label: f.label })), + this.buildTreeNode('code_batch', this.getTableFields('code_batch')) + ]) + ]; + + // 构建立减金节点(包含 voucher -> voucher_batch 嵌套) + const voucherChildren = [ + ...this.getTableFields('order_voucher').map(f => ({ value: f.value, label: f.label })), + this.buildTreeNode('voucher', [ + ...this.getTableFields('voucher').map(f => ({ value: f.value, label: f.label })), + this.buildTreeNode('voucher_batch', this.getTableFields('voucher_batch')) + ]) + ]; + + // 根据订单类型添加特定子表 + if (orderType === 1) { + // 直充卡密 + children.push(this.buildTreeNode('plan', planChildren)); + children.push(this.buildTreeNode('merchant_key_send', this.getTableFields('merchant_key_send'))); + } else if (orderType === 2) { + // 立减金 + children.push(this.buildTreeNode('order_voucher', voucherChildren)); + children.push(this.buildTreeNode('plan', planChildren)); + } else if (orderType === 3) { + // 红包 + children.push(this.buildTreeNode('order_cash', this.getTableFields('order_cash'))); + children.push(this.buildTreeNode('plan', planChildren)); + } else { + // 全部类型 + children.push(this.buildTreeNode('order_cash', this.getTableFields('order_cash'))); + children.push(this.buildTreeNode('order_voucher', voucherChildren)); + children.push(this.buildTreeNode('plan', planChildren)); + children.push(this.buildTreeNode('merchant_key_send', this.getTableFields('merchant_key_send'))); + } + + return children; + } + + /** + * 构建字段选择树数据 + * @param {string} datasource - 数据源 + * @param {number} orderType - 订单类型 + * @param {string} [mainTable] - 主表名(可选,用于编辑模式) + * @returns {Array} 树数据 + */ + buildFieldTree(datasource, orderType, mainTable) { + const type = Number(orderType || 0); + + if (datasource === 'ymt') { + const actualMainTable = (mainTable === 'order_info') ? 'order' : (mainTable || 'order'); + const orderNode = this.buildTreeNode(actualMainTable, this.buildYmtOrderChildren(type)); + return type ? [orderNode] : [{ value: 'scene_order', label: '订单数据', children: [orderNode] }]; + } else { + const tableForTree = mainTable || 'order'; + const orderNode = this.buildTreeNode(tableForTree, this.buildMarketingOrderChildren(type)); + return type ? [orderNode] : [{ value: 'scene_order', label: '订单数据', children: [orderNode] }]; + } + } + + /** + * 将 table.field 格式转换为路径数组格式 + * @param {string[]} fields - 字段数组,格式为 ['table.field', ...] + * @param {string} datasource - 数据源 + * @param {string} mainTable - 主表名 + * @returns {Array} 路径数组,格式为 [['table', 'field'], ...] + */ + convertFieldsToPaths(fields, datasource, mainTable) { + const actualMainTable = (datasource === 'ymt' && mainTable === 'order_info') ? 'order' : mainTable; + const relations = window.AppConfig?.getTableRelations(datasource) || {}; + + const toPath = (tableField) => { + const parts = String(tableField || '').split('.'); + if (parts.length !== 2) return null; + + let table = parts[0]; + const field = parts[1]; + + // ymt 数据源 order_info 映射为 order + if (datasource === 'ymt' && table === 'order_info') { + table = 'order'; + } + + // 查找表的路径关系 + const relation = relations[table]; + if (!relation) { + // 未知表,尝试直接返回 + return [actualMainTable, table, field]; + } + + const pathToTable = relation.path || []; + if (pathToTable.length === 0) { + // 主表字段 + return [actualMainTable, field]; + } else { + // 子表字段 + return [actualMainTable, ...pathToTable, field]; + } + }; + + return fields + .map(toPath) + .filter(path => Array.isArray(path) && path.length >= 2) + .filter(path => { + // 验证字段存在 + const table = path[path.length - 2]; + const field = path[path.length - 1]; + return this.hasField(table, field); + }); + } + + /** + * 将路径数组格式转换为 table.field 格式 + * @param {Array} paths - 路径数组 + * @param {string} datasource - 数据源 + * @param {string} mainTable - 主表名 + * @returns {string[]} 字段数组,格式为 ['table.field', ...] + */ + convertPathsToFields(paths, datasource, mainTable) { + const actualMainTable = (datasource === 'ymt' && mainTable === 'order_info') ? 'order' : mainTable; + const allTables = this.getAllTables(); + + // 检查是否只选中了主表节点(表示选择整表) + const hasMainTableOnly = paths.some( + p => Array.isArray(p) && p.length === 1 && p[0] === actualMainTable + ); + + if (hasMainTableOnly) { + // 返回主表所有字段 + return this.getTableFields(actualMainTable).map(f => `${mainTable}.${f.value}`); + } + + return paths.flatMap(path => { + if (!Array.isArray(path)) return []; + + // 检查是否是组节点(表节点而非字段节点) + const lastPart = path[path.length - 1]; + if (allTables.includes(lastPart)) { + // 是表节点,跳过 + return []; + } + + if (path.length >= 2) { + const table = path[path.length - 2]; + const field = path[path.length - 1]; + // ymt 数据源需要将 order 转回 order_info + const finalTable = (datasource === 'ymt' && table === 'order') ? 'order_info' : table; + return [`${finalTable}.${field}`]; + } + return []; + }); + } + + /** + * 路径数组去重 + * @param {Array} paths - 路径数组 + * @returns {Array} 去重后的路径数组 + */ + deduplicatePaths(paths = []) { + const seen = new Set(); + const result = []; + + for (const path of paths) { + if (!Array.isArray(path)) continue; + const key = path.join('|'); + if (seen.has(key)) continue; + seen.add(key); + result.push(path); + } + + return result; + } + + /** + * 获取主表所有叶子字段的路径 + * @param {string} datasource - 数据源 + * @returns {Array} 路径数组 + */ + getMainTableLeafPaths(datasource) { + const mainTable = datasource === 'ymt' ? 'order' : 'order'; + return this.getTableFields(mainTable).map(f => [mainTable, f.value]); + } +} + +/** + * 树节点工具函数 + */ +const TreeUtils = { + /** + * 在树中查找节点 + * @param {Array} nodes - 树节点数组 + * @param {string} targetKey - 目标节点 key + * @returns {Object|null} 找到的节点 + */ + findNode(nodes, targetKey) { + for (const node of nodes) { + if (node.value === targetKey) { + return node; + } + if (node.children) { + const found = TreeUtils.findNode(node.children, targetKey); + if (found) return found; + } + } + return null; + }, + + /** + * 查找节点的完整路径 + * @param {Array} nodes - 树节点数组 + * @param {string} targetKey - 目标节点 key + * @param {string[]} currentPath - 当前路径 + * @returns {string[]|null} 节点路径 + */ + findNodePath(nodes, targetKey, currentPath = []) { + for (const node of nodes) { + const newPath = [...currentPath, node.value]; + if (node.value === targetKey) { + return newPath; + } + if (node.children && node.children.length > 0) { + const found = TreeUtils.findNodePath(node.children, targetKey, newPath); + if (found) return found; + } + } + return null; + }, + + /** + * 根据路径数组查找节点 value + * @param {Array} nodes - 树节点数组 + * @param {string[]} path - 路径数组 + * @param {number} index - 当前索引 + * @returns {string|null} 节点 value + */ + findNodeByPath(nodes, path, index = 0) { + if (!nodes || index >= path.length) return null; + + const target = path[index]; + for (const node of nodes) { + if (node.value === target) { + if (index === path.length - 1) { + return node.value; + } else if (node.children && node.children.length > 0) { + const found = TreeUtils.findNodeByPath(node.children, path, index + 1); + if (found) return found; + } + } + } + return null; + }, + + /** + * 判断节点是否为叶子节点 + * @param {Object} node - 节点对象 + * @returns {boolean} 是否为叶子节点 + */ + isLeafNode(node) { + return !node.children || node.children.length === 0; + }, + + /** + * 将选中的 keys 转换为路径数组 + * @param {string[]} checkedKeys - 选中的节点 keys + * @param {Array} treeData - 树数据 + * @returns {Array} 路径数组(仅叶子节点) + */ + checkedKeysToLeafPaths(checkedKeys, treeData) { + return checkedKeys + .filter(key => { + const node = TreeUtils.findNode(treeData, key); + return node && TreeUtils.isLeafNode(node); + }) + .map(key => { + const path = TreeUtils.findNodePath(treeData, key); + return path || [key]; + }) + .filter(path => path.length >= 2); + }, + + /** + * 将路径数组转换为节点 keys + * @param {Array} paths - 路径数组 + * @param {Array} treeData - 树数据 + * @returns {string[]} 节点 keys + */ + pathsToNodeKeys(paths, treeData) { + return paths.map(path => { + if (Array.isArray(path)) { + const nodeValue = TreeUtils.findNodeByPath(treeData, path); + if (nodeValue) return nodeValue; + // 找不到时使用路径最后一部分 + return path[path.length - 1]; + } else if (typeof path === 'string') { + return path; + } + return null; + }).filter(Boolean); + } +}; + +// 创建全局实例 +const fieldsManager = new FieldsManager(); + +// 导出模块 +window.FieldsModule = { + FieldsManager, + TreeUtils, + fieldsManager +}; diff --git a/web/modules/state.js b/web/modules/state.js new file mode 100644 index 0000000..c193bb4 --- /dev/null +++ b/web/modules/state.js @@ -0,0 +1,263 @@ +/** + * 状态管理模块 - 拆分状态对象,提供初始化函数 + * @module state + */ + +/** + * 创建模板表单初始状态 + * @param {string} [datasource='marketing'] - 数据源 + * @returns {Object} 模板表单状态 + */ +const createTemplateFormState = (datasource = 'marketing') => ({ + name: '', + datasource: datasource, + main_table: datasource === 'ymt' ? 'order_info' : 'order', + orderType: datasource === 'ymt' ? 2 : 1, + fieldsRaw: '', + fieldsSel: [], + file_format: 'xlsx', + visibility: 'private' +}); + +/** + * 创建编辑表单初始状态 + * @returns {Object} 编辑表单状态 + */ +const createEditFormState = () => ({ + id: null, + name: '', + datasource: 'marketing', + main_table: 'order', + orderType: 1, + fieldsSel: [], + visibility: 'private', + file_format: 'xlsx' +}); + +/** + * 创建导出表单初始状态 + * @returns {Object} 导出表单状态 + */ +const createExportFormState = () => ({ + tplId: null, + datasource: 'marketing', + file_format: 'xlsx', + dateRange: [], + // 营销系统筛选条件 + creatorIds: [], + creatorIdsRaw: '', + resellerId: null, + planId: null, + keyBatchId: null, + codeBatchId: null, + voucherChannelActivityId: '', + // 易码通筛选条件 + ymtCreatorId: '', + ymtMerchantId: '', + ymtActivityId: '' +}); + +/** + * 创建导出模板状态 + * @returns {Object} 导出模板状态 + */ +const createExportTemplateState = () => ({ + id: null, + filters: {}, + main_table: '', + fields: [], + datasource: '', + file_format: '' +}); + +/** + * 创建任务列表状态 + * @returns {Object} 任务列表状态 + */ +const createJobsState = () => ({ + jobs: [], + jobsVisible: false, + jobsTplId: null, + jobsPage: 1, + jobsPageSize: 10, + jobsTotal: 0 +}); + +/** + * 创建SQL预览状态 + * @returns {Object} SQL预览状态 + */ +const createSqlPreviewState = () => ({ + sqlVisible: false, + sqlText: '', + sqlExplainDesc: '' +}); + +/** + * 创建对话框状态 + * @returns {Object} 对话框状态 + */ +const createDialogState = () => { + const { CONSTANTS } = window.AppConfig || {}; + const defaultWidth = CONSTANTS?.DIALOG_DEFAULT_WIDTH || 900; + const editDefaultWidth = CONSTANTS?.DIALOG_EDIT_DEFAULT_WIDTH || 900; + + return { + createVisible: false, + editVisible: false, + exportVisible: false, + exportSubmitting: false, + createWidth: localStorage.getItem('tplDialogWidth') || (defaultWidth + 'px'), + editWidth: localStorage.getItem('tplEditDialogWidth') || (editDefaultWidth + 'px') + }; +}; + +/** + * 创建完整的应用状态 + * @returns {Object} 完整应用状态 + */ +const createAppState = () => { + return { + // 模板列表 + templates: [], + + // 当前任务详情 + job: {}, + + // 模板表单 + form: createTemplateFormState(), + + // 编辑表单 + edit: createEditFormState(), + + // 导出表单 + exportForm: createExportFormState(), + + // 导出模板详情 + exportTpl: createExportTemplateState(), + + // 任务列表 + ...createJobsState(), + + // SQL 预览 + ...createSqlPreviewState(), + + // 对话框 + ...createDialogState() + }; +}; + +/** + * 重置模板表单 + * @param {Object} form - 表单对象 + * @param {string} [datasource='marketing'] - 数据源 + */ +const resetTemplateForm = (form, datasource = 'marketing') => { + const initial = createTemplateFormState(datasource); + Object.assign(form, initial); +}; + +/** + * 重置编辑表单 + * @param {Object} edit - 编辑表单对象 + */ +const resetEditForm = (edit) => { + const initial = createEditFormState(); + Object.assign(edit, initial); +}; + +/** + * 重置导出表单 + * @param {Object} exportForm - 导出表单对象 + */ +const resetExportForm = (exportForm) => { + const initial = createExportFormState(); + Object.assign(exportForm, initial); +}; + +/** + * 表单验证规则工厂 + */ +const ValidationRules = { + /** + * 创建模板表单验证规则 + * @returns {Object} 验证规则 + */ + createTemplateRules() { + return { + name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }], + datasource: [{ required: true, message: '请选择数据源', trigger: 'change' }], + main_table: [{ required: true, message: '请选择导出场景', trigger: 'change' }], + orderType: [{ required: true, message: '请选择订单类型', trigger: 'change' }], + fieldsSel: [{ + validator: (_rule, val, cb) => { + if (Array.isArray(val) && val.length > 0) { + cb(); + } else { + cb(new Error('请至少选择一个字段')); + } + }, + trigger: 'change' + }], + file_format: [{ required: true, message: '请选择输出格式', trigger: 'change' }], + visibility: [{ required: true, message: '请选择可见性', trigger: 'change' }] + }; + }, + + /** + * 创建编辑表单验证规则 + * @returns {Object} 验证规则 + */ + createEditRules() { + return { + name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }], + orderType: [{ required: true, message: '请选择订单类型', trigger: 'change' }], + fieldsSel: [{ + validator: (_rule, val, cb) => { + if (Array.isArray(val) && val.length > 0) { + cb(); + } else { + cb(new Error('请至少选择一个字段')); + } + }, + trigger: 'change' + }] + }; + }, + + /** + * 创建导出表单验证规则 + * @returns {Object} 验证规则 + */ + createExportRules() { + return { + tplId: [{ required: true, message: '请选择模板', trigger: 'change' }], + dateRange: [{ + validator: (_rule, val, cb) => { + if (Array.isArray(val) && val.length === 2) { + cb(); + } else { + cb(new Error('请选择时间范围')); + } + }, + trigger: 'change' + }] + }; + } +}; + +// 导出模块 +window.StateModule = { + createTemplateFormState, + createEditFormState, + createExportFormState, + createExportTemplateState, + createJobsState, + createSqlPreviewState, + createDialogState, + createAppState, + resetTemplateForm, + resetEditForm, + resetExportForm, + ValidationRules +}; diff --git a/web/modules/utils.js b/web/modules/utils.js new file mode 100644 index 0000000..f0a83ba --- /dev/null +++ b/web/modules/utils.js @@ -0,0 +1,187 @@ +/** + * 工具函数模块 - 通用工具函数 + * @module utils + */ + +/** + * 显示消息提示 + * @param {string} text - 消息内容 + * @param {'success'|'error'|'warning'|'info'} [type='success'] - 消息类型 + */ +const showMessage = (text, type = 'success') => { + if (window.ElementPlus?.ElMessage) { + window.ElementPlus.ElMessage({ message: text, type }); + } else { + console.log(`[${type}] ${text}`); + } +}; + +/** + * 格式化日期时间 + * @param {Date} date - 日期对象 + * @returns {string} 格式化后的日期字符串 YYYY-MM-DD HH:mm:ss + */ +const formatDateTime = (date) => { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +}; + +/** + * 获取月份范围 + * @param {number} [offsetMonths=-1] - 月份偏移量(负数表示往前推) + * @returns {[string, string]} [开始时间, 结束时间] + */ +const getMonthRange = (offsetMonths = -1) => { + const now = new Date(); + const base = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); + const start = new Date(base.getFullYear(), base.getMonth() + offsetMonths, 1, 0, 0, 0); + const end = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59); + return [formatDateTime(start), formatDateTime(end)]; +}; + +/** + * 计算对话框宽度(限制在最小和最大值之间) + * @param {number} width - 目标宽度 + * @param {number} [minWidth=500] - 最小宽度 + * @param {number} [maxWidth=1400] - 最大宽度 + * @returns {string} 宽度字符串,如 '900px' + */ +const clampDialogWidth = (width, minWidth = 500, maxWidth = 1400) => { + const clamped = Math.max(minWidth, Math.min(maxWidth, width)); + return clamped + 'px'; +}; + +/** + * 从 localStorage 获取对话框宽度 + * @param {string} key - 存储键名 + * @param {number} defaultWidth - 默认宽度 + * @returns {string} 宽度字符串 + */ +const getStoredDialogWidth = (key, defaultWidth) => { + const stored = localStorage.getItem(key); + return stored || (defaultWidth + 'px'); +}; + +/** + * 保存对话框宽度到 localStorage + * @param {string} key - 存储键名 + * @param {string} width - 宽度字符串 + */ +const saveDialogWidth = (key, width) => { + localStorage.setItem(key, width); +}; + +/** + * 计算任务进度百分比 + * @param {Object} job - 任务对象 + * @returns {string} 进度描述 + */ +const calculateJobProgress = (job) => { + const estimate = Number(job.row_estimate || 0); + const done = Number(job.total_rows || 0); + + if (job.status === 'completed') return '100%'; + if (job.status === 'failed') return '失败'; + if (job.status === 'canceled') return '已取消'; + if (job.status === 'queued') return '0%'; + + if (job.status === 'running') { + const effectiveEstimate = estimate > 0 ? estimate : (done > 0 ? done * 2 : 0); + if (effectiveEstimate > 0) { + const percent = Math.max(0, Math.min(100, Math.floor(done * 100 / effectiveEstimate))); + return percent + '%'; + } + if (done > 0) return `已写${done.toLocaleString()}`; + return '评估中'; + } + + const effectiveEstimate = estimate > 0 ? estimate : (done > 0 ? done * 2 : 0); + if (effectiveEstimate > 0) { + const percent = Math.max(0, Math.min(100, Math.floor(done * 100 / effectiveEstimate))); + return percent + '%'; + } + return '评估中'; +}; + +/** + * 解析宽度字符串为数字 + * @param {string} widthStr - 宽度字符串,如 '900px' + * @param {number} defaultValue - 默认值 + * @returns {number} 宽度数值 + */ +const parseWidth = (widthStr, defaultValue) => { + return parseInt(String(widthStr).replace('px', '') || String(defaultValue), 10); +}; + +/** + * 深度克隆对象 + * @param {Object} obj - 要克隆的对象 + * @returns {Object} 克隆后的对象 + */ +const deepClone = (obj) => { + return JSON.parse(JSON.stringify(obj)); +}; + +/** + * 安全地获取嵌套属性 + * @param {Object} obj - 对象 + * @param {string} path - 属性路径,如 'a.b.c' + * @param {*} defaultValue - 默认值 + * @returns {*} 属性值或默认值 + */ +const getNestedValue = (obj, path, defaultValue = undefined) => { + const keys = path.split('.'); + let result = obj; + for (const key of keys) { + if (result == null) return defaultValue; + result = result[key]; + } + return result !== undefined ? result : defaultValue; +}; + +/** + * 防抖函数 + * @param {Function} fn - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} 防抖后的函数 + */ +const debounce = (fn, delay) => { + let timer = null; + return function (...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +}; + +/** + * 节流函数 + * @param {Function} fn - 要节流的函数 + * @param {number} limit - 限制时间(毫秒) + * @returns {Function} 节流后的函数 + */ +const throttle = (fn, limit) => { + let inThrottle = false; + return function (...args) { + if (!inThrottle) { + fn.apply(this, args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; +}; + +// 导出模块 +window.AppUtils = { + showMessage, + formatDateTime, + getMonthRange, + clampDialogWidth, + getStoredDialogWidth, + saveDialogWidth, + calculateJobProgress, + parseWidth, + deepClone, + getNestedValue, + debounce, + throttle +};