feat: PDD 批量采集支持可选客户端 (#74)
This commit is contained in:
+25
-13
@@ -28,6 +28,12 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError,
|
||||||
|
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
|
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
|
||||||
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
|
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
|
||||||
@@ -49,16 +55,17 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
detailValues.Set("page", strconv.Itoa(result.Page))
|
detailValues.Set("page", strconv.Itoa(result.Page))
|
||||||
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
|
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
"Status": statusLine,
|
"Status": statusLine,
|
||||||
"StatusFilter": string(status),
|
"StatusFilter": string(status),
|
||||||
"StatusOptions": service.CollectStatusOptions(),
|
"StatusOptions": service.CollectStatusOptions(),
|
||||||
"HasAnyProducts": result.Total > 0,
|
"HasAnyProducts": result.Total > 0,
|
||||||
"IsFiltered": result.IsFiltered,
|
"IsFiltered": result.IsFiltered,
|
||||||
"CurrentPage": result.Page,
|
"CurrentPage": result.Page,
|
||||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||||
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
|
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
|
||||||
|
"AssignableClients": assignableClients,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +138,15 @@ func (h *Handler) PddCollect(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := service.CreatePddCollectTasks(h.db, ids)
|
result, err := service.CreatePddCollectTasksForUser(h.db, currentUser(c), ids, c.PostForm("client_id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError,
|
status := http.StatusInternalServerError
|
||||||
"创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。")
|
message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。"
|
||||||
|
if service.IsValidationError(err) {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
message = err.Error() + "。这一批任务整体没有创建,请重新选择。"
|
||||||
|
}
|
||||||
|
fail(c, status, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-12
@@ -21,8 +21,8 @@ const claimCandidateLimit = 10
|
|||||||
// 指定给本机的 assigned_client = 我 且 status = 'assigned'
|
// 指定给本机的 assigned_client = 我 且 status = 'assigned'
|
||||||
// 无主的 assigned_client 为空 且 status = 'pending'
|
// 无主的 assigned_client 为空 且 status = 'pending'
|
||||||
//
|
//
|
||||||
// 采集任务创建时不指定客户端(浏览商品页没有副作用,哪台设备采都一样),
|
// 采集任务默认不指定客户端(浏览商品页没有副作用,哪台设备采都一样),
|
||||||
// 所以必须支持第二种,否则那些任务永远没人能领。
|
// PDD 批量页面也允许人工指定,因此两种领取条件都必须保留。
|
||||||
// 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。
|
// 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。
|
||||||
//
|
//
|
||||||
// **指定给本机的优先。** 显式分配是人为决定,应当先兑现;
|
// **指定给本机的优先。** 显式分配是人为决定,应当先兑现;
|
||||||
@@ -422,24 +422,32 @@ func DeleteTasks(q Execer, taskIDs []string) (int64, error) {
|
|||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertCollectTask 建一条采集任务。
|
// InsertCollectTask 建一条不指定客户端的采集任务。
|
||||||
//
|
// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 PDD 页的新选项影响。
|
||||||
// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending,
|
|
||||||
// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作,
|
|
||||||
// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。
|
|
||||||
//
|
|
||||||
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL,
|
|
||||||
// 没有它客户端拿到任务也不知道去哪采。
|
|
||||||
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
|
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
|
||||||
|
return InsertCollectTaskForClient(q, taskID, goodsID, goodsURL, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCollectTaskForClient 建一条采集任务。
|
||||||
|
// assignedClient 为空时任务无主待领;有值时只等待指定客户端领取。
|
||||||
|
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL。
|
||||||
|
func InsertCollectTaskForClient(q Execer, taskID, goodsID, goodsURL, assignedClient string) error {
|
||||||
if goodsID == "" || goodsURL == "" {
|
if goodsID == "" || goodsURL == "" {
|
||||||
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
|
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
|
||||||
}
|
}
|
||||||
|
assignedClient = strings.TrimSpace(assignedClient)
|
||||||
|
status := model.TaskPending
|
||||||
|
assigned := sql.NullString{}
|
||||||
|
if assignedClient != "" {
|
||||||
|
status = model.TaskAssigned
|
||||||
|
assigned = sql.NullString{String: assignedClient, Valid: true}
|
||||||
|
}
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
||||||
pdd_goods_url, pdd_goods_id, created_at, updated_at)
|
pdd_goods_url, pdd_goods_id, created_at, updated_at)
|
||||||
VALUES (?, 'collect', 'pending', NULL, ?, ?, ?, ?)`,
|
VALUES (?, 'collect', ?, ?, ?, ?, ?, ?)`,
|
||||||
taskID, goodsURL, goodsID, now, now)
|
taskID, status, assigned, goodsURL, goodsID, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
|
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -558,7 +558,7 @@ func (r CollectTaskResult) Skipped() int {
|
|||||||
return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
|
return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePddCollectTasks 为勾选的 PDD 商品创建采集任务。
|
// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。
|
||||||
//
|
//
|
||||||
// `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending),
|
// `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending),
|
||||||
// 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样,
|
// 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样,
|
||||||
@@ -576,6 +576,20 @@ func (r CollectTaskResult) Skipped() int {
|
|||||||
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
||||||
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
||||||
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
||||||
|
return createPddCollectTasks(db, goodsIDs, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
|
||||||
|
// 即使浏览器伪造 clientID,也必须重新按当前登录账号校验可见范围。
|
||||||
|
func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []string, clientID string) (CollectTaskResult, error) {
|
||||||
|
visibleUserID, err := visibleClientUserID(actor)
|
||||||
|
if err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID string) (CollectTaskResult, error) {
|
||||||
var result CollectTaskResult
|
var result CollectTaskResult
|
||||||
|
|
||||||
goodsIDs = dedupe(goodsIDs)
|
goodsIDs = dedupe(goodsIDs)
|
||||||
@@ -589,6 +603,16 @@ func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, er
|
|||||||
}
|
}
|
||||||
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||||||
|
|
||||||
|
if clientID != "" {
|
||||||
|
visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID)
|
||||||
|
if err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
return CollectTaskResult{}, invalidInput("所选客户端不存在或不在当前账号可见范围")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 跳过原因里"正在采集"的那些,各自还要等多久才超时可重试;
|
// 跳过原因里"正在采集"的那些,各自还要等多久才超时可重试;
|
||||||
// 取其中最快的一个,给操作员一个"下一步该等多久"的具体数字。
|
// 取其中最快的一个,给操作员一个"下一步该等多久"的具体数字。
|
||||||
var (
|
var (
|
||||||
@@ -632,8 +656,8 @@ func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, er
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repository.InsertCollectTask(
|
if err := repository.InsertCollectTaskForClient(
|
||||||
tx, newCollectTaskID(), p.GoodsID, p.URL); err != nil {
|
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID); err != nil {
|
||||||
return CollectTaskResult{}, err
|
return CollectTaskResult{}, err
|
||||||
}
|
}
|
||||||
result.Created++
|
result.Created++
|
||||||
|
|||||||
@@ -597,8 +597,8 @@ func collectTaskOf(t *testing.T, db *sql.DB, goodsID string) (status, assigned,
|
|||||||
return status, a.String, url, true
|
return status, a.String, url, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// `[必须]` 采集任务不指定客户端。采集是纯读取,哪台机器跑都一样,
|
// `[必须]` 默认不指定客户端。常规采集哪台机器跑都一样,
|
||||||
// 指定了反而会在那台机器关着的时候干等。
|
// 保持无主待领可以避免某台机器关着时一直等待。
|
||||||
func TestCreatePddCollectTasks_不指定客户端(t *testing.T) {
|
func TestCreatePddCollectTasks_不指定客户端(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
createProduct(t, db, "737116531267")
|
createProduct(t, db, "737116531267")
|
||||||
@@ -651,6 +651,75 @@ func TestCreatePddCollectTasks_建出的任务能被任意客户端领走(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePddCollectTasksForUser_指定客户端时只等待该客户端领取(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC))
|
||||||
|
RegisterClient(db, model.Client{ClientID: "client-chosen", Name: "指定机器"}, true)
|
||||||
|
RegisterClient(db, model.Client{ClientID: "client-other", Name: "其他机器"}, true)
|
||||||
|
createProduct(t, db, "737116531267")
|
||||||
|
|
||||||
|
result, err := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, "client-chosen")
|
||||||
|
if err != nil || result.Created != 1 {
|
||||||
|
t.Fatalf("指定客户端建任务失败: result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
status, assigned, _, ok := collectTaskOf(t, db, "737116531267")
|
||||||
|
if !ok || status != "assigned" || assigned != "client-chosen" {
|
||||||
|
t.Fatalf("任务初始状态或客户端不对: status=%q assigned=%q ok=%t", status, assigned, ok)
|
||||||
|
}
|
||||||
|
otherTask, err := ClaimNextTask(db, "client-other", []string{"collect"})
|
||||||
|
if err != nil || otherTask != nil {
|
||||||
|
t.Fatalf("其他客户端不应领到指定任务: task=%+v err=%v", otherTask, err)
|
||||||
|
}
|
||||||
|
chosenTask, err := ClaimNextTask(db, "client-chosen", []string{"collect"})
|
||||||
|
if err != nil || chosenTask == nil {
|
||||||
|
t.Fatalf("指定客户端应该能领取任务: task=%+v err=%v", chosenTask, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePddCollectTasksForUser_采购员不能指定别人客户端且整批回滚(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||||
|
RegisterClient(db, model.Client{ClientID: "client-b", Name: "B 的机器"}, true)
|
||||||
|
if _, _, err := AssignClient(db, admin, "client-b", buyerB.UserID,
|
||||||
|
time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
createProduct(t, db, "737116531267")
|
||||||
|
createProduct(t, db, "937122477375")
|
||||||
|
|
||||||
|
_, err := CreatePddCollectTasksForUser(db, buyerA,
|
||||||
|
[]string{"737116531267", "937122477375"}, "client-b")
|
||||||
|
if !IsValidationError(err) {
|
||||||
|
t.Fatalf("不可见客户端应返回表单错误,实际 %v", err)
|
||||||
|
}
|
||||||
|
var taskCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect'`).Scan(&taskCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if taskCount != 0 {
|
||||||
|
t.Fatalf("拒绝后不应产生部分任务,实际 %d", taskCount)
|
||||||
|
}
|
||||||
|
for _, goodsID := range []string{"737116531267", "937122477375"} {
|
||||||
|
product, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||||
|
if product.CollectStatus != model.CollectPending {
|
||||||
|
t.Errorf("拒绝后商品 %s 状态不应改变,实际 %s", goodsID, product.CollectStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePddCollectTasksForUser_不指定时保持无主待领(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC))
|
||||||
|
createProduct(t, db, "737116531267")
|
||||||
|
if _, err := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status, assigned, _, _ := collectTaskOf(t, db, "737116531267")
|
||||||
|
if status != "pending" || assigned != "" {
|
||||||
|
t.Errorf("默认应保持无主待领,status=%q assigned=%q", status, assigned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
|
func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
createProduct(t, db, "737116531267")
|
createProduct(t, db, "737116531267")
|
||||||
|
|||||||
@@ -159,6 +159,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PDD 批量采集弹窗只同步已选数量并提供提交反馈。
|
||||||
|
客户端权限和任务初始状态始终由服务端决定。 */
|
||||||
|
function setupCollectModal() {
|
||||||
|
var openButton = document.querySelector("[data-collect-open]");
|
||||||
|
var count = document.querySelector("[data-collect-count]");
|
||||||
|
var form = document.getElementById("pdd-form");
|
||||||
|
var submitButton = document.querySelector("[data-collect-submit]");
|
||||||
|
if (!openButton || !count || !form || !submitButton) return;
|
||||||
|
|
||||||
|
openButton.addEventListener("click", function () {
|
||||||
|
count.textContent = checkedCount();
|
||||||
|
});
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
if (event.submitter !== submitButton) return;
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.setAttribute("aria-busy", "true");
|
||||||
|
submitButton.textContent = "创建中…";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 弹窗 ──────────────────────────────────
|
/* ── 弹窗 ──────────────────────────────────
|
||||||
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
|
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
|
||||||
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
|
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
|
||||||
@@ -375,6 +395,7 @@
|
|||||||
setupUserResetModal();
|
setupUserResetModal();
|
||||||
setupClientAssignmentModal();
|
setupClientAssignmentModal();
|
||||||
setupPurchaseModal();
|
setupPurchaseModal();
|
||||||
|
setupCollectModal();
|
||||||
setupModals();
|
setupModals();
|
||||||
setupRowDetail();
|
setupRowDetail();
|
||||||
setupCaptchaRefresh();
|
setupCaptchaRefresh();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
|
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
|
||||||
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
|
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
|
||||||
<button type="submit" form="pdd-form" formaction="/pdd/collect"
|
<button type="button" data-modal-open="collect-modal" data-collect-open
|
||||||
data-need-checked>创建采集任务</button>
|
data-need-checked>创建采集任务</button>
|
||||||
|
|
||||||
<form class="inline grow" method="get" action="/pdd">
|
<form class="inline grow" method="get" action="/pdd">
|
||||||
@@ -139,6 +139,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ── 批量采集确认弹窗 ─────────────────────────
|
||||||
|
客户端是可选项,默认留空。服务端仍会按当前账号重新校验候选范围。 */}}
|
||||||
|
<div class="modal-backdrop" id="collect-modal" hidden>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="collect-modal-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="collect-modal-title">创建采集任务</h2>
|
||||||
|
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>将为已选择的 <strong data-collect-count>0</strong> 个商品创建采集任务。</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="collect-client">执行客户端</label>
|
||||||
|
<select id="collect-client" name="client_id" form="pdd-form" autofocus>
|
||||||
|
<option value="">不指定(任意客户端可领取)</option>
|
||||||
|
{{range .AssignableClients}}
|
||||||
|
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
通常保持默认即可。指定后,任务只等待该客户端领取;客户端离线时不会自动改派。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
<button type="submit" form="pdd-form" formaction="/pdd/collect" class="primary"
|
||||||
|
data-collect-submit>确认创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
||||||
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
|
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
|
||||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
||||||
|
|||||||
Reference in New Issue
Block a user