重构数据引擎和报表引擎

This commit is contained in:
2026-06-11 13:06:54 +08:00
parent 285a0fc632
commit 419473f266
53 changed files with 8434 additions and 375 deletions

View File

@@ -0,0 +1,949 @@
package report
import (
"github.com/gogf/gf/v2/net/ghttp"
)
// ReportAdminPage 报表引擎管理页面
func ReportAdminPage(r *ghttp.Request) {
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
r.Response.Write(reportAdminHTML)
r.Exit()
}
var reportAdminHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>报表引擎管理</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; }
.header { background: #001529; color: #fff; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
.header h1 { font-size: 18px; font-weight: 600; }
.header a { color: #91a7ff; text-decoration: none; font-size: 13px; }
.container { max-width: 1500px; margin: 0 auto; padding: 16px; }
.tabs { display: flex; gap: 0; margin-bottom: 16px; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.tab { padding: 10px 24px; cursor: pointer; font-size: 13px; color: #666; border-bottom: 2px solid transparent; transition: all .2s; }
.tab:hover { color: #1890ff; }
.tab.active { color: #1890ff; border-bottom-color: #1890ff; font-weight: 600; }
.panel { display: none; }
.panel.active { display: block; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.toolbar h2 { font-size: 15px; color: #1a1a2e; }
.toolbar-left { display: flex; align-items: center; gap: 8px; }
.toolbar-right { display: flex; gap: 6px; }
.btn { display: inline-flex; align-items: center; gap: 4px; padding: 6px 14px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all .2s; white-space: nowrap; }
.btn-primary { background: #1890ff; color: #fff; }
.btn-primary:hover { background: #40a9ff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.btn-danger:hover { background: #ff7875; }
.btn-success { background: #52c41a; color: #fff; }
.btn-success:hover { background: #73d13d; }
.btn-warning { background: #faad14; color: #fff; }
.btn-warning:hover { background: #ffc53d; }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-outline { background: #fff; border: 1px solid #d9d9d9; color: #333; }
.btn-outline:hover { border-color: #1890ff; color: #1890ff; }
table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); border-collapse: collapse; }
th { background: #fafafa; padding: 10px 14px; text-align: left; font-size: 12px; color: #666; font-weight: 600; border-bottom: 1px solid #f0f0f0; }
td { padding: 10px 14px; font-size: 12px; border-bottom: 1px solid #f0f0f0; }
tr:hover td { background: #fafafa; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.badge.active { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge.inactive { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
.badge.info { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.actions { display: flex; gap: 4px; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 1000; }
.modal-overlay.open { display: block; }
.modal { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #fff; border-radius: 8px; width: 720px; max-width: 95%; max-height: 88vh; overflow-y: auto; z-index: 1001; box-shadow: 0 4px 24px rgba(0,0,0,0.15); }
.modal.open { display: block; }
.modal-header { padding: 14px 20px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background: #fff; z-index: 1; }
.modal-header h3 { font-size: 15px; }
.modal-close { background: none; border: none; font-size: 20px; cursor: pointer; color: #999; }
.modal-body { padding: 20px; }
.modal-footer { padding: 12px 20px; border-top: 1px solid #f0f0f0; display: flex; justify-content: flex-end; gap: 8px; position: sticky; bottom: 0; background: #fff; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 12px; outline: none; transition: border .2s; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
.form-group textarea { font-family: "Fira Code", "Consolas", monospace; font-size: 11px; min-height: 60px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.form-inline { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.form-inline .form-group { margin-bottom: 0; flex: 1; min-width: 150px; }
.checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
.checkbox-group label { display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; font-weight: normal; }
.select-biz { padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 12px; }
.loading { text-align: center; padding: 40px; color: #999; }
.empty { text-align: center; padding: 32px; color: #999; font-size: 13px; }
.toast { position: fixed; top: 16px; right: 16px; padding: 10px 20px; border-radius: 6px; color: #fff; font-size: 13px; z-index: 2000; animation: slideIn .3s; }
.toast.success { background: #52c41a; }
.toast.error { background: #ff4d4f; }
.toast.info { background: #1890ff; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.result-box { margin-top: 12px; background: #fafafa; border-radius: 6px; padding: 12px; max-height: 400px; overflow: auto; font-size: 12px; font-family: monospace; white-space: pre-wrap; }
.query-section { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 12px; }
.query-section h3 { font-size: 14px; margin-bottom: 10px; color: #333; }
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; background: #f0f5ff; border: 1px solid #adc6ff; border-radius: 12px; font-size: 11px; cursor: pointer; }
.chip.selected { background: #1890ff; color: #fff; border-color: #1890ff; }
.chip-group { display: flex; flex-wrap: wrap; gap: 6px; }
.field-tag { display: inline-flex; align-items: center; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 4px; padding: 2px 8px; font-size: 11px; margin: 2px; }
.field-tag .remove { margin-left: 4px; cursor: pointer; color: #ff4d4f; font-weight: bold; }
.add-filter-row { display: grid; grid-template-columns: 1fr 1fr 1.5fr 40px; gap: 6px; align-items: end; margin-bottom: 6px; }
.section-label { font-size: 12px; color: #999; margin-bottom: 6px; }
</style>
</head>
<body>
<div class="header">
<h1>&#x1F4CA; 报表引擎管理</h1>
<a href="/admin">&larr; 返回管理后台</a>
</div>
<div class="container">
<div class="tabs">
<div class="tab active" onclick="switchTab('business',this)">业务管理</div>
<div class="tab" onclick="switchTab('report',this)">报表配置</div>
<div class="tab" onclick="switchTab('field',this)">字段配置</div>
<div class="tab" onclick="switchTab('extract',this)">抽取配置</div>
<div class="tab" onclick="switchTab('query',this)">数据查询</div>
</div>
<!-- ========== 业务管理 ========== -->
<div id="tab-business" class="panel active">
<div class="toolbar">
<div class="toolbar-left"><h2>业务列表</h2></div>
<div class="toolbar-right">
<button class="btn btn-outline btn-sm" onclick="initTables()">初始化系统表</button>
<button class="btn btn-primary btn-sm" onclick="openBizModal()">+ 新建业务</button>
</div>
</div>
<div id="biz-loading" class="loading">加载中...</div>
<div id="biz-list"></div>
</div>
<!-- ========== 报表配置 ========== -->
<div id="tab-report" class="panel">
<div class="toolbar">
<div class="toolbar-left">
<h2>报表列表</h2>
<select class="select-biz" id="report-biz-select" onchange="loadReportList()">
<option value="">-- 选择业务 --</option>
</select>
</div>
<div class="toolbar-right">
<button class="btn btn-primary btn-sm" onclick="openReportModal()">+ 新建报表</button>
</div>
</div>
<div id="report-loading" class="loading" style="display:none">加载中...</div>
<div id="report-list"></div>
</div>
<!-- ========== 字段配置 ========== -->
<div id="tab-field" class="panel">
<div class="toolbar">
<div class="toolbar-left">
<h2>字段列表</h2>
<select class="select-biz" id="field-biz-select" onchange="onFieldBizChange()">
<option value="">-- 选择业务 --</option>
</select>
<select class="select-biz" id="field-report-select" onchange="loadFieldList()">
<option value="">-- 选择报表 --</option>
</select>
</div>
<div class="toolbar-right">
<button class="btn btn-primary btn-sm" onclick="openFieldModal()">+ 新建字段</button>
</div>
</div>
<div id="field-loading" class="loading" style="display:none">加载中...</div>
<div id="field-list"></div>
</div>
<!-- ========== 抽取配置 ========== -->
<div id="tab-extract" class="panel">
<div class="toolbar">
<div class="toolbar-left">
<h2>抽取配置列表</h2>
<select class="select-biz" id="extract-biz-select" onchange="onExtractBizChange()">
<option value="">-- 选择业务 --</option>
</select>
<select class="select-biz" id="extract-report-select" onchange="loadExtractList()">
<option value="">-- 选择报表 --</option>
</select>
</div>
<div class="toolbar-right">
<button class="btn btn-success btn-sm" onclick="execExtract()">执行抽取</button>
<button class="btn btn-primary btn-sm" onclick="openExtractModal()">+ 新建抽取配置</button>
</div>
</div>
<div id="extract-loading" class="loading" style="display:none">加载中...</div>
<div id="extract-list"></div>
</div>
<!-- ========== 数据查询 ========== -->
<div id="tab-query" class="panel">
<div class="toolbar">
<div class="toolbar-left">
<h2>数据查询</h2>
<select class="select-biz" id="query-biz-select" onchange="onQueryBizChange()">
<option value="">-- 选择业务 --</option>
</select>
<select class="select-biz" id="query-report-select" onchange="loadQueryFields()">
<option value="">-- 选择报表 --</option>
</select>
</div>
</div>
<div id="query-content" style="display:none">
<div class="query-section">
<h3>&#x1F4CA; 统计维度</h3>
<div id="dimension-chips" class="chip-group"></div>
</div>
<div class="query-section">
<h3>&#x1F4C8; 统计指标(点击后选择聚合方式)</h3>
<div id="indicator-area"></div>
<div id="selected-indicators" style="margin-top:8px"></div>
</div>
<div class="query-section">
<h3>&#x1F50D; 筛选条件</h3>
<div id="filter-area"></div>
</div>
<div class="query-section">
<h3>&#x1F4C5; 时间范围</h3>
<div class="form-inline">
<div class="form-group"><label>开始日期</label><input type="date" id="query-start-date"></div>
<div class="form-group"><label>结束日期</label><input type="date" id="query-end-date"></div>
<div class="form-group"><label>排序字段</label><input id="query-order-field" placeholder="如 total_amount"></div>
<div class="form-group"><label>排序方向</label><select id="query-order-dir"><option value="DESC">降序</option><option value="ASC">升序</option></select></div>
<div class="form-group"><label>每页</label><input type="number" id="query-page-size" value="20" style="width:70px"></div>
<div><button class="btn btn-primary" onclick="executeQuery()" style="margin-top:22px">查询</button></div>
</div>
</div>
<div id="query-result" class="result-box" style="display:none"></div>
</div>
<div id="query-empty" class="empty" style="display:none">请先选择业务和报表</div>
</div>
</div><!-- container -->
<!-- Modal -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()"></div>
<div class="modal" id="modal">
<div class="modal-header"><h3 id="modalTitle"></h3><button class="modal-close" onclick="closeModal()">&times;</button></div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter"></div>
</div>
<script>
const BASE = '';
let state = { businesses: [], currentBiz: null };
function toast(msg, type) {
const t = document.createElement('div');
t.className = 'toast ' + (type||'info');
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2500);
}
async function api(method, url, body) {
const opt = { method, headers: {} };
if (body) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const resp = await fetch(BASE + url, opt);
if (!resp.ok) { const t = await resp.text(); throw new Error(t || resp.statusText); }
const json = await resp.json();
console.log('[API] ' + method + ' ' + url, 'raw response:', json);
// GoFrame MiddlewareHandlerResponse 包裹: {code:0, data: <returnValue>}
// returnValue 可能是 {data: {...}} 或 {list: [...]} 等
if (json.code !== undefined && json.data !== undefined) {
if (json.code !== 0) throw new Error(json.message || '请求失败');
console.log('[API] extracted data:', json.data);
// 兼容 GoFrame 的 data 可能包含首字母大写的 Go 字段名
// 如 getBusinessRes{Data: ...} 在某些版本可能序列化为 {"Data": ...}
// 此时尝试统一:若有 Data 字段且无 data 字段,取 Data
if (json.data && json.data.Data !== undefined && json.data.data === undefined) {
return json.data.Data;
}
return json.data;
}
return json;
}
function esc(s) {
if (s == null || s === undefined) return '';
if (typeof s === 'object') s = JSON.stringify(s);
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function jsAttr(s) {
// 用于 onclick 等 JS 属性中的字符串值转义(替换反斜杠和单引号)
if (s == null || s === undefined) return '';
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function v(id) { return document.getElementById(id).value; }
async function initTables() {
try {
await api('POST', '/report/initTables');
toast('系统表初始化完成', 'success');
} catch(e) { toast(e.message, 'error'); }
}
// ==================== 导航 ====================
function switchTab(name, tabEl) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
if (tabEl) tabEl.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
if (name === 'business') loadBizList();
else if (name === 'report') loadBizSelects().then(() => loadReportList());
else if (name === 'field') loadBizSelects().then(() => onFieldBizChange());
else if (name === 'extract') loadBizSelects().then(() => onExtractBizChange());
else if (name === 'query') loadBizSelects().then(() => { document.getElementById('query-content').style.display='none'; document.getElementById('query-empty').style.display='block'; });
}
// ==================== 业务选择器 ====================
async function loadBizSelects() {
try {
const data = await api('GET', '/report/businesses');
state.businesses = data.list || [];
const optHtml = '<option value="">-- 选择业务 --</option>' + state.businesses.map(b => '<option value="'+b.businessCode+'">'+esc(b.businessName)+' ('+b.businessCode+')</option>').join('');
['report-biz-select','field-biz-select','extract-biz-select','query-biz-select'].forEach(id => {
const sel = document.getElementById(id);
const oldVal = sel.value;
sel.innerHTML = optHtml;
if (oldVal) sel.value = oldVal;
});
} catch(e) { toast(e.message, 'error'); }
}
async function onFieldBizChange() {
const bc = v('field-biz-select');
const rs = document.getElementById('field-report-select');
rs.innerHTML = '<option value="">-- 选择报表 --</option>';
if (!bc) return;
try {
const data = await api('GET', '/report/reports?businessCode=' + bc);
rs.innerHTML = '<option value="">-- 选择报表 --</option>' + (data.list||[]).map(r => '<option value="'+r.reportCode+'">'+esc(r.reportName)+' ('+r.reportCode+')</option>').join('');
} catch(e) { toast(e.message, 'error'); }
}
async function onExtractBizChange() {
const bc = v('extract-biz-select');
const rs = document.getElementById('extract-report-select');
rs.innerHTML = '<option value="">-- 选择报表 --</option>';
if (!bc) return;
try {
const data = await api('GET', '/report/reports?businessCode=' + bc);
rs.innerHTML = '<option value="">-- 选择报表 --</option>' + (data.list||[]).map(r => '<option value="'+r.reportCode+'">'+esc(r.reportName)+' ('+r.reportCode+')</option>').join('');
} catch(e) { toast(e.message, 'error'); }
}
// ==================== 业务 CRUD ====================
async function loadBizList() {
document.getElementById('biz-loading').style.display = 'block';
try {
const data = await api('GET', '/report/businesses');
document.getElementById('biz-loading').style.display = 'none';
const list = data.list || [];
if (list.length === 0) {
document.getElementById('biz-list').innerHTML = '<div class="empty">暂无业务,点击"新建业务"添加</div>';
return;
}
let h = '<table><thead><tr><th>ID</th><th>编码</th><th>名称</th><th>描述</th><th>创建人</th><th>状态</th><th>操作</th></tr></thead><tbody>';
for (const b of list) {
const sc = b.status === 'ACTIVE' ? 'active' : 'inactive';
h += '<tr><td>'+b.id+'</td><td><strong>'+esc(b.businessCode)+'</strong></td><td>'+esc(b.businessName)+'</td>' +
'<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(b.description||'-')+'</td>' +
'<td>'+esc(b.creator||'-')+'</td>' +
'<td><span class="badge '+sc+'">'+(b.status||'ACTIVE')+'</span></td>' +
'<td class="actions">' +
'<button class="btn btn-sm btn-outline" onclick="openBizModal('+b.id+')">编辑</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteBiz('+b.id+')">删除</button></td></tr>';
}
h += '</tbody></table>';
document.getElementById('biz-list').innerHTML = h;
} catch(e) {
document.getElementById('biz-loading').style.display = 'none';
toast(e.message, 'error');
}
}
function bizFormHTML(d) {
d = d || {};
return '<div class="form-row"><div class="form-group"><label>业务编码 *</label><input id="f-bcode" value="'+esc(d.businessCode||'')+'" placeholder="如 KUAISHOU"></div>' +
'<div class="form-group"><label>业务名称 *</label><input id="f-bname" value="'+esc(d.businessName||'')+'" placeholder="如 快手电商"></div></div>' +
'<div class="form-group"><label>描述</label><input id="f-bdesc" value="'+esc(d.description||'')+'" placeholder="业务描述"></div>' +
'<div class="form-row"><div class="form-group"><label>操作人</label><input id="f-bop" value="'+esc(d.creator||'admin')+'"></div>' +
'<div class="form-group"><label>状态</label><select id="f-bs"><option value="ACTIVE"'+((d.status||'ACTIVE')==='ACTIVE'?' selected':'')+'>启用</option><option value="INACTIVE"'+(d.status==='INACTIVE'?' selected':'')+'>停用</option></select></div></div>';
}
async function openBizModal(id) {
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
const footer = document.getElementById('modalFooter');
if (id) {
try {
const res = await api('GET', '/report/business?id=' + id);
const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
console.log('[BizEdit] id=' + id + ' raw:', res, 'data:', d);
if (!d || !d.id) { toast('未获取到业务数据ID='+id+'', 'error'); return; }
title.textContent = '编辑业务';
body.innerHTML = bizFormHTML(d);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveBiz('+id+')">保存</button>';
} catch(e) { toast(e.message, 'error'); return; }
} else {
title.textContent = '新建业务';
body.innerHTML = bizFormHTML(null);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveBiz(0)">创建</button>';
}
document.getElementById('modalOverlay').classList.add('open');
document.getElementById('modal').classList.add('open');
}
async function saveBiz(id) {
const body = {
businessCode: v('f-bcode'), businessName: v('f-bname'),
description: v('f-bdesc'), status: v('f-bs'), operator: v('f-bop'),
};
if (id) body.id = id;
try {
await api('POST', '/report/business/save', body);
toast(id ? '更新成功' : '创建成功', 'success');
closeModal(); loadBizList(); loadBizSelects();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteBiz(id) {
if (!confirm('确定删除此业务?')) return;
try { await api('DELETE', '/report/business?id='+id); toast('删除成功', 'success'); loadBizList(); loadBizSelects(); }
catch(e) { toast(e.message, 'error'); }
}
// ==================== 报表 CRUD ====================
async function loadReportList() {
const bc = v('report-biz-select');
if (!bc) { document.getElementById('report-list').innerHTML = '<div class="empty">请先选择业务</div>'; return; }
document.getElementById('report-loading').style.display = 'block';
try {
const data = await api('GET', '/report/reports?businessCode=' + bc);
document.getElementById('report-loading').style.display = 'none';
const list = data.list || [];
if (list.length === 0) {
document.getElementById('report-list').innerHTML = '<div class="empty">暂无报表</div>';
return;
}
let h = '<table><thead><tr><th>ID</th><th>编码</th><th>名称</th><th>统计宽表</th><th>日期字段</th><th>冲突键</th><th>状态</th><th>操作</th></tr></thead><tbody>';
for (const r of list) {
const sc = r.status === 'ACTIVE' ? 'active' : 'inactive';
h += '<tr><td>'+r.id+'</td><td><strong>'+esc(r.reportCode)+'</strong></td><td>'+esc(r.reportName)+'</td>' +
'<td style="font-family:monospace;font-size:11px">'+esc(r.statTableName)+'</td>' +
'<td>'+esc(r.dateField||'stat_date')+'</td>' +
'<td style="font-size:11px">'+esc((r.conflictKeys||[]).join(', '))+'</td>' +
'<td><span class="badge '+sc+'">'+(r.status||'ACTIVE')+'</span></td>' +
'<td class="actions">' +
'<button class="btn btn-sm btn-outline" onclick="openReportModal('+r.id+')">编辑</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteReport('+r.id+')">删除</button></td></tr>';
}
h += '</tbody></table>';
document.getElementById('report-list').innerHTML = h;
} catch(e) { document.getElementById('report-loading').style.display = 'none'; toast(e.message, 'error'); }
}
function reportFormHTML(d) {
d = d || {};
return '<div class="form-group"><label>所属业务</label><input id="f-rbiz" value="'+esc(d.businessCode||v('report-biz-select')||'')+'" readonly style="background:#f5f5f5"></div>' +
'<div class="form-row"><div class="form-group"><label>报表编码 *</label><input id="f-rcode" value="'+esc(d.reportCode||'')+'" placeholder="如 shop_daily_report"></div>' +
'<div class="form-group"><label>报表名称 *</label><input id="f-rname" value="'+esc(d.reportName||'')+'" placeholder="如 店铺日报"></div></div>' +
'<div class="form-group"><label>描述</label><input id="f-rdesc" value="'+esc(d.description||'')+'"></div>' +
'<div class="form-row"><div class="form-group"><label>统计宽表名 *</label><input id="f-rtable" value="'+esc(d.statTableName||'')+'" placeholder="如 stat_kuaishou_shop_daily"></div>' +
'<div class="form-group"><label>统计宽表注释</label><input id="f-rtcomment" value="'+esc(d.statTableComment||'')+'"></div></div>' +
'<div class="form-row"><div class="form-group"><label>日期字段</label><input id="f-rdate" value="'+esc(d.dateField||'stat_date')+'"></div>' +
'<div class="form-group"><label>操作人</label><input id="f-rop" value="'+esc(d.creator||'admin')+'"></div></div>' +
'<div class="form-group"><label>冲突键(逗号分隔)</label><input id="f-rck" value="'+esc((d.conflictKeys||[]).join(','))+'"></div>' +
'<div class="form-group"><label>主键(逗号分隔)</label><input id="f-rpk" value="'+esc((d.primaryKeys||['id']).join(','))+'"></div>' +
'<div class="form-row"><div class="form-group"><label>状态</label><select id="f-rs"><option value="ACTIVE"'+((d.status||'ACTIVE')==='ACTIVE'?' selected':'')+'>启用</option><option value="INACTIVE"'+(d.status==='INACTIVE'?' selected':'')+'>停用</option></select></div></div>';
}
async function openReportModal(id) {
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
const footer = document.getElementById('modalFooter');
if (id) {
try {
const res = await api('GET', '/report/report?id=' + id);
const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
console.log('[ReportEdit] id=' + id + ' raw:', res, 'data:', d);
if (!d || !d.id) { toast('未获取到报表数据ID='+id+'', 'error'); return; }
title.textContent = '编辑报表';
body.innerHTML = reportFormHTML(d);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveReport('+id+')">保存</button>';
} catch(e) { toast(e.message, 'error'); return; }
} else {
title.textContent = '新建报表';
body.innerHTML = reportFormHTML(null);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveReport(0)">创建</button>';
}
document.getElementById('modalOverlay').classList.add('open');
document.getElementById('modal').classList.add('open');
}
async function saveReport(id) {
const body = {
businessCode: v('f-rbiz'), reportCode: v('f-rcode'), reportName: v('f-rname'),
description: v('f-rdesc'), statTableName: v('f-rtable'), statTableComment: v('f-rtcomment'),
dateField: v('f-rdate'), operator: v('f-rop'), status: v('f-rs'),
conflictKeys: v('f-rck').split(',').map(s => s.trim()).filter(Boolean),
primaryKeys: v('f-rpk').split(',').map(s => s.trim()).filter(Boolean),
};
if (id) body.id = id;
try {
await api('POST', '/report/report/save', body);
toast(id ? '更新成功' : '创建成功', 'success');
closeModal(); loadReportList();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteReport(id) {
if (!confirm('确定删除此报表?')) return;
try { await api('DELETE', '/report/report?id='+id); toast('删除成功', 'success'); loadReportList(); }
catch(e) { toast(e.message, 'error'); }
}
// ==================== 字段 CRUD ====================
async function loadFieldList() {
const bc = v('field-biz-select');
const rc = v('field-report-select');
if (!bc || !rc) { document.getElementById('field-list').innerHTML = '<div class="empty">请先选择业务和报表</div>'; return; }
document.getElementById('field-loading').style.display = 'block';
try {
const data = await api('GET', '/report/fields?businessCode=' + bc + '&reportCode=' + rc);
document.getElementById('field-loading').style.display = 'none';
const dims = data.dimensions || [], inds = data.indicators || [], fils = data.filters || [];
if (dims.length + inds.length + fils.length === 0) {
document.getElementById('field-list').innerHTML = '<div class="empty">暂无字段</div>';
return;
}
const allFields = [...dims.map(f => ({...f, _role: '维度'})), ...inds.map(f => ({...f, _role: '指标'})), ...fils.map(f => ({...f, _role: '筛选'}))];
let h = '<table><thead><tr><th>ID</th><th>角色</th><th>编码</th><th>名称</th><th>类型</th><th>可聚合</th><th>默认聚合</th><th>分组</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>';
for (const f of allFields) {
const sc = f.status === 'ACTIVE' ? 'active' : 'inactive';
h += '<tr><td>'+f.id+'</td><td><span class="badge '+(f.fieldRole==='DIMENSION'?'info':f.fieldRole==='INDICATOR'?'success':'')+'">'+f._role+'</span></td>' +
'<td><strong>'+esc(f.fieldCode)+'</strong></td><td>'+esc(f.fieldName)+'</td>' +
'<td>'+esc(f.fieldType)+'</td>' +
'<td>'+(f.isAggregatable ? '✓' : '-')+'</td>' +
'<td>'+esc(f.defaultAggregate||'-')+'</td>' +
'<td>'+esc(f.groupName||'-')+'</td><td>'+f.sortOrder+'</td>' +
'<td><span class="badge '+sc+'">'+(f.status||'ACTIVE')+'</span></td>' +
'<td class="actions">' +
'<button class="btn btn-sm btn-outline" onclick="openFieldModal('+f.id+')">编辑</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteField('+f.id+')">删除</button></td></tr>';
}
h += '</tbody></table>';
document.getElementById('field-list').innerHTML = h;
} catch(e) { document.getElementById('field-loading').style.display = 'none'; toast(e.message, 'error'); }
}
function fieldFormHTML(d) {
d = d || {};
return '<div class="form-row"><div class="form-group"><label>所属业务</label><input id="f-fbiz" value="'+esc(d.businessCode||v('field-biz-select')||'')+'" readonly style="background:#f5f5f5"></div>' +
'<div class="form-group"><label>所属报表</label><input id="f-frep" value="'+esc(d.reportCode||v('field-report-select')||'')+'" readonly style="background:#f5f5f5"></div></div>' +
'<div class="form-row-3"><div class="form-group"><label>字段编码 *</label><input id="f-fcode" value="'+esc(d.fieldCode||'')+'" placeholder="如 order_amount"></div>' +
'<div class="form-group"><label>字段名称 *</label><input id="f-fname" value="'+esc(d.fieldName||'')+'"></div>' +
'<div class="form-group"><label>字段类型 *</label><select id="f-ftype"><option value="STRING"'+(d.fieldType==='STRING'||!d.fieldType?' selected':'')+'>STRING</option><option value="INT"'+(d.fieldType==='INT'?' selected':'')+'>INT</option><option value="FLOAT"'+(d.fieldType==='FLOAT'?' selected':'')+'>FLOAT</option><option value="DATE"'+(d.fieldType==='DATE'?' selected':'')+'>DATE</option><option value="DATETIME"'+(d.fieldType==='DATETIME'?' selected':'')+'>DATETIME</option><option value="JSONB"'+(d.fieldType==='JSONB'?' selected':'')+'>JSONB</option></select></div></div>' +
'<div class="form-row-3"><div class="form-group"><label>字段角色 *</label><select id="f-frole"><option value="DIMENSION"'+(d.fieldRole==='DIMENSION'||!d.fieldRole?' selected':'')+'>维度(DIMENSION)</option><option value="INDICATOR"'+(d.fieldRole==='INDICATOR'?' selected':'')+'>指标(INDICATOR)</option><option value="FILTER"'+(d.fieldRole==='FILTER'?' selected':'')+'>筛选(FILTER)</option><option value="FILTER_ONLY"'+(d.fieldRole==='FILTER_ONLY'?' selected':'')+'>仅筛选(FILTER_ONLY)</option></select></div>' +
'<div class="form-group"><label>数据存储类型</label><select id="f-fdtype"><option value="STRING"'+(d.dataType==='STRING'||!d.dataType?' selected':'')+'>STRING</option><option value="INT"'+(d.dataType==='INT'?' selected':'')+'>INT</option><option value="FLOAT"'+(d.dataType==='FLOAT'?' selected':'')+'>FLOAT</option><option value="DATE"'+(d.dataType==='DATE'?' selected':'')+'>DATE</option><option value="DATETIME"'+(d.dataType==='DATETIME'?' selected':'')+'>DATETIME</option><option value="JSONB"'+(d.dataType==='JSONB'?' selected':'')+'>JSONB</option></select></div>' +
'<div class="form-group"><label>默认聚合</label><select id="f-faggr"><option value=""'+(!d.defaultAggregate?' selected':'')+'>无</option><option value="SUM"'+(d.defaultAggregate==='SUM'?' selected':'')+'>SUM</option><option value="COUNT"'+(d.defaultAggregate==='COUNT'?' selected':'')+'>COUNT</option><option value="AVG"'+(d.defaultAggregate==='AVG'?' selected':'')+'>AVG</option><option value="MAX"'+(d.defaultAggregate==='MAX'?' selected':'')+'>MAX</option><option value="MIN"'+(d.defaultAggregate==='MIN'?' selected':'')+'>MIN</option></select></div></div>' +
'<div class="form-row-3"><div class="form-group"><label>分组名称</label><input id="f-fgroup" value="'+esc(d.groupName||'')+'"></div>' +
'<div class="form-group"><label>排序号</label><input id="f-fsort" type="number" value="'+(d.sortOrder||0)+'"></div>' +
'<div class="form-group"><label>单位</label><input id="f-funit" value="'+esc(d.unit||'')+'"></div></div>' +
'<div class="form-group"><label>表达式(衍生字段如 {refund}/{order}*100</label><input id="f-fexpr" value="'+esc(d.expression||'')+'"></div>' +
'<div class="form-row"><div class="form-group"><label>操作人</label><input id="f-fop" value="'+esc(d.creator||'admin')+'"></div>' +
'<div class="form-group"><label>状态</label><select id="f-fs"><option value="ACTIVE"'+((d.status||'ACTIVE')==='ACTIVE'?' selected':'')+'>启用</option><option value="INACTIVE"'+(d.status==='INACTIVE'?' selected':'')+'>停用</option></select></div></div>' +
'<div class="checkbox-group" style="margin-top:8px"><label><input type="checkbox" id="f-fagg" '+(d.isAggregatable===true?'checked':'')+'> 可聚合</label>' +
'<label><input type="checkbox" id="f-ffilter" '+((d.isFilterable===undefined||d.isFilterable===true)?'checked':'')+'> 可筛选</label>' +
'<label><input type="checkbox" id="f-fquery" '+((d.isQueryable===undefined||d.isQueryable===true)?'checked':'')+'> 可查询</label>' +
'<label><input type="checkbox" id="f-fsortable" '+((d.isSortable===undefined||d.isSortable===true)?'checked':'')+'> 可排序</label></div>';
}
async function openFieldModal(id) {
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
const footer = document.getElementById('modalFooter');
if (id) {
try {
const res = await api('GET', '/report/field?id=' + id);
const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
console.log('[FieldEdit] id=' + id + ' raw:', res, 'data:', d);
if (!d || !d.id) { toast('未获取到字段数据ID='+id+'', 'error'); return; }
title.textContent = '编辑字段';
body.innerHTML = fieldFormHTML(d);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveField('+id+')">保存</button>';
} catch(e) { toast(e.message, 'error'); return; }
} else {
title.textContent = '新建字段';
body.innerHTML = fieldFormHTML(null);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveField(0)">创建</button>';
}
document.getElementById('modalOverlay').classList.add('open');
document.getElementById('modal').classList.add('open');
}
function fieldVal(id) { const el = document.getElementById(id); return el ? (el.type==='checkbox'?el.checked:el.value) : ''; }
async function saveField(id) {
const body = {
businessCode: v('f-fbiz'), reportCode: v('f-frep'),
fieldCode: v('f-fcode'), fieldName: v('f-fname'),
fieldType: v('f-ftype'), dataType: v('f-fdtype'), fieldRole: v('f-frole'),
defaultAggregate: v('f-faggr'), groupName: v('f-fgroup'),
sortOrder: parseInt(v('f-fsort'))||0, unit: v('f-funit'),
expression: v('f-fexpr'), operator: v('f-fop'), status: v('f-fs'),
isAggregatable: fieldVal('f-fagg'),
isFilterable: fieldVal('f-ffilter'),
isQueryable: fieldVal('f-fquery'),
isSortable: fieldVal('f-fsortable'),
};
if (id) body.id = id;
try {
await api('POST', '/report/field/save', body);
toast(id ? '更新成功' : '创建成功', 'success');
closeModal(); loadFieldList();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteField(id) {
if (!confirm('确定删除此字段?')) return;
try { await api('DELETE', '/report/field?id='+id); toast('删除成功', 'success'); loadFieldList(); }
catch(e) { toast(e.message, 'error'); }
}
// ==================== 抽取配置 CRUD ====================
async function loadExtractList() {
const bc = v('extract-biz-select');
const rc = v('extract-report-select');
if (!bc || !rc) { document.getElementById('extract-list').innerHTML = '<div class="empty">请先选择业务和报表</div>'; return; }
document.getElementById('extract-loading').style.display = 'block';
try {
const data = await api('GET', '/report/extractConfigs?businessCode=' + bc + '&reportCode=' + rc);
document.getElementById('extract-loading').style.display = 'none';
const list = data.list || [];
if (list.length === 0) {
document.getElementById('extract-list').innerHTML = '<div class="empty">暂无抽取配置</div>';
return;
}
let h = '<table><thead><tr><th>ID</th><th>编码</th><th>名称</th><th>源表</th><th>目标表</th><th>抽取模式</th><th>抽取类型</th><th>启用</th><th>操作</th></tr></thead><tbody>';
for (const ec of list) {
h += '<tr><td>'+ec.id+'</td><td><strong>'+esc(ec.extractCode)+'</strong></td><td>'+esc(ec.extractName)+'</td>' +
'<td style="font-family:monospace;font-size:11px">'+esc(ec.sourceTableName||'-')+'</td>' +
'<td style="font-family:monospace;font-size:11px">'+esc(ec.targetTableName||'-')+'</td>' +
'<td><span class="badge '+(ec.extractMode==='AGGREGATE'?'info':'active')+'">'+(ec.extractMode||'DIRECT')+'</span></td>' +
'<td><span class="badge active">'+(ec.extractType||'INCREMENTAL')+'</span></td>' +
'<td>'+(ec.isEnabled ? '✓' : '✗')+'</td>' +
'<td class="actions">' +
'<button class="btn btn-sm btn-outline" onclick="openExtractModal('+ec.id+')">编辑</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteExtract('+ec.id+')">删除</button></td></tr>';
}
h += '</tbody></table>';
document.getElementById('extract-list').innerHTML = h;
} catch(e) { document.getElementById('extract-loading').style.display = 'none'; toast(e.message, 'error'); }
}
function extractFormHTML(d) {
d = d || {};
return '<div class="form-row"><div class="form-group"><label>所属业务</label><input id="f-ebiz" value="'+esc(d.businessCode||v('extract-biz-select')||'')+'" readonly style="background:#f5f5f5"></div>' +
'<div class="form-group"><label>所属报表</label><input id="f-erep" value="'+esc(d.reportCode||v('extract-report-select')||'')+'" readonly style="background:#f5f5f5"></div></div>' +
'<div class="form-row"><div class="form-group"><label>抽取编码 *</label><input id="f-ecode" value="'+esc(d.extractCode||'')+'"></div>' +
'<div class="form-group"><label>抽取名称 *</label><input id="f-ename" value="'+esc(d.extractName||'')+'"></div></div>' +
'<div class="form-row"><div class="form-group"><label>源表名 *</label><input id="f-esrc" value="'+esc(d.sourceTableName||'')+'"></div>' +
'<div class="form-group"><label>源表别名</label><input id="f-esrca" value="'+esc(d.sourceTableAlias||'')+'"></div></div>' +
'<div class="form-row"><div class="form-group"><label>目标表名 *</label><input id="f-etgt" value="'+esc(d.targetTableName||'')+'"></div>' +
'<div class="form-group"><label>增量依据字段</label><input id="f-ekey" value="'+esc(d.extractKeyField||'')+'"></div></div>' +
'<div class="form-row-3"><div class="form-group"><label>抽取模式</label><select id="f-emode"><option value="DIRECT"'+(d.extractMode==='DIRECT'||!d.extractMode?' selected':'')+'>DIRECT</option><option value="AGGREGATE"'+(d.extractMode==='AGGREGATE'?' selected':'')+'>AGGREGATE</option></select></div>' +
'<div class="form-group"><label>抽取类型</label><select id="f-etype"><option value="INCREMENTAL"'+(d.extractType==='INCREMENTAL'||!d.extractType?' selected':'')+'>增量(INCREMENTAL)</option><option value="FULL"'+(d.extractType==='FULL'?' selected':'')+'>全量(FULL)</option></select></div>' +
'<div class="form-group"><label>批大小</label><input id="f-ebatch" type="number" value="'+(d.batchSize||1000)+'"></div></div>' +
'<div class="form-group"><label>GROUP BY 字段(逗号分隔)</label><input id="f-egroup" value="'+esc((d.groupByFields||[]).join(','))+'"></div>' +
'<div class="form-group"><label>过滤表达式</label><input id="f-efilter" value="'+esc(d.filterExpression||'')+'" placeholder="如 o.status != \'CANCELLED\'"></div>' +
'<div class="form-group"><label>操作人</label><input id="f-eop" value="'+esc(d.creator||'admin')+'"></div>' +
'<div class="form-row"><div class="form-group"><label>状态</label><select id="f-es"><option value="ACTIVE"'+((d.status||'ACTIVE')==='ACTIVE'?' selected':'')+'>启用</option><option value="INACTIVE"'+(d.status==='INACTIVE'?' selected':'')+'>停用</option></select></div>' +
'<div class="form-group"><label><input type="checkbox" id="f-eenabled" '+((d.isEnabled===undefined||d.isEnabled===true)?'checked':'')+'> 启用抽取</label></div></div>';
}
async function openExtractModal(id) {
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
const footer = document.getElementById('modalFooter');
if (id) {
try {
const res = await api('GET', '/report/extractConfig?id=' + id);
const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
console.log('[ExtractEdit] id=' + id + ' raw:', res, 'data:', d);
if (!d || !d.id) { toast('未获取到抽取配置数据ID='+id+'', 'error'); return; }
title.textContent = '编辑抽取配置';
body.innerHTML = extractFormHTML(d);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveExtract('+id+')">保存</button>';
} catch(e) { toast(e.message, 'error'); return; }
} else {
title.textContent = '新建抽取配置';
body.innerHTML = extractFormHTML(null);
footer.innerHTML = '<button class="btn btn-outline" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="saveExtract(0)">创建</button>';
}
document.getElementById('modalOverlay').classList.add('open');
document.getElementById('modal').classList.add('open');
}
async function saveExtract(id) {
const body = {
businessCode: v('f-ebiz'), reportCode: v('f-erep'),
extractCode: v('f-ecode'), extractName: v('f-ename'),
sourceTableName: v('f-esrc'), sourceTableAlias: v('f-esrca'),
targetTableName: v('f-etgt'), extractKeyField: v('f-ekey'),
extractMode: v('f-emode'), extractType: v('f-etype'),
batchSize: parseInt(v('f-ebatch'))||1000,
groupByFields: v('f-egroup').split(',').map(s => s.trim()).filter(Boolean),
filterExpression: v('f-efilter'), operator: v('f-eop'),
status: v('f-es'), isEnabled: document.getElementById('f-eenabled').checked,
};
if (id) body.id = id;
try {
await api('POST', '/report/extractConfig/save', body);
toast(id ? '更新成功' : '创建成功', 'success');
closeModal(); loadExtractList();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteExtract(id) {
if (!confirm('确定删除此抽取配置?')) return;
try { await api('DELETE', '/report/extractConfig?id='+id); toast('删除成功', 'success'); loadExtractList(); }
catch(e) { toast(e.message, 'error'); }
}
async function execExtract() {
const bc = v('extract-biz-select');
const rc = v('extract-report-select');
if (!bc || !rc) { toast('请先选择业务和报表', 'error'); return; }
const date = prompt('请输入统计日期 (yyyy-MM-dd)', new Date().toISOString().slice(0,10));
if (!date) return;
try {
const res = await api('POST', '/report/extract', { businessCode: bc, reportCode: rc, statDate: date, executor: 'admin' });
toast('抽取完成!成功: ' + res.successCount + ' 失败: ' + res.failCount + ' 耗时: ' + res.execTimeMs + 'ms', res.success ? 'success' : 'error');
} catch(e) { toast(e.message, 'error'); }
}
// ==================== 数据查询 ====================
let queryCtx = { dimensions: [], indicators: [], filters: [], availableFields: null };
async function onQueryBizChange() {
const bc = v('query-biz-select');
const rs = document.getElementById('query-report-select');
rs.innerHTML = '<option value="">-- 选择报表 --</option>';
queryCtx = { dimensions: [], indicators: [], filters: [], availableFields: null };
if (!bc) return;
try {
const data = await api('GET', '/report/reports?businessCode=' + bc);
rs.innerHTML = '<option value="">-- 选择报表 --</option>' + (data.list||[]).map(r => '<option value="'+r.reportCode+'">'+esc(r.reportName)+' ('+r.reportCode+')</option>').join('');
} catch(e) { toast(e.message, 'error'); }
}
async function loadQueryFields() {
const bc = v('query-biz-select');
const rc = v('query-report-select');
if (!bc || !rc) {
document.getElementById('query-content').style.display = 'none';
document.getElementById('query-empty').style.display = 'block';
return;
}
try {
const data = await api('GET', '/report/fields?businessCode=' + bc + '&reportCode=' + rc);
queryCtx.availableFields = {
dimensions: data.dimensions || [],
indicators: data.indicators || [],
filters: data.filters || []
};
queryCtx.dimensions = [];
queryCtx.indicators = [];
queryCtx.filters = [];
renderQueryUI();
document.getElementById('query-content').style.display = 'block';
document.getElementById('query-empty').style.display = 'none';
document.getElementById('query-result').style.display = 'none';
} catch(e) { toast(e.message, 'error'); }
}
function renderQueryUI() {
const f = queryCtx.availableFields;
if (!f) return;
// 维度
let dimHtml = '';
for (const d of f.dimensions) {
const sel = queryCtx.dimensions.includes(d.fieldCode);
dimHtml += '<span class="chip'+(sel?' selected':'')+'" onclick="toggleDim(\''+jsAttr(d.fieldCode)+'\')">'+esc(d.fieldName||d.fieldCode)+'</span>';
}
document.getElementById('dimension-chips').innerHTML = dimHtml;
// 指标
let indHtml = '';
for (const d of f.indicators) {
const sel = queryCtx.indicators.find(i => i.fieldCode === d.fieldCode);
indHtml += '<span class="chip'+(sel?' selected':'')+'" onclick="toggleInd(\''+jsAttr(d.fieldCode)+'\', \''+jsAttr(d.defaultAggregate||'SUM')+'\', \''+jsAttr(d.fieldName||d.fieldCode)+'\')">'+esc(d.fieldName||d.fieldCode)+(d.unit?'('+d.unit+')':'')+'</span>';
}
document.getElementById('indicator-area').innerHTML = indHtml;
renderSelectedIndicators();
// 筛选
let filHtml = '';
for (const d of [...f.filters, ...f.indicators, ...f.dimensions]) {
filHtml += '<span class="chip" onclick="addFilter(\''+jsAttr(d.fieldCode)+'\', \''+jsAttr(d.fieldName||d.fieldCode)+'\')">'+esc(d.fieldName||d.fieldCode)+'</span>';
}
document.getElementById('filter-area').innerHTML = filHtml;
// 默认时间范围
if (!document.getElementById('query-start-date').value) {
const today = new Date().toISOString().slice(0,10);
const weekAgo = new Date(Date.now() - 7*86400000).toISOString().slice(0,10);
document.getElementById('query-start-date').value = weekAgo;
document.getElementById('query-end-date').value = today;
}
}
function toggleDim(code) {
const idx = queryCtx.dimensions.indexOf(code);
if (idx >= 0) queryCtx.dimensions.splice(idx, 1);
else queryCtx.dimensions.push(code);
renderQueryUI();
}
function toggleInd(code, defAggr, name) {
const idx = queryCtx.indicators.findIndex(i => i.fieldCode === code);
if (idx >= 0) { queryCtx.indicators.splice(idx, 1); renderSelectedIndicators(); renderQueryUI(); return; }
const aggr = prompt('选择聚合方式 (SUM/COUNT/AVG/MAX/MIN)', defAggr||'SUM');
if (!aggr) return;
queryCtx.indicators.push({ fieldCode: code, aggregate: aggr.toUpperCase(), alias: code + '_' + aggr.toLowerCase() });
renderSelectedIndicators();
renderQueryUI();
}
function renderSelectedIndicators() {
if (queryCtx.indicators.length === 0) {
document.getElementById('selected-indicators').innerHTML = '';
return;
}
let h = '<div style="font-size:12px;color:#666;margin-bottom:4px">已选指标:</div>';
for (const ind of queryCtx.indicators) {
h += '<span class="field-tag">'+esc(ind.fieldCode)+' ('+ind.aggregate+') <span class="remove" onclick="removeInd(\''+jsAttr(ind.fieldCode)+'\')">×</span></span>';
}
document.getElementById('selected-indicators').innerHTML = h;
}
function removeInd(code) {
queryCtx.indicators = queryCtx.indicators.filter(i => i.fieldCode !== code);
renderSelectedIndicators();
renderQueryUI();
}
let filterCounter = 0;
function addFilter(code, name) {
filterCounter++;
const area = document.getElementById('filter-area');
const ops = ['=','!=','>','<','>=','<=','IN','LIKE','BETWEEN'];
const isBetween = ops[ops.length-1];
const row = document.createElement('div');
row.className = 'add-filter-row';
row.id = 'filter-row-'+filterCounter;
row.innerHTML = '<select id="fcol-'+filterCounter+'" style="padding:6px 8px;border:1px solid #d9d9d9;border-radius:6px;font-size:11px"><option value="'+esc(code)+'">'+esc(name)+' ('+esc(code)+')</option></select>' +
'<select id="fop-'+filterCounter+'" style="padding:6px 8px;border:1px solid #d9d9d9;border-radius:6px;font-size:11px" onchange="onFilterOpChange('+filterCounter+')">'+ops.map(o=>'<option value="'+o+'">'+o+'</option>').join('')+'</select>' +
'<input id="fval-'+filterCounter+'" style="padding:6px 8px;border:1px solid #d9d9d9;border-radius:6px;font-size:11px" placeholder="值">' +
'<button class="btn btn-sm btn-danger" style="padding:4px 8px" onclick="removeFilter('+filterCounter+')">×</button>';
area.appendChild(row);
}
function onFilterOpChange(idx) {
const op = document.getElementById('fop-'+idx).value;
const valEl = document.getElementById('fval-'+idx);
if (op === 'BETWEEN') valEl.placeholder = '值1,值2';
else if (op === 'IN') valEl.placeholder = '逗号分隔';
else valEl.placeholder = '值';
}
function removeFilter(idx) {
const row = document.getElementById('filter-row-'+idx);
if (row) row.remove();
}
async function executeQuery() {
const bc = v('query-biz-select');
const rc = v('query-report-select');
if (!bc || !rc) { toast('请先选择业务和报表', 'error'); return; }
const filters = [];
document.querySelectorAll('[id^="filter-row-"]').forEach(row => {
const m = row.id.match(/filter-row-(\d+)/);
if (!m) return;
const cid = m[1];
const colEl = document.getElementById('fcol-'+cid);
const opEl = document.getElementById('fop-'+cid);
const valEl = document.getElementById('fval-'+cid);
if (!colEl || !opEl || !valEl || !valEl.value.trim()) return;
const op = opEl.value;
let val = valEl.value.trim();
let val2 = null;
if (op === 'BETWEEN') {
const parts = val.split(',').map(s => s.trim());
if (parts.length >= 2) { val = parts[0]; val2 = parts[1]; }
else return;
} else if (op === 'IN') {
val = val.split(',').map(s => s.trim());
}
const f = { fieldCode: colEl.value, operator: op, value: val };
if (val2 !== null) f.value2 = val2;
filters.push(f);
});
const body = {
businessCode: bc, reportCode: rc,
dimensions: queryCtx.dimensions,
indicators: queryCtx.indicators,
filters: filters,
timeRange: { startDate: v('query-start-date'), endDate: v('query-end-date') },
orderBy: v('query-order-field') ? [{ fieldCode: v('query-order-field'), direction: v('query-order-dir') }] : [],
page: 1, pageSize: parseInt(v('query-page-size'))||20,
};
const resultBox = document.getElementById('query-result');
resultBox.style.display = 'block';
resultBox.textContent = '查询中...';
try {
const res = await api('POST', '/report/query', body);
let output = '总记录数: ' + res.total + ' | 执行耗时: ' + res.execTimeMs + 'ms\n';
if (res.sql) output += '生成SQL: ' + res.sql + '\n';
output += '\n';
if (res.list && res.list.length > 0) {
// 表头
const keys = Object.keys(res.list[0]);
output += keys.join('\t') + '\n';
output += keys.map(() => '---').join('\t') + '\n';
for (const row of res.list) {
output += keys.map(k => row[k] != null ? String(row[k]) : '').join('\t') + '\n';
}
} else {
output += '(无数据)';
}
resultBox.textContent = output;
} catch(e) { resultBox.textContent = '查询失败: ' + e.message; }
}
// ==================== 通用 ====================
function closeModal() {
document.getElementById('modalOverlay').classList.remove('open');
document.getElementById('modal').classList.remove('open');
}
// 初始化
loadBizList();
loadBizSelects();
</script>
</body>
</html>`