Files
data-engine/controller/report/report_admin_controller.go
2026-06-11 13:06:54 +08:00

950 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>`