MarketingSystemDataExportTool/web/main.js

1499 lines
53 KiB
JavaScript

const { createApp, reactive } = Vue;
// ==================== 常量定义 ====================
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
};
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') || CONSTANTS.DIALOG_DEFAULT_WIDTH + 'px',
editWidth: localStorage.getItem('tplEditDialogWidth') || CONSTANTS.DIALOG_EDIT_DEFAULT_WIDTH + 'px',
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([]);
/**
* 加载字段元数据和推荐字段(合并为单次 API 调用)
* @param {string} ds - 数据源
* @param {number} type - 订单类型
* @returns {Promise<string[]>} 推荐字段列表
*/
const loadFieldsMetaAndRecommended = async (ds, type) => {
try {
const res = await fetch(API_BASE + '/api/metadata/fields?datasource=' + encodeURIComponent(ds) + '&order_type=' + encodeURIComponent(String(type || 0)));
if (!res.ok) {
throw new Error(`加载字段元数据失败: ${res.status}`);
}
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;
// 处理推荐字段
const rec = Array.isArray(data?.data?.recommended) ? data.data.recommended : (Array.isArray(data?.recommended) ? data.recommended : []);
recommendedMeta.value = rec;
return rec;
} catch (e) {
console.error('加载字段元数据失败:', e);
metaFM.value = {};
metaTableLabels.value = {};
recommendedMeta.value = [];
return [];
}
};
// 兼容旧接口
const loadFieldsMeta = (ds, type) => loadFieldsMetaAndRecommended(ds, type);
const loadRecommendedFields = (ds, orderType) => loadFieldsMetaAndRecommended(ds, orderType);
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);
/**
* 树形选择器复选框变化处理
* @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 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);
}
}, CONSTANTS.TREE_RENDER_DELAY);
});
};
// 监听字段选择变化,同步到树形选择器
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 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');
if (!res.ok) {
throw new Error(`加载创建者列表失败: ${res.status}`);
}
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) {
console.error('加载创建者列表失败:', 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);
if (!res.ok) {
throw new Error(`加载易码通用户列表失败: ${res.status}`);
}
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) {
console.error('加载易码通用户列表失败:', 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(','));
if (!res.ok) {
throw new Error(`加载分销商列表失败: ${res.status}`);
}
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) {
console.error('加载分销商列表失败:', 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());
if (!res.ok) {
throw new Error(`加载客户列表失败: ${res.status}`);
}
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) {
console.error('加载客户列表失败:', 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());
if (!res.ok) {
throw new Error(`加载活动列表失败: ${res.status}`);
}
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) {
console.error('加载活动列表失败:', 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());
if (!res.ok) {
throw new Error(`加载计划列表失败: ${res.status}`);
}
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) {
console.error('加载计划列表失败:', 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 FM = getFieldsMap(ds);
const hasField = (tbl, fld) => {
const arr = FM[tbl] || [];
return arr.some(i => i.value === fld);
};
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];
let targetTable = table;
if (ds === 'ymt' && table === 'order_info') {
table = 'order';
targetTable = table;
}
if (table === actualMainTable) {
targetTable = actualMainTable;
return [actualMainTable, field];
} else if (table === 'order_detail') {
targetTable = 'order_detail';
return [actualMainTable, 'order_detail', field];
} else if (table === 'plan') {
targetTable = 'plan';
return [actualMainTable, 'plan', field];
} else if (table === 'key_batch') {
targetTable = 'key_batch';
return [actualMainTable, 'plan', 'key_batch', field];
} else if (table === 'code_batch') {
targetTable = 'code_batch';
return [actualMainTable, 'plan', 'key_batch', 'code_batch', field];
} else if (table === 'order_voucher') {
targetTable = 'order_voucher';
return [actualMainTable, 'order_voucher', field];
} else if (table === 'voucher') {
targetTable = 'voucher';
return [actualMainTable, 'order_voucher', 'voucher', field];
} else if (table === 'voucher_batch') {
targetTable = 'voucher_batch';
return [actualMainTable, 'order_voucher', 'voucher', 'voucher_batch', field];
} else if (table === 'merchant_key_send') {
targetTable = 'merchant_key_send';
return [actualMainTable, 'merchant_key_send', field];
} else if (table === 'order_cash') {
targetTable = 'order_cash';
return [actualMainTable, 'order_cash', field];
} else if (table === 'order_digit') {
targetTable = 'order_digit';
return [actualMainTable, 'order_digit', field];
} else if (table === 'goods_voucher_batch') {
targetTable = 'goods_voucher_batch';
return [actualMainTable, 'goods_voucher_batch', field];
} else if (table === 'goods_voucher_subject_config') {
targetTable = 'goods_voucher_subject_config';
return [actualMainTable, 'goods_voucher_subject_config', field];
} else if (table === 'merchant') {
targetTable = 'merchant';
return [actualMainTable, 'merchant', field];
} else if (table === 'activity') {
targetTable = 'activity';
return [actualMainTable, 'activity', field];
}
return null;
};
return fields
.map(toPath)
.filter(p => Array.isArray(p) && p.length >= 2)
.filter(path => {
const tbl = path[path.length - 2];
const fld = path[path.length - 1];
return hasField(tbl, fld);
});
};
const dedupPaths = (paths = []) => {
const seen = new Set();
const out = [];
for (const p of paths) {
if (!Array.isArray(p)) continue;
const key = p.join('|');
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
};
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 = dedupPaths(convertFieldsToPaths(fields, state.edit.datasource, mainTable));
state.edit.fieldsSel = paths;
// 设置树形选择器的选中状态(延迟确保树已渲染)
await Vue.nextTick();
setTimeout(() => {
setTreeChecked('edit', paths);
}, CONSTANTS.TREE_EDIT_RENDER_DELAY);
} 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) {
const ds2 = state.exportForm.datasource;
const t2 = exportType.value;
if ((ds2 === 'marketing' && t2 === 2) || (ds2 === 'ymt' && t2 === 3)) {
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');