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

View File

@ -5,8 +5,6 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -92,7 +90,7 @@ func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return 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()) 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 { if err != nil {
w.WriteHeader(http.StatusInternalServerError) 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) { 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) 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 m = map[string]interface{}{}
var jid uint64
var templateID uint64
var status string
var requestedBy uint64
var totalRows sql.NullInt64 var totalRows sql.NullInt64
var fileFormat string
var startedAt, finishedAt sql.NullTime var startedAt, finishedAt sql.NullTime
var createdAt, updatedAt time.Time 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 { if err != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found")) w.Write([]byte("not found"))
return 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["total_rows"] = totalRows.Int64
m["started_at"] = startedAt.Time m["started_at"] = startedAt.Time
m["finished_at"] = finishedAt.Time m["finished_at"] = finishedAt.Time
@ -259,4 +267,3 @@ func toString(v interface{}) string {
return "" 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) { 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) 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 m = map[string]interface{}{}
var tid uint64
var name, datasource, mainTable, fileFormat, visibility string
var ownerID uint64
var enabled int var enabled int
var explainScore sql.NullInt64 var explainScore sql.NullInt64
var lastValidatedAt sql.NullTime var lastValidatedAt sql.NullTime
var createdAt, updatedAt time.Time var createdAt, updatedAt time.Time
var fields, filters []byte 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 { if err != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found")) w.Write([]byte("not found"))
return 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["enabled"] = enabled == 1
m["explain_score"] = explainScore.Int64 m["explain_score"] = explainScore.Int64
m["last_validated_at"] = lastValidatedAt.Time 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>MarketingSystemDataTool</title> <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"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<header class="navbar navbar-dark bg-dark"> <div id="app">
<div class="container-fluid"> <el-container>
<span class="navbar-brand mb-0 h1">导出工具</span> <el-header height="56px">
</div> <el-row align="middle" justify="space-between">
</header> <el-col :span="8">
<main class="container py-3"> <div class="title">导出工具</div>
<div class="row g-3"> </el-col>
<div class="col-12 col-lg-7"> </el-row>
<div class="card"> </el-header>
<div class="card-header">模板列表</div> <el-main>
<div class="card-body" id="templates"></div> <el-row :gutter="16">
</div> <el-col :span="14">
</div> <el-card header="模板列表">
<div class="col-12 col-lg-5"> <el-table :data="templates" size="small" stripe>
<div class="card"> <el-table-column prop="id" label="ID" width="80" />
<div class="card-header">新增模板</div> <el-table-column prop="name" label="名称" />
<div class="card-body"> <el-table-column prop="datasource" label="数据源" width="120" />
<form id="tpl-form" class="row g-3"> <el-table-column prop="file_format" label="格式" width="100" />
<div class="col-12"> <el-table-column prop="explain_score" label="评分" width="100" />
<label class="form-label">模板名称</label> <el-table-column label="操作" width="140">
<input class="form-control" name="name" placeholder="模板名称" required> <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>
<div class="col-6"> <div v-else>暂无任务</div>
<label class="form-label">数据源</label> </el-card>
<select class="form-select" name="datasource"> </el-col>
<option value="marketing">营销系统</option> </el-row>
<option value="ymt">易码通</option> </el-main>
</select> </el-container>
</div> </div>
<div class="col-6"> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<label class="form-label">主表</label> <script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<input class="form-control" name="main_table" value="order" required> <script src="/main.js"></script>
</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>
</body> </body>
</html> </html>

View File

@ -1,57 +1,64 @@
async function loadTemplates(){ const { createApp, reactive } = Vue;
const res=await fetch('/api/templates'); const app = createApp({
const data=await res.json(); setup(){
const el=document.getElementById('templates'); const state = reactive({
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(''); templates: [],
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>`; job: {},
document.querySelectorAll('button.export').forEach(b=>b.onclick=async()=>{ form: {
const id=b.getAttribute('data-id'); name: '',
const payload={template_id:Number(id),requested_by:1,permission:{},options:{},file_format:'csv'}; datasource: 'marketing',
const r=await fetch('/api/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); main_table: 'order',
const j=await r.json(); fieldsRaw: 'order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time',
loadJob(j.id); creatorRaw: '',
}); timeRange: [],
} file_format: 'csv',
visibility: 'private',
async function loadJob(id){ owner_id: '1'
const res=await fetch('/api/exports/'+id); }
const j=await res.json(); })
const el=document.getElementById('jobs'); const msg = (t, type='success')=>ElementPlus.ElMessage({message:t,type});
const link=j.files&&j.files.length?`<a class="btn btn-success btn-sm" href="/api/exports/${id}/download" target="_blank">下载</a>`:''; const fmtDT = (d)=>{
el.innerHTML=`<div class="alert alert-info">任务 ${id} 状态:<strong>${j.status}</strong> 行数:${j.total_rows||''} ${link}</div>`; 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())}`;
}
document.getElementById('tpl-form').onsubmit=async(e)=>{ const loadTemplates = async ()=>{
e.preventDefault(); const res = await fetch('/api/templates');
const fd=new FormData(e.target); state.templates = await res.json();
const fields=fd.get('fields').split(',').map(s=>s.trim()); }
const filters={}; const createTemplate = async ()=>{
const creators=fd.get('creator_in'); const fields = state.form.fieldsRaw.split(',').map(s=>s.trim()).filter(Boolean);
if(creators){filters.creator_in=creators.split(',').map(s=>Number(s.trim()))} const filters = {};
const tb=fd.get('time_begin'); if(state.form.creatorRaw){ filters.creator_in = state.form.creatorRaw.split(',').map(s=>Number(s.trim())).filter(x=>!isNaN(x)) }
const te=fd.get('time_end'); if(state.form.timeRange && state.form.timeRange.length===2){
if(tb&&te){ filters.create_time_between = [fmtDT(new Date(state.form.timeRange[0])), fmtDT(new Date(state.form.timeRange[1]))]
const fmt=(s)=>s.replace('T',' ')+':00'; }
filters.create_time_between=[fmt(tb),fmt(te)] 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'), app.use(ElementPlus)
datasource:fd.get('datasource'), app.mount('#app')
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();