const { createApp, reactive } = Vue; const app = createApp({ setup() { // ==================== 配置和工具函数 ==================== const API_BASE = (window.__API_BASE__ && String(window.__API_BASE__).trim()) ? String(window.__API_BASE__).trim() : (typeof location !== 'undefined' ? location.origin : 'http://localhost:8077'); const getUserId = () => { const sp = new URLSearchParams(window.location.search || ''); const v = sp.get('userId') || sp.get('userid') || sp.get('user_id'); return v && String(v).trim() ? String(v).trim() : ''; }; const getMerchantId = () => { const sp = new URLSearchParams(window.location.search || ''); const v = sp.get('merchantId') || sp.get('merchantid') || sp.get('merchant_id'); return v && String(v).trim() ? String(v).trim() : ''; }; const qsUser = () => { const uid = getUserId(); const mid = getMerchantId(); const parts = []; if (uid) parts.push('userId=' + encodeURIComponent(uid)); if (mid) parts.push('merchantId=' + encodeURIComponent(mid)); return parts.length ? ('?' + parts.join('&')) : ''; }; const msg = (text, type = 'success') => ElementPlus.ElMessage({ message: text, type }); const fmtDT = (d) => { const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const monthRange = (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 [fmtDT(start), fmtDT(end)]; }; // ==================== 状态管理 ==================== const state = reactive({ templates: [], jobs: [], jobsVisible: false, jobsTplId: null, jobsPage: 1, jobsPageSize: 10, jobsTotal: 0, sqlVisible: false, sqlText: '', sqlExplainDesc: '', job: {}, form: { name: '', datasource: 'marketing', main_table: 'order', orderType: 1, fieldsRaw: 'order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time', fieldsSel: [], file_format: 'xlsx', visibility: 'private' }, createVisible: false, editVisible: false, exportVisible: false, exportSubmitting: false, createWidth: localStorage.getItem('tplDialogWidth') || '900px', editWidth: localStorage.getItem('tplEditDialogWidth') || '900px', edit: { id: null, name: '', datasource: 'marketing', main_table: 'order', orderType: 1, fieldsSel: [], visibility: 'private', file_format: 'xlsx' }, exportForm: { tplId: null, datasource: 'marketing', file_format: 'xlsx', dateRange: [], creatorIds: [], creatorIdsRaw: '', resellerId: null, planId: null, keyBatchId: null, codeBatchId: null, voucherChannelActivityId: '', ymtCreatorId: '', ymtMerchantId: '', ymtActivityId: '' }, exportTpl: { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' } }); // ==================== 计算属性 ==================== const hasUserId = Vue.computed(() => !!getUserId()); const currentUserId = Vue.computed(() => { const v = getUserId(); return v ? Number(v) : null; }); // ==================== 字段元数据管理 ==================== const metaFM = Vue.ref({}); const metaTableLabels = Vue.ref({}); const recommendedMeta = Vue.ref([]); const loadFieldsMeta = async (ds, type) => { try { const res = await fetch(API_BASE + '/api/metadata/fields?datasource=' + encodeURIComponent(ds) + '&order_type=' + encodeURIComponent(String(type || 0))); const data = await res.json(); const tables = Array.isArray(data?.data?.tables) ? data.data.tables : (Array.isArray(data?.tables) ? data.tables : []); const m = {}; const tblLabels = {}; tables.forEach(t => { const arr = Array.isArray(t.fields) ? t.fields : []; const visibleFields = arr.filter(it => !it.hidden); m[t.table] = visibleFields.map(it => ({ value: it.field, label: it.label })); if (t.label) tblLabels[t.table] = t.label; }); metaFM.value = m; metaTableLabels.value = tblLabels; } catch (_e) { metaFM.value = {}; metaTableLabels.value = {}; } }; const loadRecommendedFields = async (ds, orderType) => { try { const res = await fetch(API_BASE + '/api/metadata/fields?datasource=' + encodeURIComponent(ds) + '&order_type=' + encodeURIComponent(String(orderType || 0))); const data = await res.json(); const rec = Array.isArray(data?.data?.recommended) ? data.data.recommended : (Array.isArray(data?.recommended) ? data.recommended : []); recommendedMeta.value = rec; return rec; } catch (_e) { recommendedMeta.value = []; return []; } }; const getFieldsMap = (ds) => metaFM.value || {}; // ==================== 表标签映射 ==================== const TABLE_LABELS = { order: '订单主表', order_detail: '订单详情', order_cash: '红包订单', order_voucher: '立减金订单', plan: '活动计划', key_batch: 'key批次', code_batch: '兑换码批次', voucher: '立减金', voucher_batch: '立减金批次', merchant_key_send: '开放平台发放记录', order_digit: '直充卡密订单', merchant: '客户', activity: '活动', goods_voucher_batch: '立减金批次表', goods_voucher_subject_config: '立减金主体配置' }; const tableLabel = (table) => metaTableLabels.value[table] || TABLE_LABELS[table] || table; // ==================== 字段选项构建 ==================== const buildFieldNode = (table, children = []) => ({ value: table, label: tableLabel(table), children }); const buildYmtOrderChildren = (FM, type) => { const orderFields = FM['order'] || []; const ch = [...orderFields.map(f => ({ value: f.value, label: f.label }))]; ch.push(buildFieldNode('merchant', FM['merchant'] || [])); ch.push(buildFieldNode('activity', FM['activity'] || [])); if (type === 2) { ch.push(buildFieldNode('order_digit', FM['order_digit'] || [])); } else if (type === 3) { ch.push(buildFieldNode('order_voucher', FM['order_voucher'] || [])); ch.push(buildFieldNode('goods_voucher_batch', FM['goods_voucher_batch'] || [])); ch.push(buildFieldNode('goods_voucher_subject_config', FM['goods_voucher_subject_config'] || [])); } else if (type === 1) { ch.push(buildFieldNode('order_cash', FM['order_cash'] || [])); } else { ch.push(buildFieldNode('order_voucher', FM['order_voucher'] || [])); ch.push(buildFieldNode('order_cash', FM['order_cash'] || [])); ch.push(buildFieldNode('order_digit', FM['order_digit'] || [])); ch.push(buildFieldNode('goods_voucher_batch', FM['goods_voucher_batch'] || [])); ch.push(buildFieldNode('goods_voucher_subject_config', FM['goods_voucher_subject_config'] || [])); } return ch; }; const buildMarketingOrderChildren = (FM, type) => { const orderFields = FM['order'] || []; const ch = [...orderFields.map(f => ({ value: f.value, label: f.label }))]; ch.push(buildFieldNode('order_detail', FM['order_detail'] || [])); const planChildren = [ ...(FM['plan'] || []).map(f => ({ value: f.value, label: f.label })), buildFieldNode('key_batch', [ ...(FM['key_batch'] || []).map(f => ({ value: f.value, label: f.label })), buildFieldNode('code_batch', FM['code_batch'] || []) ]) ]; const voucherChildren = [ ...(FM['order_voucher'] || []).map(f => ({ value: f.value, label: f.label })), buildFieldNode('voucher', [ ...(FM['voucher'] || []).map(f => ({ value: f.value, label: f.label })), buildFieldNode('voucher_batch', FM['voucher_batch'] || []) ]) ]; if (type === 1) { ch.push(buildFieldNode('plan', planChildren)); ch.push(buildFieldNode('merchant_key_send', FM['merchant_key_send'] || [])); } else if (type === 2) { ch.push(buildFieldNode('order_voucher', voucherChildren)); ch.push(buildFieldNode('plan', planChildren)); } else if (type === 3) { ch.push(buildFieldNode('order_cash', FM['order_cash'] || [])); ch.push(buildFieldNode('plan', planChildren)); } else { ch.push(buildFieldNode('order_cash', FM['order_cash'] || [])); ch.push(buildFieldNode('order_voucher', voucherChildren)); ch.push(buildFieldNode('plan', planChildren)); ch.push(buildFieldNode('merchant_key_send', FM['merchant_key_send'] || [])); } return ch; }; // 树形选择器数据(与级联选择器兼容) const fieldTreeData = Vue.computed(() => { const ds = state.form.datasource; const FM = getFieldsMap(ds); const type = Number(state.form.orderType || 0); if (ds === 'ymt') { const orderNode = buildFieldNode('order', buildYmtOrderChildren(FM, type)); return type ? [orderNode] : [{ value: 'scene_order', label: '订单数据', children: [orderNode] }]; } else { const orderNode = buildFieldNode('order', buildMarketingOrderChildren(FM, type)); return type ? [orderNode] : [{ value: 'scene_order', label: '订单数据', children: [orderNode] }]; } }); const editFieldTreeData = Vue.computed(() => { const ds = state.edit.datasource; const FM = getFieldsMap(ds); const type = Number(state.edit.orderType || 0); const mainTable = state.edit.main_table || 'order'; const actualMainTable = (ds === 'ymt' && mainTable === 'order_info') ? 'order' : mainTable; if (ds === 'ymt') { const orderFields = FM[actualMainTable] || []; const ch = [...orderFields.map(f => ({ value: f.value, label: f.label }))]; const ymtChildren = buildYmtOrderChildren(FM, type); ch.push(...ymtChildren.slice(orderFields.length)); return [buildFieldNode(actualMainTable, ch)]; } else { return [buildFieldNode(mainTable, buildMarketingOrderChildren(FM, type))]; } }); // 保留级联选择器的选项(用于兼容) const fieldOptionsDynamic = fieldTreeData; const editFieldOptionsDynamic = editFieldTreeData; // ==================== 选项配置 ==================== const sceneOptions = Vue.computed(() => { const ds = state.form.datasource; return [{ label: '订单数据', value: ds === 'ymt' ? 'order_info' : 'order' }]; }); const editSceneOptions = Vue.computed(() => { const ds = state.edit.datasource; return [{ label: '订单数据', value: ds === 'ymt' ? 'order_info' : 'order' }]; }); const visibilityOptions = [ { label: '个人', value: 'private' }, { label: '公共', value: 'public' } ]; const formatOptions = [ { label: 'XLSX', value: 'xlsx' }, { label: 'CSV', value: 'csv' } ]; const datasourceOptions = [ { label: '营销系统', value: 'marketing' }, { label: '易码通', value: 'ymt' } ]; const orderTypeOptionsFor = (ds) => { if (ds === 'ymt') { return [ { label: '直充卡密', value: 2 }, { label: '立减金', value: 3 }, { label: '红包', value: 1 } ]; } return [ { label: '直充卡密', value: 1 }, { label: '立减金', value: 2 }, { label: '红包', value: 3 } ]; }; const dsLabel = (v) => { if (v === 'marketing') return '营销系统'; if (v === 'ymt') return '易码通'; return v || ''; }; const DEFAULT_FIELDS = { marketing: 'order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time', ymt: 'order_number,creator,out_trade_no,type,status,contract_price,num,pay_amount,create_time' }; // ==================== 树形选择器 ==================== const createFieldsTree = Vue.ref(null); const editFieldsTree = Vue.ref(null); // 树形选择器复选框变化处理 const onTreeCheck = (kind, data) => { const tree = kind === 'create' ? createFieldsTree.value : editFieldsTree.value; if (!tree) return; // 获取所有选中的节点(只获取完全选中的,不包括半选中的父节点) const checkedKeys = tree.getCheckedKeys(); const treeData = kind === 'create' ? fieldTreeData.value : editFieldTreeData.value; // 递归查找节点路径 const 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 = findNodePath(node.children, targetKey, newPath); if (found) return found; } } return null; }; // 查找节点(用于判断是否是叶子节点) const findNode = (nodes, targetKey) => { for (const node of nodes) { if (node.value === targetKey) { return node; } if (node.children) { const found = findNode(node.children, targetKey); if (found) return found; } } return null; }; // 将选中的节点转换为路径数组格式 const paths = checkedKeys .filter(key => { // 只保留叶子节点(字段节点) const node = findNode(treeData, key); return node && (!node.children || node.children.length === 0); }) .map(key => { // 查找节点的完整路径 const path = findNodePath(treeData, key); return path || [key]; }) .filter(path => path.length >= 2); // 确保路径至少包含表和字段 // 更新表单字段选择(使用路径数组格式) if (kind === 'create') { state.form.fieldsSel = paths; } else { state.edit.fieldsSel = paths; } }; // 设置树形选择器的选中状态 const setTreeChecked = (kind, values) => { const tree = kind === 'create' ? createFieldsTree.value : editFieldsTree.value; if (!tree || !values || !Array.isArray(values)) { console.warn('setTreeChecked: 树或值无效', { kind, tree: !!tree, values }); return; } const treeData = kind === 'create' ? fieldTreeData.value : editFieldTreeData.value; // 递归查找节点,根据路径数组找到对应的 value const 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 = findNodeByPath(node.children, path, index + 1); if (found) return found; } } } return null; }; // 将路径数组转换为节点 key 值 const keys = values.map(v => { if (Array.isArray(v)) { // 路径数组格式,查找对应的节点 value const nodeValue = findNodeByPath(treeData, v); if (nodeValue) return nodeValue; // 如果找不到,尝试使用路径的最后一部分(字段名) return v[v.length - 1]; } else if (typeof v === 'string') { // 已经是字符串格式,可能是 'table.field' 或直接是字段名 return v; } return null; }).filter(Boolean); Vue.nextTick(() => { setTimeout(() => { try { if (keys.length > 0 && tree) { tree.setCheckedKeys(keys); } } catch (e) { console.warn('设置树形选择器选中状态失败:', e); } }, 100); }); }; // 监听字段选择变化,同步到树形选择器 Vue.watch(() => state.form.fieldsSel, (newVal) => { if (newVal && Array.isArray(newVal) && newVal.length > 0) { Vue.nextTick(() => { setTreeChecked('create', newVal); }); } }); Vue.watch(() => state.edit.fieldsSel, (newVal) => { if (newVal && Array.isArray(newVal) && newVal.length > 0) { Vue.nextTick(() => { setTreeChecked('edit', newVal); }); } }); // 兼容级联选择器的函数(已废弃,保留以避免错误) const onCascaderVisible = () => {}; const onFieldsSelChange = () => {}; // ==================== 表单验证规则 ==================== const createRules = { 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' }] }; const editRules = { 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' }] }; const exportRules = { tplId: [{ required: true, message: '请选择模板', trigger: 'change' }], dateRange: [{ validator: (_r, v, cb) => { if (Array.isArray(v) && v.length === 2) { cb(); } else { cb(new Error('请选择时间范围')); } }, trigger: 'change' }] }; // ==================== 表单引用 ==================== const createFormRef = Vue.ref(null); const exportFormRef = Vue.ref(null); const editFormRef = 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 { const res = await fetch(API_BASE + '/api/creators'); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); creatorOptions.value = arr.map(it => ({ label: it.name || String(it.id), value: Number(it.id) })); } catch (_e) { creatorOptions.value = []; } }; const loadYmtCreators = async () => { try { const uid = getUserId(); const url = API_BASE + '/api/ymt/users?limit=2000' + (uid ? ('&q=' + encodeURIComponent(uid)) : ''); const res = await fetch(url); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); ymtCreatorOptions.value = arr.map(it => ({ label: it.name || String(it.id), value: Number(it.id) })); } catch (_e) { ymtCreatorOptions.value = []; } }; const loadResellers = async () => { const ids = Array.isArray(state.exportForm.creatorIds) ? state.exportForm.creatorIds : []; if (!ids.length) { resellerOptions.value = []; return; } try { const res = await fetch(API_BASE + '/api/resellers?creator=' + ids.join(',')); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); resellerOptions.value = arr.map(it => ({ label: (it.name || '') + (it.name ? '' : ''), value: Number(it.id) })); } catch (_e) { resellerOptions.value = []; } }; const loadYmtMerchants = async () => { const uid = state.exportForm.ymtCreatorId; if (!uid) { ymtMerchantOptions.value = []; return; } try { const qs = new URLSearchParams(); qs.set('user_id', String(uid)); qs.set('limit', '2000'); const res = await fetch(API_BASE + '/api/ymt/merchants?' + qs.toString()); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); ymtMerchantOptions.value = arr.map(it => ({ label: `${it.id} - ${it.name || ''}`, value: Number(it.id) })); } catch (_e) { ymtMerchantOptions.value = []; } }; const loadYmtActivities = async () => { const mid = state.exportForm.ymtMerchantId; if (!mid) { ymtActivityOptions.value = []; return; } try { const qs = new URLSearchParams(); qs.set('merchant_id', String(mid)); qs.set('limit', '2000'); const res = await fetch(API_BASE + '/api/ymt/activities?' + qs.toString()); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); ymtActivityOptions.value = arr.map(it => ({ label: `${it.id} - ${it.name || ''}`, value: Number(it.id) })); } catch (_e) { ymtActivityOptions.value = []; } }; const loadPlans = async () => { const rid = state.exportForm.resellerId; if (!rid) { planOptions.value = []; return; } try { const qs = new URLSearchParams(); qs.set('reseller', String(rid)); const res = await fetch(API_BASE + '/api/plans?' + qs.toString()); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); planOptions.value = arr.map(it => ({ label: `${it.id} - ${it.title || ''}`, value: Number(it.id) })); } catch (_e) { planOptions.value = []; } }; // ==================== 计算属性 ==================== const hasCreators = Vue.computed(() => Array.isArray(state.exportForm.creatorIds) && 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 f = state.exportTpl && state.exportTpl.filters; if (!f) return null; if (f.type_eq != null) return Number(f.type_eq); if (Array.isArray(f.type_in) && f.type_in.length === 1) return Number(f.type_in[0]); return null; }); const exportTypeList = Vue.computed(() => { const f = state.exportTpl && state.exportTpl.filters; if (!f) return []; if (Array.isArray(f.type_in) && f.type_in.length) return f.type_in.map(n => Number(n)); if (f.type_eq != null) return [Number(f.type_eq)]; return []; }); const isOrder = Vue.computed(() => { const mt = state.exportTpl && state.exportTpl.main_table; return mt === 'order' || mt === 'order_info'; }); const orderTypeLabel = (ds, n) => { if (ds === 'ymt') { if (n === 2) return '直充卡密'; if (n === 3) return '立减金'; if (n === 1) return '红包'; return ''; } if (n === 1) return '直充卡密'; if (n === 2) return '立减金'; if (n === 3) return '红包'; return ''; }; const sceneLabel = (s) => { if (s === 'order' || s === 'order_info') return '订单'; return s || ''; }; const exportTitle = Vue.computed(() => { let base = '执行导出'; const mt = state.exportTpl && state.exportTpl.main_table; if (mt) { base += ' - ' + sceneLabel(mt); if (mt === 'order' || mt === 'order_info') { const list = exportTypeList.value; const ds = state.exportTpl && state.exportTpl.datasource; const labels = list.map(n => orderTypeLabel(ds, n)).filter(Boolean); if (labels.length) { base += ' - 订单类型:' + labels.join('、'); } } } return base; }); // ==================== 字段路径处理 ==================== const orderLeafPaths = (ds) => { const FM = getFieldsMap(ds); const mainTable = 'order'; return (FM[mainTable] || []).map(f => [mainTable, f.value]); }; const tableKeys = (ds) => Object.keys(getFieldsMap(ds) || {}); const isGroupPath = (ds, path) => Array.isArray(path) && path.length >= 1 && tableKeys(ds).includes(path[path.length - 1]); const convertFieldsToPaths = (fields, ds, mainTable) => { const actualMainTable = (ds === 'ymt' && mainTable === 'order_info') ? 'order' : mainTable; const toPath = (tf) => { const parts = String(tf || '').split('.'); if (parts.length !== 2) return null; let table = parts[0]; const field = parts[1]; if (ds === 'ymt' && table === 'order_info') { table = 'order'; } if (table === actualMainTable) { return [actualMainTable, field]; } else if (table === 'order_detail') { return [actualMainTable, 'order_detail', field]; } else if (table === 'plan') { return [actualMainTable, 'plan', field]; } else if (table === 'key_batch') { return [actualMainTable, 'plan', 'key_batch', field]; } else if (table === 'code_batch') { return [actualMainTable, 'plan', 'key_batch', 'code_batch', field]; } else if (table === 'order_voucher') { return [actualMainTable, 'order_voucher', field]; } else if (table === 'voucher') { return [actualMainTable, 'order_voucher', 'voucher', field]; } else if (table === 'voucher_batch') { return [actualMainTable, 'order_voucher', 'voucher', 'voucher_batch', field]; } else if (table === 'merchant_key_send') { return [actualMainTable, 'merchant_key_send', field]; } else if (table === 'order_cash') { return [actualMainTable, 'order_cash', field]; } else if (table === 'order_digit') { return [actualMainTable, 'order_digit', field]; } else if (table === 'goods_voucher_batch') { return [actualMainTable, 'goods_voucher_batch', field]; } else if (table === 'goods_voucher_subject_config') { return [actualMainTable, 'goods_voucher_subject_config', field]; } else if (table === 'merchant') { return [actualMainTable, 'merchant', field]; } else if (table === 'activity') { return [actualMainTable, 'activity', field]; } return null; }; return fields.map(toPath).filter(p => Array.isArray(p) && p.length >= 2); }; const convertPathsToFields = (paths, ds, mainTable) => { const actualMainTable = (ds === 'ymt' && mainTable === 'order_info') ? 'order' : mainTable; const hasMainTableOnly = paths.some(p => Array.isArray(p) && p.length === 1 && p[0] === actualMainTable); if (hasMainTableOnly) { return orderLeafPaths(ds).map(p => `${p[0]}.${p[1]}`); } return paths.flatMap(path => { if (!Array.isArray(path)) return []; if (isGroupPath(ds, path)) return []; if (path.length >= 2) { const t = path[path.length - 2]; const f = path[path.length - 1]; const finalTable = (ds === 'ymt' && t === 'order') ? 'order_info' : t; return [`${finalTable}.${f}`]; } return []; }); }; // ==================== 模板管理 ==================== const loadTemplates = async () => { try { const res = await fetch(API_BASE + '/api/templates' + qsUser()); if (!res.ok) { msg('加载模板失败', 'error'); state.templates = []; return; } const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); state.templates = arr; } catch (e) { msg('加载模板异常', 'error'); state.templates = []; } }; const createTemplate = async () => { const formRef = createFormRef.value; const ok = formRef ? await formRef.validate().catch(() => false) : true; if (!ok) { msg('请完善必填项', 'error'); return; } let fields = []; if (state.form.fieldsSel && state.form.fieldsSel.length) { fields = convertPathsToFields(state.form.fieldsSel, state.form.datasource, state.form.main_table); } else { const rec = recommendedMeta.value || []; if (Array.isArray(rec) && rec.length) { fields = rec; } else { const ds = state.form.datasource; const defaultTable = ds === 'ymt' ? 'order_info' : 'order'; fields = state.form.fieldsRaw.split(',').map(s => s.trim()).filter(Boolean).map(f => `${defaultTable}.${f}`); } } const payload = { name: state.form.name, datasource: state.form.datasource, main_table: (state.form.datasource === 'ymt' ? 'order_info' : 'order'), fields, filters: { type_eq: Number(state.form.orderType) }, file_format: state.form.file_format, visibility: state.form.visibility, owner_id: (getUserId() ? Number(getUserId()) : 0) }; const res = await fetch(API_BASE + '/api/templates' + qsUser(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { msg('创建成功'); state.createVisible = false; loadTemplates(); } else { msg(await res.text(), 'error'); } }; const openEdit = async (row) => { state.edit.id = row.id; try { const res = await fetch(API_BASE + '/api/templates/' + row.id); const data = await res.json(); const tpl = data?.data || {}; state.edit.name = tpl.name || row.name || ''; state.edit.datasource = tpl.datasource || row.datasource || 'marketing'; state.edit.main_table = tpl.main_table || row.main_table || 'order'; state.edit.file_format = tpl.file_format || row.file_format || 'xlsx'; state.edit.visibility = tpl.visibility || row.visibility || 'private'; const filters = tpl.filters || {}; if (filters && (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 = 1; } const fields = Array.isArray(tpl.fields) ? tpl.fields : []; state.editVisible = true; await Vue.nextTick(); await loadFieldsMeta(state.edit.datasource, state.edit.orderType); await Vue.nextTick(); const mainTable = state.edit.main_table || 'order'; const paths = convertFieldsToPaths(fields, state.edit.datasource, mainTable); state.edit.fieldsSel = paths; // 设置树形选择器的选中状态(延迟确保树已渲染) await Vue.nextTick(); setTimeout(() => { setTreeChecked('edit', paths); }, 300); } catch (_e) { 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 = 1; state.edit.fieldsSel = []; state.editVisible = true; } }; const saveEdit = async () => { const formRef = editFormRef.value; const ok = formRef ? await formRef.validate().catch(() => false) : true; if (!ok) { msg('请完善必填项', 'error'); return; } const id = state.edit.id; let fields = []; const ds = state.edit.datasource; const mainTable = state.edit.main_table || 'order'; if (state.edit.fieldsSel && state.edit.fieldsSel.length) { fields = convertPathsToFields(state.edit.fieldsSel, ds, mainTable); } else { const defaultTable = 'order'; const def = DEFAULT_FIELDS[ds] || ''; fields = def.split(',').map(s => s.trim()).filter(Boolean).map(f => `${defaultTable}.${f}`); } if (!fields.length) { msg('请至少选择一个字段', 'error'); return; } const payload = { name: state.edit.name, visibility: state.edit.visibility, file_format: state.edit.file_format, fields, filters: { type_eq: Number(state.edit.orderType || 1) }, main_table: 'order' }; try { const res = await fetch(API_BASE + '/api/templates/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { msg('保存成功'); state.editVisible = false; loadTemplates(); } else { msg(await res.text(), 'error'); } } catch (e) { msg('保存请求发生错误: ' + e.message, 'error'); } }; const removeTemplate = async (id) => { const r = await fetch(API_BASE + '/api/templates/' + id + '?soft=1', { method: 'DELETE' }); if (r.ok) { msg('删除成功'); loadTemplates(); } else { msg(await r.text(), 'error'); } }; // ==================== 导出管理 ==================== const loadTemplateDetail = async (id) => { try { const res = await fetch(API_BASE + '/api/templates/' + id); if (!res.ok) { msg('加载模板详情失败', 'error'); state.exportTpl = { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' }; return; } const data = await res.json(); const tpl = data?.data || {}; state.exportTpl = tpl; } catch (e) { msg('加载模板详情异常', 'error'); state.exportTpl = { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' }; } }; const openExport = async (row) => { state.exportForm.tplId = row.id; await loadTemplateDetail(row.id); 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 uid = getUserId(); if (uid) { const parts = String(uid).split(',').map(s => s.trim()).filter(Boolean); if (parts.length > 1) { state.exportForm.creatorIds = parts.map(n => Number(n)); } else { state.exportForm.creatorIds = [Number(uid)]; } } } if (state.exportForm.datasource === 'ymt') { await loadYmtCreators(); await loadYmtMerchants(); await loadYmtActivities(); const uid = getUserId(); if (uid) { const first = String(uid).split(',').map(s => s.trim()).filter(Boolean)[0]; if (first) { state.exportForm.ymtCreatorId = Number(first); } } } if (!Array.isArray(state.exportForm.dateRange) || state.exportForm.dateRange.length !== 2) { state.exportForm.dateRange = monthRange(-1); } state.exportVisible = true; }; const submitExport = async () => { const formRef = exportFormRef.value; const ok = formRef ? await formRef.validate().catch(() => false) : true; if (!ok) { msg('请完善必填项', 'error'); return; } state.exportSubmitting = true; msg('估算中', 'info'); try { const id = state.exportForm.tplId; const filters = {}; const tVal = exportType.value; if (tVal != null) { filters.type_eq = Number(tVal); } if (Array.isArray(state.exportForm.dateRange) && state.exportForm.dateRange.length === 2) { filters.create_time_between = [state.exportForm.dateRange[0], state.exportForm.dateRange[1]]; } if (state.exportForm.planId) { filters.plan_id_eq = Number(state.exportForm.planId); } if (state.exportForm.resellerId) { filters.reseller_id_eq = Number(state.exportForm.resellerId); } if (state.exportForm.voucherChannelActivityId) { filters.order_voucher_channel_activity_id_eq = state.exportForm.voucherChannelActivityId; } if (Array.isArray(state.exportForm.creatorIds) && state.exportForm.creatorIds.length) { filters.creator_in = state.exportForm.creatorIds.map(Number); } else if (state.exportForm.creatorIdsRaw) { const arr = String(state.exportForm.creatorIdsRaw).split(',').map(s => s.trim()).filter(Boolean); if (arr.length) { filters.creator_in = arr; } } if (state.exportForm.datasource === 'ymt') { if (String(state.exportForm.ymtCreatorId).trim()) { filters.creator_in = [Number(state.exportForm.ymtCreatorId)]; } if (String(state.exportForm.ymtMerchantId).trim()) { filters.reseller_id_eq = Number(state.exportForm.ymtMerchantId); } if (String(state.exportForm.ymtActivityId).trim()) { filters.plan_id_eq = Number(state.exportForm.ymtActivityId); } } const payload = { template_id: Number(id), requested_by: 1, permission: {}, options: {}, filters, file_format: state.exportForm.file_format, datasource: state.exportForm.datasource }; const r = await fetch(API_BASE + '/api/exports' + qsUser(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const j = await r.json(); const jid = j?.data?.id ?? j?.id; state.exportVisible = false; if (jid) { state.jobsTplId = Number(id); state.jobsVisible = true; loadJobs(1); startJobsPolling(); } else { msg('任务创建返回异常', 'error'); } } finally { state.exportSubmitting = false; } }; // ==================== 任务管理 ==================== const loadJobs = async (page) => { if (!page) page = state.jobsPage; try { const qs = new URLSearchParams(); qs.set('page', String(page)); qs.set('page_size', String(state.jobsPageSize)); if (state.jobsTplId) { qs.set('template_id', String(state.jobsTplId)); } const res = await fetch(API_BASE + '/api/exports?' + qs.toString() + (qsUser() ? ('&' + qsUser().slice(1)) : '')); if (!res.ok) { state.jobs = []; return; } const data = await res.json(); const payload = data?.data || data || {}; const arr = Array.isArray(payload.items) ? payload.items : (Array.isArray(payload) ? payload : []); state.jobs = arr; state.jobsTotal = Number(payload.total || 0); state.jobsPage = Number(payload.page || page); } catch (_e) { state.jobs = []; } }; let jobsPollTimer = null; const startJobsPolling = () => { if (jobsPollTimer) return; jobsPollTimer = setInterval(() => { if (state.jobsVisible) { loadJobs(state.jobsPage); } }, 1000); }; const stopJobsPolling = () => { if (jobsPollTimer) { clearInterval(jobsPollTimer); jobsPollTimer = null; } }; const openJobs = (row) => { state.jobsTplId = row.id; state.jobsVisible = true; loadJobs(1); startJobsPolling(); }; const closeJobs = () => { state.jobsVisible = false; stopJobsPolling(); }; const jobPercent = (row) => { const est = Number(row.row_estimate || 0); const done = Number(row.total_rows || 0); if (row.status === 'completed') return '100%'; if (row.status === 'failed') return '失败'; if (row.status === 'canceled') return '已取消'; if (row.status === 'queued') return '0%'; if (row.status === 'running') { const effectiveEst = est > 0 ? est : (done > 0 ? done * 2 : 0); if (effectiveEst > 0) { const p = Math.max(0, Math.min(100, Math.floor(done * 100 / effectiveEst))); return p + '%'; } if (done > 0) return `已写${done.toLocaleString()}`; return '评估中'; } const effectiveEst = est > 0 ? est : (done > 0 ? done * 2 : 0); if (effectiveEst > 0) { const p = Math.max(0, Math.min(100, Math.floor(done * 100 / effectiveEst))); return p + '%'; } return '评估中'; }; const loadJob = async (id) => { try { const res = await fetch(API_BASE + '/api/exports/' + id); if (!res.ok) { msg('加载任务失败', 'error'); state.job = {}; return; } const data = await res.json(); state.job = data?.data || {}; } catch (e) { msg('加载任务异常', 'error'); state.job = {}; } }; const download = (id) => { window.open(API_BASE + '/api/exports/' + id + '/download', '_blank'); }; const openSQL = async (id) => { try { state.sqlExplainDesc = ''; const res = await fetch(API_BASE + '/api/exports/' + id + '/sql'); const data = await res.json(); const s = data?.data?.final_sql || data?.final_sql || data?.data?.sql || data?.sql || ''; state.sqlText = s; try { const jobRes = await fetch(API_BASE + '/api/exports/' + id + (qsUser() ? qsUser() : '')); if (jobRes.ok) { const jobData = await jobRes.json(); const job = jobData?.data || jobData || {}; state.sqlExplainDesc = job?.eval_desc || job?.eval_status || ''; } else { const row = Array.isArray(state.jobs) ? state.jobs.find(r => Number(r.id) === Number(id)) : null; state.sqlExplainDesc = row?.eval_desc || row?.eval_status || ''; } } catch (_ignore) { const row = Array.isArray(state.jobs) ? state.jobs.find(r => Number(r.id) === Number(id)) : null; state.sqlExplainDesc = row?.eval_desc || row?.eval_status || ''; } state.sqlVisible = true; } catch (_e) { state.sqlText = ''; state.sqlExplainDesc = ''; state.sqlVisible = false; msg('加载SQL失败', 'error'); } }; // ==================== 对话框尺寸管理 ==================== const clampWidth = (w) => { const n = Math.max(500, Math.min(1400, w)); return n + 'px'; }; const resizeDialog = (kind, delta) => { if (kind === 'create') { const cur = parseInt(String(state.createWidth).replace('px', '') || '900', 10); const next = clampWidth(cur + delta); state.createWidth = next; localStorage.setItem('tplDialogWidth', next); } else if (kind === 'edit') { const cur = parseInt(String(state.editWidth).replace('px', '') || '600', 10); const next = clampWidth(cur + delta); state.editWidth = next; localStorage.setItem('tplEditDialogWidth', next); } }; // ==================== 监听器 ==================== Vue.watch(() => state.form.datasource, async (ds) => { state.form.fieldsSel = []; state.form.main_table = (ds === 'ymt' ? 'order_info' : 'order'); state.form.orderType = (ds === 'ymt' ? 2 : 1); await loadFieldsMeta(ds, state.form.orderType); const rec = await loadRecommendedFields(ds, state.form.orderType); const recOrderFields = (rec || []).filter(k => String(k).startsWith('order.')).map(k => String(k).split('.')[1]); state.form.fieldsRaw = (recOrderFields.length ? recOrderFields.join(',') : (DEFAULT_FIELDS[ds] || '')); }); Vue.watch(() => state.form.orderType, async () => { state.form.fieldsSel = []; const ds = state.form.datasource; await loadFieldsMeta(ds, state.form.orderType); const rec = await loadRecommendedFields(ds, state.form.orderType); const recOrderFields = (rec || []).filter(k => String(k).startsWith('order.')).map(k => String(k).split('.')[1]); state.form.fieldsRaw = (recOrderFields.length ? recOrderFields.join(',') : (DEFAULT_FIELDS[ds] || '')); }); Vue.watch(() => state.edit.datasource, async (ds) => { state.edit.fieldsSel = []; state.edit.main_table = (ds === 'ymt' ? 'order_info' : 'order'); if (!Number(state.edit.orderType || 0)) { state.edit.orderType = (ds === 'ymt' ? 2 : 1); } await loadFieldsMeta(ds, state.edit.orderType); }); Vue.watch(() => state.edit.orderType, async () => { state.edit.fieldsSel = []; await loadFieldsMeta(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(); loadFieldsMeta(state.form.datasource, state.form.orderType); // ==================== 返回 ==================== return { ...Vue.toRefs(state), visibilityOptions, formatOptions, datasourceOptions, fieldOptionsDynamic, editFieldOptionsDynamic, sceneOptions, editSceneOptions, loadTemplates, createTemplate, openExport, submitExport, loadJob, loadJobs, openJobs, closeJobs, download, openSQL, openEdit, saveEdit, removeTemplate, resizeDialog, createRules, exportRules, editRules, createFormRef, exportFormRef, editFormRef, dsLabel, orderTypeOptionsFor, exportType, isOrder, exportTitle, creatorOptions, ymtCreatorOptions, ymtMerchantOptions, ymtActivityOptions, resellerOptions, planOptions, hasCreators, hasReseller, hasPlan, hasKeyBatch, hasCodeBatch, jobPercent, fmtDT, createFieldsTree, editFieldsTree, fieldTreeData, editFieldTreeData, onTreeCheck, setTreeChecked, // 兼容性保留(已废弃) createFieldsCascader: createFieldsTree, editFieldsCascader: editFieldsTree, onCascaderVisible, onFieldsSelChange, hasUserId, currentUserId }; } }); app.use(ElementPlus); app.mount('#app');