feat(web): 重构前端使用 Vue 3 和 Element Plus 实现

- 将前端技术栈从原生 JS + Bootstrap 迁移至 Vue 3 + Element Plus
- 实现响应式数据绑定和组件化开发
- 优化表单交互体验,增加日期范围选择器等组件
- 更新项目文档说明前端技术栈变更
- 重构 API 数据处理逻辑,使用 reactive 状态管理
This commit is contained in:
zhouyonggao 2025-11-24 17:41:21 +08:00
parent e7eff92b02
commit 7bc0c54e1d
6 changed files with 174 additions and 143 deletions

View File

@ -2,14 +2,14 @@
## 1. 项目概述
- 技术栈:后端使用 `Go`,前端使用 `HTML + CSS + JS`前后端代码同仓管理。
- 技术栈:后端使用 `Go`,前端使用 `Vue 3 + Element Plus`(通过 CDN 引入,无打包);前后端代码同仓管理。
- 目标:为“营销系统”和“易码通系统”提供统一的高性能数据导出能力,支持模板化 SQL 构建与权限控制,生成 `CSV``Excel` 文件。
- 数据源:至少包含两个独立库(`marketing_db`、`ymt_db`),通过环境变量配置连接与凭据。
## 2. 目录结构与命名约定
- `server/`Go 服务端代码API、模板校验、导出执行、权限校验、日志与监控
- `web/`:前端页面与静态资源(模板管理、导出发起、进度查看、历史下载)。
- `web/`:前端页面与静态资源(模板管理、导出发起、进度查看、历史下载),采用 `Vue 3 + Element Plus` 组件搭建
- `config/`:非敏感配置(字段白名单、场景定义、关联关系元数据)。敏感信息使用环境变量注入。
- `scripts/`:开发与运维脚本(如生成索引建议、批量校验 EXPLAIN
- 命名规范:统一使用下划线或小驼峰,文件名见名知意;避免缩写导致歧义。
@ -93,8 +93,9 @@
- `GET /api/exports/{id}` 进度与指标;`GET /api/exports/{id}/download` 下载文件。
- `POST /api/exports/{id}/cancel` 取消任务。
- 前端约定:
- 使用 `Vue 3 + Element Plus`,通过 CDN 加载:`vue@3` 与 `element-plus`;页面在 `web/index.html` 中挂载。
- 统一通过权限范围参数(如 `user_id IN (...)`)传递查询边界;由后端注入到 SQL。
- 所有下拉/选择项来自白名单与元数据;禁止自由输入列名与表名。
- 所有下拉/选择项来自白名单与元数据;禁止自由输入列名与表名;表单与日期选择使用 Element Plus 组件
## 10. 性能与稳定性

View File

@ -5,8 +5,6 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -92,7 +90,7 @@ func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(err.Error()))
return
}
_, _ = exporter.RunExplain(a.marketing, q, args)
_, _, _ = exporter.RunExplain(a.marketing, q, args)
res, err := a.meta.Exec("INSERT INTO export_jobs (template_id, status, requested_by, permission_scope_json, options_json, file_format, created_at) VALUES (?,?,?,?,?,?,?)", p.TemplateID, "queued", p.RequestedBy, toJSON(p.Permission), toJSON(p.Options), p.FileFormat, time.Now())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@ -197,15 +195,25 @@ func (a *ExportsAPI) runJob(id uint64, q string, args []interface{}, cols []stri
func (a *ExportsAPI) get(w http.ResponseWriter, r *http.Request, id string) {
row := a.meta.QueryRow("SELECT id, template_id, status, requested_by, total_rows, file_format, started_at, finished_at, created_at, updated_at FROM export_jobs WHERE id=?", id)
var m = map[string]interface{}{}
var jid uint64
var templateID uint64
var status string
var requestedBy uint64
var totalRows sql.NullInt64
var fileFormat string
var startedAt, finishedAt sql.NullTime
var createdAt, updatedAt time.Time
err := row.Scan(&m["id"], &m["template_id"], &m["status"], &m["requested_by"], &totalRows, &m["file_format"], &startedAt, &finishedAt, &createdAt, &updatedAt)
err := row.Scan(&jid, &templateID, &status, &requestedBy, &totalRows, &fileFormat, &startedAt, &finishedAt, &createdAt, &updatedAt)
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
m["id"] = jid
m["template_id"] = templateID
m["status"] = status
m["requested_by"] = requestedBy
m["file_format"] = fileFormat
m["total_rows"] = totalRows.Int64
m["started_at"] = startedAt.Time
m["finished_at"] = finishedAt.Time
@ -259,4 +267,3 @@ func toString(v interface{}) string {
return ""
}
}

View File

@ -136,17 +136,27 @@ func (a *TemplatesAPI) listTemplates(w http.ResponseWriter, r *http.Request) {
func (a *TemplatesAPI) getTemplate(w http.ResponseWriter, r *http.Request, id string) {
row := a.meta.QueryRow("SELECT id,name,datasource,main_table,fields_json,filters_json,file_format,visibility,owner_id,enabled,explain_score,last_validated_at,created_at,updated_at FROM export_templates WHERE id=?", id)
var m = map[string]interface{}{}
var tid uint64
var name, datasource, mainTable, fileFormat, visibility string
var ownerID uint64
var enabled int
var explainScore sql.NullInt64
var lastValidatedAt sql.NullTime
var createdAt, updatedAt time.Time
var fields, filters []byte
err := row.Scan(&m["id"], &m["name"], &m["datasource"], &m["main_table"], &fields, &filters, &m["file_format"], &m["visibility"], &m["owner_id"], &enabled, &explainScore, &lastValidatedAt, &createdAt, &updatedAt)
err := row.Scan(&tid, &name, &datasource, &mainTable, &fields, &filters, &fileFormat, &visibility, &ownerID, &enabled, &explainScore, &lastValidatedAt, &createdAt, &updatedAt)
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
m["id"] = tid
m["name"] = name
m["datasource"] = datasource
m["main_table"] = mainTable
m["file_format"] = fileFormat
m["visibility"] = visibility
m["owner_id"] = ownerID
m["enabled"] = enabled == 1
m["explain_score"] = explainScore.Int64
m["last_validated_at"] = lastValidatedAt.Time

BIN
server/server Executable file

Binary file not shown.

View File

@ -4,87 +4,93 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MarketingSystemDataTool</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="navbar navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">导出工具</span>
</div>
</header>
<main class="container py-3">
<div class="row g-3">
<div class="col-12 col-lg-7">
<div class="card">
<div class="card-header">模板列表</div>
<div class="card-body" id="templates"></div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card">
<div class="card-header">新增模板</div>
<div class="card-body">
<form id="tpl-form" class="row g-3">
<div class="col-12">
<label class="form-label">模板名称</label>
<input class="form-control" name="name" placeholder="模板名称" required>
<div id="app">
<el-container>
<el-header height="56px">
<el-row align="middle" justify="space-between">
<el-col :span="8">
<div class="title">导出工具</div>
</el-col>
</el-row>
</el-header>
<el-main>
<el-row :gutter="16">
<el-col :span="14">
<el-card header="模板列表">
<el-table :data="templates" size="small" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="datasource" label="数据源" width="120" />
<el-table-column prop="file_format" label="格式" width="100" />
<el-table-column prop="explain_score" label="评分" width="100" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button size="small" type="primary" @click="runExport(scope.row.id)">执行导出</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="10">
<el-card header="新增模板">
<el-form :model="form" label-width="110px">
<el-form-item label="模板名称"><el-input v-model="form.name" placeholder="模板名称" /></el-form-item>
<el-form-item label="数据源">
<el-select v-model="form.datasource" placeholder="选择">
<el-option label="营销系统" value="marketing" />
<el-option label="易码通" value="ymt" />
</el-select>
</el-form-item>
<el-form-item label="主表"><el-input v-model="form.main_table" /></el-form-item>
<el-form-item label="字段(逗号分隔)"><el-input v-model="form.fieldsRaw" /></el-form-item>
<el-form-item label="creator 列表"><el-input v-model="form.creatorRaw" placeholder="如1,2,3" /></el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="form.timeRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" />
</el-form-item>
<el-row :gutter="8">
<el-col :span="12">
<el-form-item label="输出格式">
<el-select v-model="form.file_format">
<el-option label="CSV" value="csv" />
<el-option label="XLSX" value="xlsx" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可见性">
<el-select v-model="form.visibility">
<el-option label="个人" value="private" />
<el-option label="公共" value="public" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="所有者ID"><el-input v-model="form.owner_id" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="createTemplate">创建并校验</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="24">
<el-card header="导出任务">
<div v-if="job.id" class="job">
任务 {{ job.id }} 状态:<b>{{ job.status }}</b> 行数:{{ job.total_rows || '' }}
<el-button v-if="job.files && job.files.length" type="success" size="small" @click="download(job.id)">下载</el-button>
</div>
<div class="col-6">
<label class="form-label">数据源</label>
<select class="form-select" name="datasource">
<option value="marketing">营销系统</option>
<option value="ymt">易码通</option>
</select>
</div>
<div class="col-6">
<label class="form-label">主表</label>
<input class="form-control" name="main_table" value="order" required>
</div>
<div class="col-12">
<label class="form-label">字段(逗号分隔)</label>
<input class="form-control" name="fields" value="order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time">
</div>
<div class="col-12">
<label class="form-label">权限范围creator 列表(逗号分隔)</label>
<input class="form-control" name="creator_in" placeholder="如1,2,3">
</div>
<div class="col-6">
<label class="form-label">开始时间</label>
<input class="form-control" type="datetime-local" name="time_begin">
</div>
<div class="col-6">
<label class="form-label">结束时间</label>
<input class="form-control" type="datetime-local" name="time_end">
</div>
<div class="col-6">
<label class="form-label">输出格式</label>
<select class="form-select" name="file_format"><option value="csv">CSV</option><option value="xlsx">XLSX</option></select>
</div>
<div class="col-6">
<label class="form-label">可见性</label>
<select class="form-select" name="visibility"><option value="private">个人</option><option value="public">公共</option></select>
</div>
<div class="col-6">
<label class="form-label">所有者ID</label>
<input class="form-control" name="owner_id" value="1">
</div>
<div class="col-12 d-grid">
<button class="btn btn-primary" type="submit">创建并校验</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header">导出任务</div>
<div class="card-body" id="jobs"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/main.js"></script>
</main>
<div v-else>暂无任务</div>
</el-card>
</el-col>
</el-row>
</el-main>
</el-container>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<script src="/main.js"></script>
</body>
</html>

View File

@ -1,57 +1,64 @@
async function loadTemplates(){
const res=await fetch('/api/templates');
const data=await res.json();
const el=document.getElementById('templates');
const rows=data.map(t=>`<tr><td>${t.id}</td><td>${t.name}</td><td>${t.datasource}</td><td>${t.file_format}</td><td>${t.explain_score||''}</td><td><button data-id="${t.id}" class="btn btn-primary btn-sm export">执行导出</button></td></tr>`).join('');
el.innerHTML=`<table class="table table-striped table-sm"><thead><tr><th>ID</th><th>名称</th><th>数据源</th><th>格式</th><th>EXPLAIN评分</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
document.querySelectorAll('button.export').forEach(b=>b.onclick=async()=>{
const id=b.getAttribute('data-id');
const payload={template_id:Number(id),requested_by:1,permission:{},options:{},file_format:'csv'};
const r=await fetch('/api/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const j=await r.json();
loadJob(j.id);
});
}
async function loadJob(id){
const res=await fetch('/api/exports/'+id);
const j=await res.json();
const el=document.getElementById('jobs');
const link=j.files&&j.files.length?`<a class="btn btn-success btn-sm" href="/api/exports/${id}/download" target="_blank">下载</a>`:'';
el.innerHTML=`<div class="alert alert-info">任务 ${id} 状态:<strong>${j.status}</strong> 行数:${j.total_rows||''} ${link}</div>`;
}
document.getElementById('tpl-form').onsubmit=async(e)=>{
e.preventDefault();
const fd=new FormData(e.target);
const fields=fd.get('fields').split(',').map(s=>s.trim());
const filters={};
const creators=fd.get('creator_in');
if(creators){filters.creator_in=creators.split(',').map(s=>Number(s.trim()))}
const tb=fd.get('time_begin');
const te=fd.get('time_end');
if(tb&&te){
const fmt=(s)=>s.replace('T',' ')+':00';
filters.create_time_between=[fmt(tb),fmt(te)]
const { createApp, reactive } = Vue;
const app = createApp({
setup(){
const state = reactive({
templates: [],
job: {},
form: {
name: '',
datasource: 'marketing',
main_table: 'order',
fieldsRaw: 'order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time',
creatorRaw: '',
timeRange: [],
file_format: 'csv',
visibility: 'private',
owner_id: '1'
}
})
const msg = (t, type='success')=>ElementPlus.ElMessage({message:t,type});
const fmtDT = (d)=>{
const pad=(n)=>String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const loadTemplates = async ()=>{
const res = await fetch('/api/templates');
state.templates = await res.json();
}
const createTemplate = async ()=>{
const fields = state.form.fieldsRaw.split(',').map(s=>s.trim()).filter(Boolean);
const filters = {};
if(state.form.creatorRaw){ filters.creator_in = state.form.creatorRaw.split(',').map(s=>Number(s.trim())).filter(x=>!isNaN(x)) }
if(state.form.timeRange && state.form.timeRange.length===2){
filters.create_time_between = [fmtDT(new Date(state.form.timeRange[0])), fmtDT(new Date(state.form.timeRange[1]))]
}
const payload = {
name: state.form.name,
datasource: state.form.datasource,
main_table: state.form.main_table,
fields,
filters,
file_format: state.form.file_format,
owner_id: Number(state.form.owner_id),
visibility: state.form.visibility
}
const res = await fetch('/api/templates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
if(res.ok){ msg('创建成功'); loadTemplates() } else { msg(await res.text(),'error') }
}
const runExport = async (id)=>{
const payload={template_id:Number(id),requested_by:1,permission:{},options:{},file_format:'csv'};
const r=await fetch('/api/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const j=await r.json();
loadJob(j.id);
}
const loadJob = async (id)=>{
const res=await fetch('/api/exports/'+id);
state.job = await res.json();
}
const download = (id)=>{ window.open('/api/exports/'+id+'/download','_blank') }
loadTemplates()
return { ...state, loadTemplates, createTemplate, runExport, loadJob, download }
}
const payload={
name:fd.get('name'),
datasource:fd.get('datasource'),
main_table:fd.get('main_table'),
fields,
filters,
file_format:fd.get('file_format'),
owner_id:Number(fd.get('owner_id')),
visibility:fd.get('visibility')
};
const res=await fetch('/api/templates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
if(res.ok){
loadTemplates();
} else {
const t=await res.text();
const el=document.getElementById('templates');
el.insertAdjacentHTML('afterbegin',`<div class="alert alert-danger">${t}</div>`);
}
};
loadTemplates();
})
app.use(ElementPlus)
app.mount('#app')