From d0e1488815f2bba73fe0e094dbe74166133b7614 Mon Sep 17 00:00:00 2001 From: zhouyonggao <1971162852@qq.com> Date: Tue, 25 Nov 2025 09:09:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在导出工具中新增模板删除功能 - 重构SQL构建器以支持多表关联查询 - 扩展字段白名单配置,支持更多业务字段 - 优化前端界面,新增模板编辑和导出配置对话框 - 改进静态文件路径查找逻辑 - 为导出任务添加执行分析和过滤条件支持 --- config/whitelist.json | 21 +- server/internal/api/exports.go | 34 ++-- server/internal/api/router.go | 14 +- server/internal/api/templates.go | 173 ++++++++++------ server/internal/exporter/sqlbuilder.go | 73 +++++-- server/internal/migrate/migrate.go | 11 ++ web/index.html | 186 +++++++++++++----- web/main.js | 262 +++++++++++++++++++++++-- 8 files changed, 595 insertions(+), 179 deletions(-) diff --git a/config/whitelist.json b/config/whitelist.json index d3ea8b7..94b1532 100644 --- a/config/whitelist.json +++ b/config/whitelist.json @@ -1,16 +1,15 @@ { "tables": ["order"], "fields": [ - "order.order_number", - "order.creator", - "order.out_trade_no", - "order.type", - "order.status", - "order.contract_price", - "order.num", - "order.total", - "order.pay_amount", - "order.create_time", - "order.update_time" + "order.order_number","order.creator","order.out_trade_no","order.type","order.status","order.contract_price","order.num","order.total","order.pay_amount","order.create_time","order.update_time", + "order_detail.plan_title","order_detail.reseller_name","order_detail.product_name","order_detail.show_url","order_detail.official_price","order_detail.cost_price","order_detail.create_time","order_detail.update_time", + "order_cash.channel","order_cash.cash_activity_id","order_cash.receive_status","order_cash.receive_time","order_cash.cash_packet_id","order_cash.cash_id","order_cash.amount","order_cash.status","order_cash.expire_time","order_cash.update_time", + "order_voucher.channel","order_voucher.channel_activity_id","order_voucher.channel_voucher_id","order_voucher.status","order_voucher.grant_time","order_voucher.usage_time","order_voucher.refund_time","order_voucher.status_modify_time","order_voucher.overdue_time","order_voucher.refund_amount","order_voucher.official_price","order_voucher.out_biz_no","order_voucher.account_no", + "plan.id","plan.title","plan.status","plan.begin_time","plan.end_time", + "key_batch.id","key_batch.batch_name","key_batch.bind_object","key_batch.quantity","key_batch.stock","key_batch.begin_time","key_batch.end_time", + "code_batch.id","code_batch.title","code_batch.status","code_batch.begin_time","code_batch.end_time","code_batch.quantity","code_batch.usage","code_batch.stock", + "voucher.channel","voucher.channel_activity_id","voucher.price","voucher.balance","voucher.used_amount","voucher.denomination", + "voucher_batch.channel_activity_id","voucher_batch.temp_no","voucher_batch.provider","voucher_batch.weight", + "merchant_key_send.merchant_id","merchant_key_send.out_biz_no","merchant_key_send.key","merchant_key_send.status","merchant_key_send.usage_time","merchant_key_send.create_time" ] } diff --git a/server/internal/api/exports.go b/server/internal/api/exports.go index ddd7a97..59c5dff 100644 --- a/server/internal/api/exports.go +++ b/server/internal/api/exports.go @@ -51,6 +51,7 @@ type ExportPayload struct{ Permission map[string]interface{} `json:"permission"` Options map[string]interface{} `json:"options"` FileFormat string `json:"file_format"` + Filters map[string]interface{} `json:"filters"` } func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) { @@ -58,40 +59,31 @@ func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) { var p ExportPayload json.Unmarshal(b, &p) var main string - var fields, filters []byte - row := a.meta.QueryRow("SELECT main_table, fields_json, filters_json FROM export_templates WHERE id=?", p.TemplateID) - err := row.Scan(&main, &fields, &filters) + var fields []byte + row := a.meta.QueryRow("SELECT main_table, fields_json FROM export_templates WHERE id=?", p.TemplateID) + err := row.Scan(&main, &fields) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid template")) return } var fs []string - var fl map[string]interface{} json.Unmarshal(fields, &fs) - json.Unmarshal(filters, &fl) - wl := map[string]bool{ - "order.order_number": true, - "order.creator": true, - "order.out_trade_no": true, - "order.type": true, - "order.status": true, - "order.contract_price": true, - "order.num": true, - "order.total": true, - "order.pay_amount": true, - "order.create_time": true, - "order.update_time": true, - } - req := exporter.BuildRequest{MainTable: main, Fields: fs, Filters: fl} + wl := whitelist() + req := exporter.BuildRequest{MainTable: main, Fields: fs, Filters: p.Filters} q, args, err := exporter.BuildSQL(req, wl) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } - _, _, _ = 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()) + expRows, score, err := exporter.RunExplain(a.marketing, q, args) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + res, err := a.meta.Exec("INSERT INTO export_jobs (template_id, status, requested_by, permission_scope_json, filters_json, options_json, explain_json, explain_score, file_format, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)", p.TemplateID, "queued", p.RequestedBy, toJSON(p.Permission), toJSON(p.Filters), toJSON(p.Options), toJSON(expRows), score, p.FileFormat, time.Now()) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index b89ae5d..f819a0e 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -3,6 +3,7 @@ package api import ( "database/sql" "net/http" + "os" ) func NewRouter(metaDB *sql.DB, marketingDB *sql.DB) http.Handler { @@ -11,6 +12,17 @@ func NewRouter(metaDB *sql.DB, marketingDB *sql.DB) http.Handler { mux.Handle("/api/templates/", TemplatesHandler(metaDB, marketingDB)) mux.Handle("/api/exports", ExportsHandler(metaDB, marketingDB)) mux.Handle("/api/exports/", ExportsHandler(metaDB, marketingDB)) - mux.Handle("/", http.FileServer(http.Dir("web"))) + sd := staticDir() + mux.Handle("/", http.FileServer(http.Dir(sd))) return mux } + +func staticDir() string { + if _, err := os.Stat("web/index.html"); err == nil { + return "web" + } + if _, err := os.Stat("../web/index.html"); err == nil { + return "../web" + } + return "web" +} diff --git a/server/internal/api/templates.go b/server/internal/api/templates.go index 1e5201c..54cbc1b 100644 --- a/server/internal/api/templates.go +++ b/server/internal/api/templates.go @@ -7,7 +7,6 @@ import ( "net/http" "strings" "time" - "marketing-system-data-tool/server/internal/exporter" ) type TemplatesAPI struct{ @@ -37,6 +36,10 @@ func TemplatesHandler(meta, marketing *sql.DB) http.Handler { api.patchTemplate(w, r, id) return } + if r.Method == http.MethodDelete { + api.deleteTemplate(w, r, id) + return + } if r.Method == http.MethodPost && strings.HasSuffix(p, "/validate") { id = strings.TrimSuffix(id, "/validate") api.validateTemplate(w, r, id) @@ -62,36 +65,10 @@ func (a *TemplatesAPI) createTemplate(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) var p TemplatePayload json.Unmarshal(b, &p) - wl := map[string]bool{ - "order.order_number": true, - "order.creator": true, - "order.out_trade_no": true, - "order.type": true, - "order.status": true, - "order.contract_price": true, - "order.num": true, - "order.total": true, - "order.pay_amount": true, - "order.create_time": true, - "order.update_time": true, - } - req := exporter.BuildRequest{MainTable: p.MainTable, Fields: p.Fields, Filters: p.Filters} - q, args, err := exporter.BuildSQL(req, wl) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } - _, score, err := exporter.RunExplain(a.marketing, q, args) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } now := time.Now() - _, err = a.meta.Exec( - "INSERT INTO export_templates (name, datasource, main_table, fields_json, filters_json, file_format, visibility, owner_id, enabled, explain_score, last_validated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)", - p.Name, p.Datasource, p.MainTable, toJSON(p.Fields), toJSON(p.Filters), p.FileFormat, p.Visibility, p.OwnerID, 1, score, now, + _, err := a.meta.Exec( + "INSERT INTO export_templates (name, datasource, main_table, fields_json, filters_json, file_format, visibility, owner_id, enabled, last_validated_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + p.Name, p.Datasource, p.MainTable, toJSON(p.Fields), nil, p.FileFormat, p.Visibility, p.OwnerID, 1, now, ) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -204,6 +181,24 @@ func (a *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, id w.Write([]byte("ok")) } +func (a *TemplatesAPI) deleteTemplate(w http.ResponseWriter, r *http.Request, id string) { + var cnt int64 + row := a.meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id=?", id) + _ = row.Scan(&cnt) + if cnt > 0 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("template in use")) + return + } + _, err := a.meta.Exec("DELETE FROM export_templates WHERE id=?", id) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write([]byte("ok")) +} + func (a *TemplatesAPI) validateTemplate(w http.ResponseWriter, r *http.Request, id string) { row := a.meta.QueryRow("SELECT main_table, fields_json, filters_json FROM export_templates WHERE id=?", id) var main string @@ -218,39 +213,7 @@ func (a *TemplatesAPI) validateTemplate(w http.ResponseWriter, r *http.Request, var fl map[string]interface{} json.Unmarshal(fields, &fs) json.Unmarshal(filters, &fl) - wl := map[string]bool{ - "order.order_number": true, - "order.creator": true, - "order.out_trade_no": true, - "order.type": true, - "order.status": true, - "order.contract_price": true, - "order.num": true, - "order.total": true, - "order.pay_amount": true, - "order.create_time": true, - "order.update_time": true, - } - req := exporter.BuildRequest{MainTable: main, Fields: fs, Filters: fl} - q, args, err := exporter.BuildSQL(req, wl) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } - _, score, err := exporter.RunExplain(a.marketing, q, args) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } - now := time.Now() - _, err = a.meta.Exec("UPDATE export_templates SET explain_score=?, last_validated_at=? WHERE id=?", score, now, id) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } + // 模板不再记录 EXPLAIN,返回成功 w.Write([]byte("ok")) } @@ -264,3 +227,87 @@ func fromJSON(b []byte) interface{} { json.Unmarshal(b, &v) return v } + +func whitelist() map[string]bool { + m := map[string]bool{ + "order.order_number": true, + "order.creator": true, + "order.out_trade_no": true, + "order.type": true, + "order.status": true, + "order.contract_price": true, + "order.num": true, + "order.total": true, + "order.pay_amount": true, + "order.create_time": true, + "order.update_time": true, + "order_detail.plan_title": true, + "order_detail.reseller_name": true, + "order_detail.product_name": true, + "order_detail.show_url": true, + "order_detail.official_price": true, + "order_detail.cost_price": true, + "order_detail.create_time": true, + "order_detail.update_time": true, + "order_cash.channel": true, + "order_cash.cash_activity_id": true, + "order_cash.receive_status": true, + "order_cash.receive_time": true, + "order_cash.cash_packet_id": true, + "order_cash.cash_id": true, + "order_cash.amount": true, + "order_cash.status": true, + "order_cash.expire_time": true, + "order_cash.update_time": true, + "order_voucher.channel": true, + "order_voucher.channel_activity_id": true, + "order_voucher.channel_voucher_id": true, + "order_voucher.status": true, + "order_voucher.grant_time": true, + "order_voucher.usage_time": true, + "order_voucher.refund_time": true, + "order_voucher.status_modify_time": true, + "order_voucher.overdue_time": true, + "order_voucher.refund_amount": true, + "order_voucher.official_price": true, + "order_voucher.out_biz_no": true, + "order_voucher.account_no": true, + "plan.id": true, + "plan.title": true, + "plan.status": true, + "plan.begin_time": true, + "plan.end_time": true, + "key_batch.id": true, + "key_batch.batch_name": true, + "key_batch.bind_object": true, + "key_batch.quantity": true, + "key_batch.stock": true, + "key_batch.begin_time": true, + "key_batch.end_time": true, + "code_batch.id": true, + "code_batch.title": true, + "code_batch.status": true, + "code_batch.begin_time": true, + "code_batch.end_time": true, + "code_batch.quantity": true, + "code_batch.usage": true, + "code_batch.stock": true, + "voucher.channel": true, + "voucher.channel_activity_id": true, + "voucher.price": true, + "voucher.balance": true, + "voucher.used_amount": true, + "voucher.denomination": true, + "voucher_batch.channel_activity_id": true, + "voucher_batch.temp_no": true, + "voucher_batch.provider": true, + "voucher_batch.weight": true, + "merchant_key_send.merchant_id": true, + "merchant_key_send.out_biz_no": true, + "merchant_key_send.key": true, + "merchant_key_send.status": true, + "merchant_key_send.usage_time": true, + "merchant_key_send.create_time": true, + } + return m +} diff --git a/server/internal/exporter/sqlbuilder.go b/server/internal/exporter/sqlbuilder.go index 5cbd5a5..094990f 100644 --- a/server/internal/exporter/sqlbuilder.go +++ b/server/internal/exporter/sqlbuilder.go @@ -8,7 +8,7 @@ import ( type BuildRequest struct { MainTable string - Fields []string + Fields []string // table.field Filters map[string]interface{} } @@ -17,12 +17,19 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{ return "", nil, errors.New("unsupported main table") } cols := []string{} - for _, f := range req.Fields { - if !whitelist["order."+f] { + need := map[string]bool{} + for _, tf := range req.Fields { + if !whitelist[tf] { return "", nil, errors.New("field not allowed") } - if f == "key" || req.MainTable == "order" { + parts := strings.Split(tf, ".") + if len(parts) != 2 { return "", nil, errors.New("invalid field format") } + t, f := parts[0], parts[1] + need[t] = true + if t == "order" { cols = append(cols, "`order`."+escape(f)) + } else { + cols = append(cols, "`"+t+"`."+escape(f)) } } if len(cols) == 0 { @@ -32,6 +39,26 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{ sb.WriteString("SELECT ") sb.WriteString(strings.Join(cols, ",")) sb.WriteString(" FROM `order`") + // JOINs based on need + // order_detail + if need["order_detail"] { sb.WriteString(" LEFT JOIN `order_detail` ON `order_detail`.order_number = `order`.order_number") } + // order_cash + if need["order_cash"] { sb.WriteString(" LEFT JOIN `order_cash` ON `order_cash`.order_number = `order`.order_number") } + // order_voucher + if need["order_voucher"] { sb.WriteString(" LEFT JOIN `order_voucher` ON `order_voucher`.order_number = `order`.order_number") } + // plan + if need["plan"] || need["key_batch"] { sb.WriteString(" LEFT JOIN `plan` ON `plan`.id = `order`.plan_id") } + // key_batch depends on plan + if need["key_batch"] { sb.WriteString(" LEFT JOIN `key_batch` ON `key_batch`.plan_id = `plan`.id") } + // code_batch depends on key_batch + if need["code_batch"] { sb.WriteString(" LEFT JOIN `code_batch` ON `code_batch`.key_batch_id = `key_batch`.id") } + // voucher depends on order_voucher + if need["voucher"] { sb.WriteString(" LEFT JOIN `voucher` ON `voucher`.channel_activity_id = `order_voucher`.channel_activity_id") } + // voucher_batch depends on voucher + if need["voucher_batch"] { sb.WriteString(" LEFT JOIN `voucher_batch` ON `voucher_batch`.voucher_id = `voucher`.id") } + // merchant_key_send depends on order.key + if need["merchant_key_send"] { sb.WriteString(" LEFT JOIN `merchant_key_send` ON `order`." + escape("key") + " = `merchant_key_send`.key") } + args := []interface{}{} where := []string{} if v, ok := req.Filters["creator_in"]; ok { @@ -40,34 +67,40 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{ case []interface{}: ids = t case []int: - for _, x := range t { - ids = append(ids, x) - } + for _, x := range t { ids = append(ids, x) } case []string: - for _, x := range t { - ids = append(ids, x) - } - } - if len(ids) == 0 { - return "", nil, errors.New("creator_in required") + for _, x := range t { ids = append(ids, x) } } + if len(ids) == 0 { return "", nil, errors.New("creator_in required") } ph := strings.Repeat("?,", len(ids)) ph = strings.TrimSuffix(ph, ",") where = append(where, "`order`.creator IN ("+ph+")") args = append(args, ids...) - } else { - return "", nil, errors.New("creator_in required") } if v, ok := req.Filters["create_time_between"]; ok { var arr []interface{} b, _ := json.Marshal(v) json.Unmarshal(b, &arr) - if len(arr) != 2 { - return "", nil, errors.New("create_time_between requires 2 values") - } + if len(arr) != 2 { return "", nil, errors.New("create_time_between requires 2 values") } where = append(where, "`order`.create_time BETWEEN ? AND ?") args = append(args, arr[0], arr[1]) } + if v, ok := req.Filters["type_eq"]; ok { + var tv int + switch t := v.(type) { + case float64: + tv = int(t) + case int: + tv = t + case string: + // simple digits parsing + for i := 0; i < len(t); i++ { c := t[i]; if c<'0'||c>'9' { continue }; tv = tv*10 + int(c-'0') } + } + if tv == 1 || tv == 2 || tv == 3 { + where = append(where, "`order`.type = ?") + args = append(args, tv) + } + } if len(where) > 0 { sb.WriteString(" WHERE ") sb.WriteString(strings.Join(where, " AND ")) @@ -76,8 +109,6 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{ } func escape(s string) string { - if s == "key" { - return "`key`" - } + if s == "key" { return "`key`" } return s } diff --git a/server/internal/migrate/migrate.go b/server/internal/migrate/migrate.go index f95ae0f..5aaf465 100644 --- a/server/internal/migrate/migrate.go +++ b/server/internal/migrate/migrate.go @@ -76,5 +76,16 @@ func Apply(db *sql.DB) error { return err } } + optional := []string{ + "ALTER TABLE export_jobs ADD COLUMN explain_json JSON", + "ALTER TABLE export_jobs ADD COLUMN explain_score INT", + "ALTER TABLE export_jobs ADD COLUMN filters_json JSON", + } + for _, s := range optional { + if _, err := db.Exec(s); err != nil { + // ignore if column exists or syntax not supported + continue + } + } return nil } diff --git a/web/index.html b/web/index.html index b46a2d6..af8a969 100644 --- a/web/index.html +++ b/web/index.html @@ -12,70 +12,37 @@ - +
导出工具
- - + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 创建并校验 - - - -
@@ -88,6 +55,123 @@ + + + + + + + + + + + + + + + + + 直充卡密 + 立减金 + 红包 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 直充卡密 + 立减金 + 红包 + + + + + + + + + + + + + + + + + + + +
diff --git a/web/main.js b/web/main.js index 2870bb8..85763f6 100644 --- a/web/main.js +++ b/web/main.js @@ -8,15 +8,200 @@ const app = createApp({ name: '', datasource: 'marketing', main_table: 'order', + orderType: 1, fieldsRaw: 'order_number,creator,out_trade_no,type,status,contract_price,num,total,pay_amount,create_time', + fieldsSel: [], creatorRaw: '', + permissionMode: 'all', timeRange: [], - file_format: 'csv', + file_format: 'xlsx', visibility: 'private', owner_id: '1' + }, + createVisible: false, + editVisible: false, + exportVisible: false, + createWidth: (localStorage.getItem('tplDialogWidth') || '900px'), + editWidth: (localStorage.getItem('tplEditDialogWidth') || '600px'), + edit: { id: null, name: '', visibility: 'private', file_format: 'csv' }, + export: { tplId: null, orderType: 1, permissionMode: 'all', creatorRaw: '', timeRange: [], file_format: 'xlsx' } + }) + const FIELDS_MAP = { + marketing: { + order: [ + { value: 'order_number', label: '订单编号' }, + { value: 'creator', label: '创建者ID' }, + { value: 'out_trade_no', label: '支付流水号' }, + { value: 'type', label: '订单类型' }, + { value: 'status', label: '订单状态' }, + { value: 'contract_price', label: '合同单价' }, + { value: 'num', label: '数量' }, + { value: 'total', label: '总金额' }, + { value: 'pay_amount', label: '支付金额' }, + { value: 'create_time', label: '创建时间' }, + { value: 'update_time', label: '更新时间' } + ] + , + order_detail: [ + { value: 'plan_title', label: '计划标题' }, + { value: 'reseller_name', label: '分销商名称' }, + { value: 'product_name', label: '商品名称' }, + { value: 'show_url', label: '商品图片URL' }, + { value: 'official_price', label: '官方价' }, + { value: 'cost_price', label: '成本价' }, + { value: 'create_time', label: '创建时间' }, + { value: 'update_time', label: '更新时间' } + ], + order_cash: [ + { value: 'channel', label: '渠道' }, + { value: 'cash_activity_id', label: '红包批次号' }, + { value: 'receive_status', label: '领取状态' }, + { value: 'receive_time', label: '拆红包时间' }, + { value: 'cash_packet_id', label: '红包ID' }, + { value: 'cash_id', label: '红包规则ID' }, + { value: 'amount', label: '红包额度' }, + { value: 'status', label: '状态' }, + { value: 'expire_time', label: '过期时间' }, + { value: 'update_time', label: '更新时间' } + ], + order_voucher: [ + { value: 'channel', label: '渠道' }, + { value: 'channel_activity_id', label: '渠道立减金批次' }, + { value: 'channel_voucher_id', label: '渠道立减金ID' }, + { value: 'status', label: '状态' }, + { value: 'grant_time', label: '领取时间' }, + { value: 'usage_time', label: '核销时间' }, + { value: 'refund_time', label: '退款时间' }, + { value: 'status_modify_time', label: '状态更新时间' }, + { value: 'overdue_time', label: '过期时间' }, + { value: 'refund_amount', label: '退款金额' }, + { value: 'official_price', label: '官方价' }, + { value: 'out_biz_no', label: '外部业务号' }, + { value: 'account_no', label: '账户号' } + ], + plan: [ + { value: 'id', label: '计划ID' }, + { value: 'title', label: '计划标题' }, + { value: 'status', label: '状态' }, + { value: 'begin_time', label: '开始时间' }, + { value: 'end_time', label: '结束时间' } + ], + key_batch: [ + { value: 'id', label: '批次ID' }, + { value: 'batch_name', label: '批次名称' }, + { value: 'bind_object', label: '绑定对象' }, + { value: 'quantity', label: '发放数量' }, + { value: 'stock', label: '剩余库存' }, + { value: 'begin_time', label: '开始时间' }, + { value: 'end_time', label: '结束时间' } + ], + code_batch: [ + { value: 'id', label: '兑换批次ID' }, + { value: 'title', label: '标题' }, + { value: 'status', label: '状态' }, + { value: 'begin_time', label: '开始时间' }, + { value: 'end_time', label: '结束时间' }, + { value: 'quantity', label: '数量' }, + { value: 'usage', label: '使用数' }, + { value: 'stock', label: '库存' } + ], + voucher: [ + { value: 'channel', label: '渠道' }, + { value: 'channel_activity_id', label: '渠道批次号' }, + { value: 'price', label: '合同单价' }, + { value: 'balance', label: '剩余额度' }, + { value: 'used_amount', label: '已用额度' }, + { value: 'denomination', label: '面额' } + ], + voucher_batch: [ + { value: 'channel_activity_id', label: '渠道批次号' }, + { value: 'temp_no', label: '模板编号' }, + { value: 'provider', label: '服务商' }, + { value: 'weight', label: '权重' } + ], + merchant_key_send: [ + { value: 'merchant_id', label: '商户ID' }, + { value: 'out_biz_no', label: '商户业务号' }, + { value: 'key', label: '券码' }, + { value: 'status', label: '状态' }, + { value: 'usage_time', label: '核销时间' }, + { value: 'create_time', label: '创建时间' } + ] } + } + const TABLE_LABELS = { + order: '订单主表', + order_detail: '订单详情', + order_cash: '红包订单', + order_voucher: '立减金订单', + plan: '活动计划', + key_batch: 'KEY批次', + code_batch: '兑换码批次', + voucher: '立减金', + voucher_batch: '立减金批次', + merchant_key_send: '开放平台发放记录' + } + const fieldOptions = Vue.computed(()=>{ + const ds = state.form.datasource + const FM = FIELDS_MAP[ds] || {} + const node = (table, children=[])=>({ value: table, label: TABLE_LABELS[table]||table, children }) + const fieldsNode = (table)=> (FM[table]||[]) + const orderChildrenBase = [] + orderChildrenBase.push(...fieldsNode('order')) + orderChildrenBase.push(node('order_detail', fieldsNode('order_detail'))) + const planChildren = [] + planChildren.push(...fieldsNode('plan')) + planChildren.push(node('key_batch', [ + ...fieldsNode('key_batch'), + node('code_batch', fieldsNode('code_batch')) + ])) + const voucherChildren = [] + voucherChildren.push(...fieldsNode('order_voucher')) + voucherChildren.push(node('voucher', [ + ...fieldsNode('voucher'), + node('voucher_batch', fieldsNode('voucher_batch')) + ])) + const orderChildrenFor = (type)=>{ + const ch = [...orderChildrenBase] + if(type===1){ + // 直充卡密:排除红包与立减金 + ch.push(node('plan', planChildren)) + ch.push(node('merchant_key_send', fieldsNode('merchant_key_send'))) + } else if(type===2){ + // 立减金:排除红包,保留立减金链 + ch.push(node('order_voucher', voucherChildren)) + ch.push(node('plan', planChildren)) + } else if(type===3){ + // 红包:仅红包链 + ch.push(node('order_cash', fieldsNode('order_cash'))) + ch.push(node('plan', planChildren)) + } else { + // 未选择类型:全部显示 + ch.push(node('order_cash', fieldsNode('order_cash'))) + ch.push(node('order_voucher', voucherChildren)) + ch.push(node('plan', planChildren)) + ch.push(node('merchant_key_send', fieldsNode('merchant_key_send'))) + } + return ch + } + const type = Number(state.form.orderType || 0) + const orderNode = node('order', orderChildrenFor(type)) + if(type){ + return [ orderNode ] + } + return [ + { value: 'scene_order', label: '订单数据', children: [ orderNode ] } + ] }) const msg = (t, type='success')=>ElementPlus.ElMessage({message:t,type}); + const visibilityOptions = [ + { label: '个人', value: 'private' }, + { label: '公共', value: 'public' } + ] + const formatOptions = [ + { label: 'XLSX', value: 'xlsx' }, + { label: 'CSV', value: 'csv' } + ] 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())}`; @@ -26,38 +211,93 @@ const app = createApp({ 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]))] + let fields = [] + if(state.form.fieldsSel && state.form.fieldsSel.length){ + fields = state.form.fieldsSel.map(path=>{ + if(Array.isArray(path)){ + if(path.length===4){ return `${path[2]}.${path[3]}` } + if(path.length===3){ return `${path[1]}.${path[2]}` } + if(path.length===2){ return `${path[0]}.${path[1]}` } + } + return path + }) + } else { + fields = state.form.fieldsRaw.split(',').map(s=>s.trim()).filter(Boolean) } 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') } + if(res.ok){ msg('创建成功'); state.createVisible=false; 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 openExport = (row)=>{ + state.export.tplId = row.id + state.export.file_format = row.file_format || 'xlsx' + state.exportVisible = true + } + const submitExport = async ()=>{ + const id = state.export.tplId + const filters = {} + if(state.export.permissionMode==='creator' && state.export.creatorRaw){ + filters.creator_in = state.export.creatorRaw.split(',').map(s=>Number(s.trim())).filter(x=>!isNaN(x)) + } + if(state.export.orderType){ filters.type_eq = Number(state.export.orderType) } + if(state.export.timeRange && state.export.timeRange.length===2){ + filters.create_time_between = [fmtDT(new Date(state.export.timeRange[0])), fmtDT(new Date(state.export.timeRange[1]))] + } + const payload={template_id:Number(id),requested_by:1,permission:{},options:{},filters, file_format: state.export.file_format}; const r=await fetch('/api/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const j=await r.json(); + state.exportVisible=false loadJob(j.id); } + const clampWidth = (w)=>{ + const n = Math.max(500, Math.min(1400, w)) + return n + 'px' + } + const resizeDialog = (kind, delta)=>{ + if(kind==='create'){ + const cur = parseInt(String(state.createWidth).replace('px','')||'900',10) + const next = clampWidth(cur + delta) + state.createWidth = next + localStorage.setItem('tplDialogWidth', next) + } else if(kind==='edit'){ + const cur = parseInt(String(state.editWidth).replace('px','')||'600',10) + const next = clampWidth(cur + delta) + state.editWidth = next + localStorage.setItem('tplEditDialogWidth', next) + } + } + const openEdit = (row)=>{ + state.edit.id = row.id + state.edit.name = row.name + state.edit.visibility = row.visibility + state.edit.file_format = row.file_format + state.editVisible = true + } + const saveEdit = async ()=>{ + const id = state.edit.id + const payload = { name: state.edit.name, visibility: state.edit.visibility, file_format: state.edit.file_format } + const res = await fetch('/api/templates/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}) + if(res.ok){ msg('保存成功'); state.editVisible=false; loadTemplates() } else { msg(await res.text(),'error') } + } + const removeTemplate = async (id)=>{ + const r = await fetch('/api/templates/'+id,{method:'DELETE'}) + if(r.ok){ msg('删除成功'); loadTemplates() } else { msg(await r.text(),'error') } + } 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 } + return { ...Vue.toRefs(state), visibilityOptions, formatOptions, fieldOptions, loadTemplates, createTemplate, openExport, submitExport, loadJob, download, openEdit, saveEdit, removeTemplate, resizeDialog } } }) app.use(ElementPlus)