feat: 支持采购任务追溯并安全重新创建 (#186)

This commit is contained in:
chengma
2026-08-12 16:26:22 +08:00
parent 836aad5c26
commit 69bcf6116a
13 changed files with 298 additions and 57 deletions
+52 -23
View File
@@ -61,6 +61,31 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
purchaseRows := append([]service.SybOrderView(nil), result.Rows...)
openSybID := strings.TrimSpace(c.Query("open_syb_id"))
openPurchaseSybID := ""
if openSybID != "" && c.Query("purchase_intent") == "1" {
focused, err := service.GetSybOrderView(h.db, openSybID)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取原货运单采购信息失败,数据没有被改动。刷新页面重试。")
return
}
if focused != nil && focused.CanPurchase {
found := false
for _, row := range purchaseRows {
if row.SybID == focused.SybID {
found = true
break
}
}
if !found {
purchaseRows = append(purchaseRows, *focused)
}
openPurchaseSybID = focused.SybID
openSybID = ""
}
}
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
if err != nil {
fail(c, http.StatusInternalServerError,
@@ -161,28 +186,31 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
}
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
"Keyword": keyword,
"ShopFilter": shop,
"StageFilter": stage,
"StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page,
"Rows": result.Rows,
"Status": status,
"HasAny": result.HasAny,
"IsFiltered": result.IsFiltered,
"NeedLogin": needLogin,
"NeedLoginReason": loginReason,
"LoginHint": loginHint,
"Username": username,
"ConfigProblem": configProblem,
"Today": today,
"SyncDateFrom": dateFrom,
"SyncDateTo": dateTo,
"RangeError": c.Query("range_error"),
"RangeWarning": rangeWarning,
"SyncRunning": syncStatus.Running,
"SyncHistory": history,
"NeedSyncHistory": c.Query("history") == "1",
"Keyword": keyword,
"ShopFilter": shop,
"StageFilter": stage,
"StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page,
"Rows": result.Rows,
"PurchaseRows": purchaseRows,
"OpenSybID": openSybID,
"OpenPurchaseSybID": openPurchaseSybID,
"Status": status,
"HasAny": result.HasAny,
"IsFiltered": result.IsFiltered,
"NeedLogin": needLogin,
"NeedLoginReason": loginReason,
"LoginHint": loginHint,
"Username": username,
"ConfigProblem": configProblem,
"Today": today,
"SyncDateFrom": dateFrom,
"SyncDateTo": dateTo,
"RangeError": c.Query("range_error"),
"RangeWarning": rangeWarning,
"SyncRunning": syncStatus.Running,
"SyncHistory": history,
"NeedSyncHistory": c.Query("history") == "1",
"HistoryFetchURL": sybHistoryPartialURL(history.Page,
keyword, shop, stage, dateFrom, dateTo),
"HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages,
@@ -805,7 +833,8 @@ func (h *Handler) TaskList(c *gin.Context) {
// TaskDetail 渲染双击行弹出的只读详情弹窗内容(不是整页)。
//
// `[必须]` 只读。改派 / 重试 / 取消是后续工单的范围,这里不提供入口。
// `[必须]` 字段只读,不提供改派 / 重试 / 取消或直接创建入口。失败/取消采购
// 只生成返回原顺运宝明细的导航链接,真正创建仍走原确认表单和服务端门禁。
// 弹窗机制复用 #18 已有的那套(static/js/app.js 的 setupRowDetail),
// 不新造一套。
func (h *Handler) TaskDetail(c *gin.Context) {
+34 -1
View File
@@ -83,7 +83,10 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
t.Fatal("没有找到模块查询参数白名单")
}
whitelist := jsSource[whitelistStart:whitelistEnd]
for _, forbidden := range []string{"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\""} {
for _, forbidden := range []string{
"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\"",
"\"open_syb_id\"", "\"purchase_intent\"",
} {
if strings.Contains(whitelist, forbidden) {
t.Errorf("稳定状态白名单不应包含临时参数 %s", forbidden)
}
@@ -189,6 +192,36 @@ func TestTaskDetail_采购核单结果使用独立只读分组(t *testing.T) {
}
}
func TestTaskDetail_采购来源与安全重新创建入口(t *testing.T) {
files := map[string][]string{
"templates/task/detail_modal.html": {
"来源信息", "蝦皮订单号", "顺运宝明细 ID", "蝦皮商品 ID", "PDD 商品 ID",
`{{if .HasSybSource}}`, `href="{{.ViewSybURL}}"`, "查看原货运单",
`{{if .CanRecreatePurchase}}`, `href="{{.RecreatePurchaseURL}}"`, "重新创建采购任务",
},
"templates/syb/list.html": {
`data-auto-open-purchase-id="{{.OpenPurchaseSybID}}"`, `{{range .PurchaseRows}}`,
`data-auto-open-detail-id="{{.OpenSybID}}"`,
},
"static/js/app.js": {
`form.getAttribute("data-auto-open-purchase-id")`,
`modal.getAttribute("data-auto-open-detail-id")`,
`showPurchaseSelection(autoSelected)`, `loadDetail(autoDetailID)`,
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少采购来源恢复约束 %q", path, want)
}
}
}
}
func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) {
files := map[string][]string{
"templates/task/list.html": {
+21
View File
@@ -158,6 +158,27 @@ func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
return key
}
func TestGetSybOrderView_按明细ID准备单条采购确认(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-DEEP-LINK")
if err := SaveSybMapping(db, "SYB-DEEP-LINK", key, "USR-1"); err != nil {
t.Fatal(err)
}
view, err := GetSybOrderView(db, " SYB-DEEP-LINK ")
if err != nil || view == nil {
t.Fatalf("精确读取失败: view=%+v err=%v", view, err)
}
if !view.CanPurchase || view.SybID != "SYB-DEEP-LINK" ||
view.MappingOptionKey != key || view.DefaultUnitPrice != "39.90" {
t.Fatalf("深链接采购确认数据不完整: %+v", view)
}
missing, err := GetSybOrderView(db, "NOT-FOUND")
if err != nil || missing != nil {
t.Fatalf("不存在明细应返回 nil: view=%+v err=%v", missing, err)
}
}
func TestSybMapping_动态维度保存并复用(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
+11
View File
@@ -1197,6 +1197,17 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
return v
}
// GetSybOrderView 按稳定明细 ID 读取一行完整视图,供任务来源深链接在分页之外
// 准备采购确认数据。查不到返回 (nil, nil)。
func GetSybOrderView(db *sql.DB, sybID string) (*SybOrderView, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
if err != nil || context == nil {
return nil, err
}
view := sybOrderViewFor(*context)
return &view, nil
}
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
type SybProcessingDetail struct {
SybID string
+41 -6
View File
@@ -12,6 +12,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
@@ -456,7 +457,8 @@ const resultDataLimit = 4000
// TaskDetailView 是双击弹窗要显示的全部内容。
//
// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。
// `[必须]` 字段只读——弹窗不直接改派、重试、取消或创建任务;安全恢复只导航
// 回原顺运宝明细,真正创建仍走原确认表单和服务端门禁。
type TaskDetailView struct {
TaskID string
TypeText string
@@ -471,12 +473,20 @@ type TaskDetailView struct {
PddGoodsURL string
PddShopText string
// IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏,
// IsPurchase 为 false 时,模板要把下面的采购专有字段整段隐藏,
// 不能显示空行——见 #19。
IsPurchase bool
SpecText string
QuantityText string
PriceLimitText string
IsPurchase bool
ShopeeOrderNo string
SybID string
ShopeeGoodsID string
PddGoodsID string
HasSybSource bool
ViewSybURL string
CanRecreatePurchase bool
RecreatePurchaseURL string
SpecText string
QuantityText string
PriceLimitText string
// PDD 核单结果只在采购任务详情显示。付款状态是 Client 核单上报时的快照,
// 不是 Admin 对 PDD 的实时查询结果。
@@ -522,6 +532,18 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
IsPurchase: t.TaskType == model.TaskPurchase,
}
if v.IsPurchase {
v.ShopeeOrderNo = orPlaceholder(t.OrderNo)
v.SybID = orPlaceholder(t.SybID)
v.ShopeeGoodsID = orPlaceholder(t.GoodsID)
v.PddGoodsID = orPlaceholder(t.PddGoodsID)
v.HasSybSource = strings.TrimSpace(t.SybID) != ""
if v.HasSybSource {
v.ViewSybURL = taskSybURL(*t, false)
v.CanRecreatePurchase = t.Status == model.TaskFailed || t.Status == model.TaskCancelled
if v.CanRecreatePurchase {
v.RecreatePurchaseURL = taskSybURL(*t, true)
}
}
v.SpecText = specText(t.PddOptions)
v.QuantityText = quantityText(t.Quantity)
v.PriceLimitText = priceLimitText(t.MaxPriceCent)
@@ -541,6 +563,19 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
return v, nil
}
// taskSybURL 生成采购任务来源的稳定深链接。syb_id 用于精确定位;订单号只负责
// 让跳转后的列表保留有意义的上下文,不能拿它代替明细主键。
func taskSybURL(task model.Task, purchaseIntent bool) string {
values := url.Values{"open_syb_id": {task.SybID}}
if strings.TrimSpace(task.OrderNo) != "" {
values.Set("order_no", task.OrderNo)
}
if purchaseIntent {
values.Set("purchase_intent", "1")
}
return "/syb?" + values.Encode()
}
// GetTaskDetailForUser 对采购员隐藏其他人和历史任务,统一表现为不存在。
func GetTaskDetailForUser(db *sql.DB, actor *model.User, taskID string) (*TaskDetailView, error) {
visibleUserID, err := visibleClientUserID(actor)
+52 -6
View File
@@ -44,7 +44,9 @@ type testTaskParams struct {
taskType model.TaskType
status model.TaskStatus
assignedClient string
sybID string
orderNo string
goodsID string
pddGoodsID string
pddOptions string
quantity int
@@ -61,13 +63,19 @@ func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
t.Helper()
now := model.NowISO()
var assigned, orderNo, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any
var assigned, sybID, orderNo, goodsID, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any
if p.assignedClient != "" {
assigned = p.assignedClient
}
if p.orderNo != "" {
orderNo = p.orderNo
}
if p.sybID != "" {
sybID = p.sybID
}
if p.goodsID != "" {
goodsID = p.goodsID
}
if p.pddGoodsID != "" {
pddGoodsID = p.pddGoodsID
}
@@ -100,14 +108,14 @@ func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
_, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at,
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
syb_id, order_no, goods_id, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
error_code, error_message, result_data, finished_at, created_by_user_id,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.taskID, string(p.taskType), string(p.status), assigned, claimedAt,
orderNo, pddGoodsID, pddOptions,
sybID, orderNo, goodsID, pddGoodsID, pddOptions,
quantity, maxPrice,
errCode, errMsg, resultData, finishedAt, nullableString(p.createdByUserID),
now, now)
@@ -425,8 +433,9 @@ func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{
taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskClaimed,
orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`,
quantity: 2, maxPriceCent: 4200, assignedClient: "client-001",
sybID: "SYB-001", orderNo: "SO-001", goodsID: "SHOPEE-001", pddGoodsID: "PDD-001",
pddOptions: `{"color":"黑色","size":"M"}`,
quantity: 2, maxPriceCent: 4200, assignedClient: "client-001",
claimedAt: model.NowISO(),
})
@@ -446,6 +455,40 @@ func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) {
if d.ClientText != "client-001" {
t.Errorf("分配客户端 = %q, want client-001", d.ClientText)
}
if d.ShopeeOrderNo != "SO-001" || d.SybID != "SYB-001" ||
d.ShopeeGoodsID != "SHOPEE-001" || d.PddGoodsID != "PDD-001" || !d.HasSybSource {
t.Errorf("采购来源字段不完整: %+v", d)
}
if d.ViewSybURL != "/syb?open_syb_id=SYB-001&order_no=SO-001" || d.CanRecreatePurchase || d.RecreatePurchaseURL != "" {
t.Errorf("执行中的采购任务只应允许查看来源: view=%q recreate=%t url=%q",
d.ViewSybURL, d.CanRecreatePurchase, d.RecreatePurchaseURL)
}
}
func TestGetTaskDetail_只有失败和取消允许进入重新创建确认(t *testing.T) {
db := newTestDB(t)
statuses := []model.TaskStatus{
model.TaskPending, model.TaskAssigned, model.TaskClaimed, model.TaskSucceeded,
model.TaskManualReview, model.TaskFailed, model.TaskCancelled,
}
for _, status := range statuses {
taskID := "PUR-" + string(status)
insertTestTask(t, db, testTaskParams{
taskID: taskID, taskType: model.TaskPurchase, status: status,
sybID: "SYB A&B", orderNo: "ORDER/1", goodsID: "SHOPEE-1", pddGoodsID: "PDD-1",
})
detail, err := GetTaskDetail(db, taskID)
if err != nil {
t.Fatal(err)
}
want := status == model.TaskFailed || status == model.TaskCancelled
if detail.CanRecreatePurchase != want {
t.Errorf("状态 %s CanRecreatePurchase=%t,want %t", status, detail.CanRecreatePurchase, want)
}
if want && detail.RecreatePurchaseURL != "/syb?open_syb_id=SYB+A%26B&order_no=ORDER%2F1&purchase_intent=1" {
t.Errorf("状态 %s 的深链接编码错误: %q", status, detail.RecreatePurchaseURL)
}
}
}
func TestGetTaskDetail_采购成功独立显示PDD核单结果(t *testing.T) {
@@ -525,6 +568,9 @@ func TestGetTaskDetail_采集任务不带采购专有字段(t *testing.T) {
if d.HasPddOrder || d.PddOrderResultText != "" {
t.Error("采集任务不应生成 PDD 核单结果展示")
}
if d.HasSybSource || d.ShopeeOrderNo != "" || d.CanRecreatePurchase {
t.Error("采集任务不应生成采购来源和重新创建入口")
}
}
func TestGetTaskDetail_有错误信息时显示(t *testing.T) {
+6
View File
@@ -363,6 +363,12 @@ tr.empty small { color: #aaa; }
}
.button-link:hover { background: #f0f2f4; }
.button-link.disabled { color: #999; background: #f5f6f8; }
.button-link.primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
.button-link.primary:hover { background: #1a5fd0; }
.sync-history-table { max-height: 460px; }
.sync-history-table td { vertical-align: top; }
.sync-history-table .sync-error {
+34 -12
View File
@@ -255,6 +255,22 @@
var input = row.querySelector("[data-unit-price-input]");
if (input) input.addEventListener("input", function () { updateTotalPrice(row); });
});
function showPurchaseSelection(selected) {
var shown = 0;
form.querySelectorAll("[data-purchase-row]").forEach(function (row) {
var enabled = !!selected[row.getAttribute("data-purchase-row")];
row.hidden = !enabled;
row.querySelectorAll("input").forEach(function (input) {
input.disabled = !enabled;
});
if (enabled) updateTotalPrice(row);
if (enabled) shown += 1;
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
return shown;
}
openButtons.forEach(function (openButton) {
openButton.addEventListener("click", function () {
var selected = {};
@@ -266,20 +282,23 @@
if (supportsSelectAction(box, "purchase")) selected[box.value] = true;
});
}
var shown = 0;
form.querySelectorAll("[data-purchase-row]").forEach(function (row) {
var enabled = !!selected[row.getAttribute("data-purchase-row")];
row.hidden = !enabled;
row.querySelectorAll("input").forEach(function (input) {
input.disabled = !enabled;
});
if (enabled) updateTotalPrice(row);
if (enabled) shown += 1;
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
showPurchaseSelection(selected);
});
});
/* 任务详情的“重新创建”只把稳定 syb_id 带回当前确认流程。
找不到或当前已不可采购时服务端不会输出这个 ID,前端绝不自行放宽。 */
var autoPurchaseID = form.getAttribute("data-auto-open-purchase-id") || "";
var autoSelected = {};
if (autoPurchaseID) autoSelected[autoPurchaseID] = true;
if (autoPurchaseID && showPurchaseSelection(autoSelected) > 0) {
var purchaseModal = form.closest(".modal-backdrop");
if (purchaseModal) {
openModal(purchaseModal);
var clientSelect = form.querySelector("select[name=client_id]");
if (clientSelect) clientSelect.focus({ preventScroll: true });
}
}
form.addEventListener("submit", function (event) {
if (event.defaultPrevented || !submitButton) return;
submitButton.disabled = true;
@@ -438,6 +457,9 @@
loadDetail(button.getAttribute("data-detail-open-id"));
});
});
var autoDetailID = modal.getAttribute("data-auto-open-detail-id") || "";
if (autoDetailID) loadDetail(autoDetailID);
}
/* 顺运宝缩略图使用上游图片 URL。这里只在弹窗里放大展示,
+2 -1
View File
@@ -53,7 +53,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
var output bytes.Buffer
data := map[string]any{
"Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf",
"Rows": rows, "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
"Rows": rows, "PurchaseRows": rows,
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
}
if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil {
t.Fatalf("渲染顺运宝列表失败: %v", err)
+5 -3
View File
@@ -215,7 +215,8 @@
<h2 id="purchase-modal-title">确认创建采购任务</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/syb/create-task" data-purchase-form>
<form method="post" action="/syb/create-task" data-purchase-form
data-auto-open-purchase-id="{{.OpenPurchaseSybID}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="shop" value="{{.ShopFilter}}">
@@ -231,7 +232,7 @@
{{if eq .PurchaseClientCount 0}}<small class="missing">当前账号没有可见的客户端,请先联系管理员绑定客户端。</small>{{else}}<small>所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。</small>{{end}}
</div>
<p class="hint">单价上限默认取已映射 PDD 规格的人民币采集价;订单总价上限按单价乘以顺运宝数量自动计算,不使用顺运宝的台币售价。</p>
{{range .Rows}}
{{range .PurchaseRows}}
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" data-purchase-quantity="{{.Quantity}}" hidden>
<input type="hidden" name="ids" value="{{.SybID}}" disabled>
<input type="hidden" name="mapping_option_key" value="{{.MappingOptionKey}}" disabled>
@@ -283,7 +284,8 @@
</div>
</div>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}"
data-auto-open-detail-id="{{.OpenSybID}}" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
+19 -2
View File
@@ -2,8 +2,8 @@
{{/* 双击一行时弹出的内容。
这是一个**片段**,不是整页——外面的弹窗壳子在 task/list.html 里。
`[必须]` 只读:本工单不做改派、重试、取消,弹窗里不放对应的表单和按钮,
见 #19。采购专有字段(目标规格/数量/订单总价上限)在采集任务这里
`[必须]` 字段只读:弹窗不直接改派、重试、取消或复制旧任务;失败/取消采购
只允许导航回原顺运宝明细重新确认,见 #186。采购专有字段在采集任务这里
**整段隐藏**,不显示空行。 */}}
{{with .D}}
<div class="modal-head">
@@ -23,6 +23,16 @@
<dt>完成时间</dt><dd>{{.FinishedAt}}</dd>
</dl>
{{if .IsPurchase}}
<h3>来源信息</h3>
<dl class="detail">
<dt>蝦皮订单号</dt><dd>{{.ShopeeOrderNo}}</dd>
<dt>顺运宝明细 ID</dt><dd>{{.SybID}}</dd>
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
<dt>PDD 商品 ID</dt><dd>{{.PddGoodsID}}</dd>
</dl>
{{end}}
<h3>执行参数</h3>
<dl class="detail">
<dt>PDD 链接</dt>
@@ -70,6 +80,13 @@
</div>
<div class="modal-foot">
{{if .HasSybSource}}
<span class="grow"></span>
<a class="button-link" href="{{.ViewSybURL}}">查看原货运单</a>
{{if .CanRecreatePurchase}}
<a class="button-link primary" href="{{.RecreatePurchaseURL}}">重新创建采购任务</a>
{{end}}
{{end}}
<button type="button" data-modal-close>关闭</button>
</div>
{{end}}