diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index e75d9e3..858fe7e 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -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. 性能与稳定性 diff --git a/server/internal/api/exports.go b/server/internal/api/exports.go index ec06914..ddd7a97 100644 --- a/server/internal/api/exports.go +++ b/server/internal/api/exports.go @@ -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 "" } } - diff --git a/server/internal/api/templates.go b/server/internal/api/templates.go index 608bbbf..1e5201c 100644 --- a/server/internal/api/templates.go +++ b/server/internal/api/templates.go @@ -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 diff --git a/server/server b/server/server new file mode 100755 index 0000000..e1c1391 Binary files /dev/null and b/server/server differ diff --git a/web/index.html b/web/index.html index ea2abdc..b46a2d6 100644 --- a/web/index.html +++ b/web/index.html @@ -4,87 +4,93 @@ MarketingSystemDataTool - + - -
-
-
-
-
模板列表
-
-
-
-
-
-
新增模板
-
-
-
- - +
+ + + + +
导出工具
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 创建并校验 + + + + + + +
+ 任务 {{ job.id }} 状态:{{ job.status }} 行数:{{ job.total_rows || '' }} + 下载
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
-
-
-
导出任务
-
-
-
-
- - -
+
暂无任务
+ + + + + + + + + diff --git a/web/main.js b/web/main.js index 3d1364f..2870bb8 100644 --- a/web/main.js +++ b/web/main.js @@ -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=>`${t.id}${t.name}${t.datasource}${t.file_format}${t.explain_score||''}`).join(''); - el.innerHTML=`${rows}
ID名称数据源格式EXPLAIN评分操作
`; - 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?`下载`:''; - el.innerHTML=`
任务 ${id} 状态:${j.status} 行数:${j.total_rows||''} ${link}
`; -} - -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',`
${t}
`); - } -}; - -loadTemplates(); +}) +app.use(ElementPlus) +app.mount('#app')