feat: 增加档口入库码独立页面 (#233)

This commit is contained in:
chengma
2026-08-15 08:59:40 +08:00
parent 9961f0f538
commit 7665fd93ed
10 changed files with 739 additions and 4 deletions
+167
View File
@@ -0,0 +1,167 @@
package web
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/service"
"cmautobuy/admin/syb"
)
var innerCodeLocation = time.FixedZone("UTC+8", 8*60*60)
// InnerCodeList 渲染独立的档口入库码工作台。
func (h *Handler) InnerCodeList(c *gin.Context) {
businessDate := strings.TrimSpace(c.Query("date"))
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
businessDate = time.Now().In(innerCodeLocation).Format("2006-01-02")
}
status := strings.TrimSpace(c.Query("status"))
keyword := strings.TrimSpace(c.Query("q"))
result, err := service.ListInnerCodePage(h.db, businessDate, status, keyword, service.ParsePage(c.Query("page")))
if err != nil {
fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。")
return
}
query := url.Values{"date": {businessDate}}
if result.Status != "" {
query.Set("status", result.Status)
}
if keyword != "" {
query.Set("q", keyword)
}
c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{
"BusinessDate": businessDate,
"StatusFilter": result.Status,
"Keyword": keyword,
"StatusOptions": service.InnerCodeStatusOptions(),
"Rows": result.Rows,
"HasAny": result.Counts.Total > 0,
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"Message": strings.TrimSpace(c.Query("message")),
"Status": service.InnerCodeStatusMessage(result),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, query.Encode()),
}))
}
// InnerCodeImport 接收一次性 xlsx,导入完成后总会删除临时文件。
func (h *Handler) InnerCodeImport(c *gin.Context) {
businessDate := strings.TrimSpace(c.PostForm("date"))
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
fail(c, http.StatusBadRequest, "请选择合法的业务日期;没有导入任何数据。")
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "没有收到 Excel(也可能超过 10MB);没有导入任何数据。")
return
}
src, err := fileHeader.Open()
if err != nil {
fail(c, http.StatusBadRequest, "无法打开上传文件;没有导入任何数据。")
return
}
defer src.Close()
head := make([]byte, 8)
n, _ := io.ReadFull(src, head)
if err := service.ValidateInnerCodeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil {
fail(c, http.StatusBadRequest, err.Error()+";没有导入任何数据。")
return
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusInternalServerError, "读取上传文件失败;没有导入任何数据。")
return
}
uploadDir, err := config.SubDir("uploads")
if err != nil {
fail(c, http.StatusInternalServerError, "无法准备临时上传目录;没有导入任何数据。")
return
}
tmp, err := os.CreateTemp(uploadDir, "inner-code-*.xlsx")
if err != nil {
fail(c, http.StatusInternalServerError, "无法保存临时上传文件;没有导入任何数据。")
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。")
return
}
if err := tmp.Close(); err != nil {
fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。")
return
}
result, err := service.ImportInnerCodeExcel(h.db, tmpPath, businessDate, currentUser(c).UserID)
if err != nil {
if service.IsInvalidInnerCodeImport(err) {
fail(c, http.StatusBadRequest, err.Error()+";请修正文件后重新导入。")
return
}
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
return
}
message := fmt.Sprintf("导入完成:读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。",
result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows)
h.innerCodeRedirect(c, businessDate, "", "", 1, message)
}
// InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。
func (h *Handler) InnerCodeMatch(c *gin.Context) {
businessDate := strings.TrimSpace(c.PostForm("date"))
status, keyword := c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page"))
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, "业务日期无效,没有执行匹配。")
return
}
cfg, err := config.Load()
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。")
return
}
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝地址配置有误,没有执行匹配。")
return
}
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
message := "校验顺运宝会话失败,没有执行匹配。"
if errors.Is(err, service.ErrSybLoginRequired) {
message = "顺运宝会话已过期,请先到“顺运宝数据”页面登录后再匹配。"
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
return
}
result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error())
return
}
message := fmt.Sprintf("匹配完成:处理 %d 条,可回写 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条。",
result.Total, result.Ready, result.AlreadyFilled, result.Skipped, result.Failed)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
}
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) {
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}}
if strings.TrimSpace(status) != "" {
values.Set("status", strings.TrimSpace(status))
}
if strings.TrimSpace(keyword) != "" {
values.Set("q", strings.TrimSpace(keyword))
}
c.Redirect(http.StatusSeeOther, "/inner-codes?"+values.Encode())
}
+9 -3
View File
@@ -115,19 +115,25 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
clients.POST("/unassign", h.ClientUnassign)
clients.POST("/delete", h.ClientDelete)
// 6. 用户管理:先经过网页登录,再叠加管理员角色校验。
// 6. 档口入库码:独立于顺运宝数据页,导航位置紧跟客户端列表。
pages.GET("/inner-codes", h.InnerCodeList)
innerCodes := pages.Group("/inner-codes")
innerCodes.POST("/import", h.InnerCodeImport)
innerCodes.POST("/match", h.InnerCodeMatch)
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
users := pages.Group("/users", AdminRequired())
users.GET("", h.UserList)
users.POST("/create", h.UserCreate)
users.POST("/status", h.UserSetStatus)
users.POST("/reset-password", h.UserResetPassword)
// 7. 第三方商品目录导入记录:系统级审计仅管理员可见。
// 8. 第三方商品目录导入记录:系统级审计仅管理员可见。
integrations := pages.Group("/integrations", AdminRequired())
integrations.GET("/catalog", h.CatalogHistory)
integrations.GET("/catalog/detail", h.CatalogHistoryDetail)
// 8. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
// 9. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
aiSettings := pages.Group("/settings/ai", AdminRequired())
aiSettings.GET("", h.AIConfigList)
aiSettings.POST("/save", h.AIConfigSave)
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"html/template"
"os"
"strings"
"testing"
)
func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
if _, err := template.ParseFS(templateFS, "templates/*/*.html"); err != nil {
t.Fatalf("模板必须可解析: %v", err)
}
header, err := os.ReadFile("templates/partials/header.html")
if err != nil {
t.Fatal(err)
}
headerText := string(header)
clientIndex := strings.Index(headerText, `href="/clients"`)
innerCodeIndex := strings.Index(headerText, `href="/inner-codes"`)
if clientIndex < 0 || innerCodeIndex < 0 || innerCodeIndex < clientIndex {
t.Fatal("档口入库码导航必须紧跟在客户端列表之后")
}
raw, err := os.ReadFile("templates/inner_code/list.html")
if err != nil {
t.Fatal(err)
}
page := string(raw)
for _, want := range []string{
`action="/inner-codes/import"`, `action="/inner-codes/match"`,
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`,
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`,
`{{if not .CanSelect}}disabled{{end}}`, `aria-label="档口入库码记录列表"`,
} {
if !strings.Contains(page, want) {
t.Errorf("正式页面缺少 %s", want)
}
}
}
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
raw, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page"]`) {
t.Fatal("档口入库码页面切换模块后应保持稳定筛选和页码")
}
}
+105
View File
@@ -15,6 +15,20 @@ type InnerCodeSybSnapshot struct {
SybData string
}
// InnerCodeListFilter 是独立页面可组合的查询条件。
type InnerCodeListFilter struct {
BusinessDate string
Status string
Keyword string
}
// InnerCodeStatusCounts 是当前业务日期的底栏摘要。
type InnerCodeStatusCounts struct {
Total int
Ready int
NeedsCheck int
}
// InnerCodeImportOutcome 说明幂等导入是新增还是更新。
type InnerCodeImportOutcome string
@@ -169,6 +183,97 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
return nil
}
const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequence,0),order_number,
COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count,
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''),
COALESCE(applied_at,''),created_at,updated_at`
func innerCodeFilterClause(filter InnerCodeListFilter) (string, []any) {
clauses := []string{"business_date=?"}
args := []any{filter.BusinessDate}
if filter.Status != "" {
clauses = append(clauses, "status=?")
args = append(args, filter.Status)
}
if filter.Keyword != "" {
like := "%" + escapeLike(filter.Keyword) + "%"
clauses = append(clauses, `(order_number LIKE ? ESCAPE '!' OR stall LIKE ? ESCAPE '!'
OR spec_raw LIKE ? ESCAPE '!' OR inner_code LIKE ? ESCAPE '!')`)
args = append(args, like, like, like, like)
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ListInnerCodeRecords 分页读取独立页面记录。
func ListInnerCodeRecords(q Execer, filter InnerCodeListFilter, limit, offset int) ([]model.InnerCodeRecord, error) {
where, args := innerCodeFilterClause(filter)
query := `SELECT ` + innerCodeListColumns + ` FROM syb_inner_code_records` + where +
` ORDER BY source_row,id LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询档口入库码列表失败: %w", err)
}
defer rows.Close()
result := make([]model.InnerCodeRecord, 0)
for rows.Next() {
row, err := scanInnerCodeRecord(rows)
if err != nil {
return nil, err
}
result = append(result, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历档口入库码列表失败: %w", err)
}
return result, nil
}
// CountInnerCodeRecords 统计与列表完全相同的筛选结果。
func CountInnerCodeRecords(q Execer, filter InnerCodeListFilter) (int, error) {
where, args := innerCodeFilterClause(filter)
var count int
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`+where, args...).Scan(&count); err != nil {
return 0, fmt.Errorf("统计档口入库码列表失败: %w", err)
}
return count, nil
}
// CountInnerCodeStatuses 统计当前业务日期总量、可回写和需核对数量。
func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCounts, error) {
var result InnerCodeStatusCounts
err := q.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
FROM syb_inner_code_records WHERE business_date=?`, businessDate).
Scan(&result.Total, &result.Ready, &result.NeedsCheck)
if err != nil {
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
}
return result, nil
}
type innerCodeRowScanner interface {
Scan(...any) error
}
func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, error) {
var row model.InnerCodeRecord
err := scanner.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence,
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey,
&row.InnerCode, &row.SourceDuplicateCount, &row.StockID, &row.DetailID,
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
&row.AppliedAt, &row.CreatedAt, &row.UpdatedAt)
if err != nil {
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
}
return row, nil
}
func nullablePositiveInt(value int) any {
if value <= 0 {
return nil
+143
View File
@@ -0,0 +1,143 @@
package service
import (
"database/sql"
"fmt"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// InnerCodeStatusOption 是页面状态筛选项。
type InnerCodeStatusOption struct {
Value string
Text string
}
var innerCodeStatusOptions = []InnerCodeStatusOption{
{"", "全部状态"},
{string(model.InnerCodePending), "待匹配"},
{string(model.InnerCodeReady), "可回写"},
{string(model.InnerCodeApplying), "回写中"},
{string(model.InnerCodeUpdated), "已回写"},
{string(model.InnerCodeAlreadyFilled), "已存在"},
{string(model.InnerCodeSkipped), "已跳过"},
{string(model.InnerCodeFailed), "失败"},
{string(model.InnerCodeNeedsCheck), "需核对"},
}
// InnerCodeRowView 是正式页面的一行。
type InnerCodeRowView struct {
ID int64
BusinessDate string
OrderNumber string
Stall string
SpecRaw string
SybSpec string
InnerCode string
RemoteInnerCode string
Status string
StatusText string
StatusClass string
ResultMessage string
UpdatedAt string
CanSelect bool
HasRemoteOldCode bool
CanRecheck bool
}
// InnerCodeListPage 是列表、分页和底栏统计。
type InnerCodeListPage struct {
Rows []InnerCodeRowView
Total int
Page int
TotalPages int
Status string
Keyword string
Date string
Counts repository.InnerCodeStatusCounts
IsFiltered bool
}
// InnerCodeStatusOptions 返回稳定顺序的筛选项副本。
func InnerCodeStatusOptions() []InnerCodeStatusOption {
return append([]InnerCodeStatusOption(nil), innerCodeStatusOptions...)
}
// ListInnerCodePage 校验筛选并返回统一 20 条 SQL 分页结果。
func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, requestedPage int) (*InnerCodeListPage, error) {
status = strings.TrimSpace(status)
if !validInnerCodeStatusFilter(status) {
status = ""
}
keyword = strings.TrimSpace(keyword)
filter := repository.InnerCodeListFilter{BusinessDate: businessDate, Status: status, Keyword: keyword}
total, err := repository.CountInnerCodeRecords(db, filter)
if err != nil {
return nil, err
}
totalPages := TotalPages(total)
page := ClampPage(requestedPage, totalPages)
records, err := repository.ListInnerCodeRecords(db, filter, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
counts, err := repository.CountInnerCodeStatuses(db, businessDate)
if err != nil {
return nil, err
}
result := &InnerCodeListPage{Total: total, Page: page, TotalPages: totalPages,
Status: status, Keyword: keyword, Date: businessDate, Counts: counts,
IsFiltered: status != "" || keyword != ""}
result.Rows = make([]InnerCodeRowView, 0, len(records))
for _, record := range records {
result.Rows = append(result.Rows, innerCodeRowView(record))
}
return result, nil
}
func validInnerCodeStatusFilter(status string) bool {
for _, option := range innerCodeStatusOptions {
if option.Value == status {
return true
}
}
return false
}
func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
statusText := map[model.InnerCodeStatus]string{
model.InnerCodePending: "待匹配", model.InnerCodeReady: "可回写",
model.InnerCodeApplying: "回写中", model.InnerCodeUpdated: "已回写",
model.InnerCodeAlreadyFilled: "已存在", model.InnerCodeSkipped: "已跳过",
model.InnerCodeFailed: "失败", model.InnerCodeNeedsCheck: "需核对",
}[record.Status]
if statusText == "" {
statusText = "未知"
}
return InnerCodeRowView{
ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber,
Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw),
SybSpec: displayInnerCodeValue(record.SybSpec), InnerCode: record.InnerCode,
RemoteInnerCode: displayInnerCodeValue(record.RemoteInnerCode), Status: string(record.Status),
StatusText: statusText, StatusClass: "inner-code-status-" + string(record.Status),
ResultMessage: displayInnerCodeValue(record.ResultMessage), UpdatedAt: formatLocalTime(record.UpdatedAt),
CanSelect: record.Status == model.InnerCodeReady,
HasRemoteOldCode: record.Status == model.InnerCodeReady && record.RemoteInnerCode != "",
CanRecheck: record.Status == model.InnerCodeNeedsCheck,
}
}
func displayInnerCodeValue(value string) string {
if strings.TrimSpace(value) == "" {
return "—"
}
return value
}
// InnerCodeStatusMessage 生成底栏可读统计。
func InnerCodeStatusMessage(page *InnerCodeListPage) string {
return fmt.Sprintf("共 %d 条,可回写 %d 条,需核对 %d 条;当前筛选 %d 条",
page.Counts.Total, page.Counts.Ready, page.Counts.NeedsCheck, page.Total)
}
+30
View File
@@ -0,0 +1,30 @@
package service
import (
"testing"
"cmautobuy/admin/model"
)
func TestInnerCodeRowView_只有可回写记录可选择(t *testing.T) {
for _, status := range []model.InnerCodeStatus{
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying,
model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped,
model.InnerCodeFailed, model.InnerCodeNeedsCheck,
} {
view := innerCodeRowView(model.InnerCodeRecord{Status: status, RemoteInnerCode: "OLD"})
if view.CanSelect != (status == model.InnerCodeReady) {
t.Errorf("status=%s CanSelect=%v", status, view.CanSelect)
}
if view.CanRecheck != (status == model.InnerCodeNeedsCheck) {
t.Errorf("status=%s CanRecheck=%v", status, view.CanRecheck)
}
}
}
func TestInnerCodeStatusOptions_覆盖所有状态(t *testing.T) {
options := InnerCodeStatusOptions()
if len(options) != 9 || options[0].Value != "" {
t.Fatalf("状态筛选项不完整: %+v", options)
}
}
+24
View File
@@ -615,6 +615,30 @@ input.wide { width: 100%; }
}
.status-active { color: #1a7f37; background: #f0fff4; }
.status-disabled { color: #777; background: #f3f4f6; }
.inner-code-status-ready,
.inner-code-status-updated,
.inner-code-status-already_filled { color: #1a7f37; background: #f0fff4; }
.inner-code-status-applying { color: #0969da; background: #eef6ff; }
.inner-code-status-skipped,
.inner-code-status-needs_check { color: #7a4b00; background: #fff8c5; }
.inner-code-status-failed { color: #b42318; background: #fff1f0; }
.inner-code-status-pending { color: #57606a; background: #f3f4f6; }
.inner-code-toolbar { flex: 0 0 auto; }
.inner-code-toolbar input[type="date"] { width: 138px; }
.inner-code-toolbar .inner-code-search { width: 220px; }
.inner-code-toolbar .file-field {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #57606a;
}
.inner-code-notice { flex: 0 0 auto; margin: -4px 0 10px; }
.inner-code-table-wrap { flex: 1 1 auto; min-height: 180px; overflow: auto; scrollbar-gutter: stable; }
.inner-code-table { min-width: 1500px; }
.inner-code-table th { position: sticky; top: 0; z-index: 1; }
.inner-code-table .inner-code-message { min-width: 260px; max-width: 360px; }
.mapping-source { white-space: nowrap; font-size: 12px; }
.source-ai { color: #6f42c1; background: #f7f0ff; }
.source-rule { color: #0969da; background: #eef6ff; }
+66
View File
@@ -23,6 +23,7 @@
"/shops": [],
"/tasks": ["type", "status", "creator", "q", "page"],
"/clients": ["name", "page"],
"/inner-codes": ["date", "status", "q", "page"],
"/users": ["q", "status", "page"]
};
var MODULE_STATE_PREFIX = "cmautobuy:module-state:";
@@ -348,6 +349,70 @@
});
}
/* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单,
并在确认弹窗里显示当前选中数量。匹配和回写规则仍全部在服务端。 */
function setupInnerCodePage() {
var root = document.querySelector("[data-inner-code-page]");
if (!root) return;
var dateInput = root.querySelector("[data-inner-code-date]");
var fileInput = root.querySelector("[data-file-input]");
var fileName = root.querySelector("[data-file-name]");
var fileOpen = root.querySelector("[data-inner-code-file-open]");
var applyButton = root.querySelector("[data-inner-code-apply-open]");
function syncDate() {
document.querySelectorAll("[data-inner-code-date-field]").forEach(function (field) {
field.value = dateInput ? dateInput.value : field.value;
});
}
function updateSelectionSummary() {
var selected = Array.prototype.slice.call(document.querySelectorAll(
'input[name="ids"][form="inner-code-apply-form"]:checked:not(:disabled)'
));
var replacements = selected.filter(function (box) {
return box.getAttribute("data-inner-code-old-value") === "1";
}).length;
document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]")
.forEach(function (node) { node.textContent = selected.length; });
document.querySelectorAll("[data-inner-code-replace-count]")
.forEach(function (node) { node.textContent = replacements; });
}
if (dateInput) dateInput.addEventListener("change", syncDate);
if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); });
if (fileInput && fileName) {
fileInput.addEventListener("change", function () {
fileName.textContent = fileInput.files.length ? fileInput.files[0].name : "未选择文件";
fileName.title = fileName.textContent;
});
}
root.addEventListener("change", updateSelectionSummary);
var table = document.querySelector(".inner-code-table");
if (table) table.addEventListener("change", updateSelectionSummary);
if (applyButton) applyButton.addEventListener("click", updateSelectionSummary);
var matchForm = document.getElementById("inner-code-match-form");
if (matchForm) matchForm.addEventListener("submit", function () {
var button = document.querySelector('[form="inner-code-match-form"]');
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "匹配中…";
}
});
var applyForm = document.getElementById("inner-code-apply-form");
if (applyForm) applyForm.addEventListener("submit", function () {
var button = document.querySelector('#inner-code-apply-modal button[type="submit"]');
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "回写中…";
}
});
syncDate();
updateSelectionSummary();
}
/* ── 弹窗 ──────────────────────────────────
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
@@ -759,6 +824,7 @@
setupPurchaseModal();
setupCollectModals();
setupUploadFeedback();
setupInnerCodePage();
setupModals();
setupRowDetail();
setupImagePreview();
+143
View File
@@ -0,0 +1,143 @@
{{define "inner_code/list"}}
{{template "header" .}}
<div class="toolbar inner-code-toolbar" data-inner-code-page>
<div class="inline">
<label for="inner-code-date">业务日期</label>
<input id="inner-code-date" type="date" value="{{.BusinessDate}}" data-inner-code-date>
</div>
<form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data"
data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<button type="button" data-inner-code-file-open>选择 Excel</button>
<input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden>
<span class="file-field" data-file-name>未选择文件</span>
<button type="submit" data-upload-submit>导入</button>
</form>
<form id="inner-code-filter" class="inline grow" method="get" action="/inner-codes">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<label for="inner-code-status">状态</label>
<select id="inner-code-status" name="status">
{{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}}
</select>
<label class="visually-hidden" for="inner-code-keyword">搜索订单号、档口、规格或入库码</label>
<input id="inner-code-keyword" class="inner-code-search" type="text" name="q" value="{{.Keyword}}"
placeholder="订单号 / 档口 / 规格 / 入库码">
<button type="submit">搜索</button>
<a class="button-link" href="/inner-codes?date={{.BusinessDate}}">清除</a>
</form>
<form id="inner-code-match-form" method="post" action="/inner-codes/match" hidden>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</form>
<button type="submit" class="primary" form="inner-code-match-form">匹配顺运宝商品</button>
<form id="inner-code-apply-form" method="post" action="/inner-codes/apply" hidden>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</form>
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
data-inner-code-apply-open disabled>回写已选 <span data-inner-code-selected-count>0</span> 条</button>
</div>
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
<div class="table-wrap inner-code-table-wrap" role="region" aria-label="档口入库码记录列表" tabindex="0">
<table class="inner-code-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可回写记录"></th>
<th>业务日期</th>
<th>订单号</th>
<th>档口及货号</th>
<th>Excel 颜色尺码</th>
<th>顺运宝规格</th>
<th>档口入库码</th>
<th>远端原值</th>
<th>状态</th>
<th>结果 / 原因</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" name="ids" value="{{.ID}}" form="inner-code-apply-form"
data-select-action="inner-code-apply" data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanSelect}}disabled{{end}}>
</td>
<td>{{.BusinessDate}}</td>
<td>{{.OrderNumber}}</td>
<td class="truncate" title="{{.Stall}}">{{.Stall}}</td>
<td class="truncate" title="{{.SpecRaw}}">{{.SpecRaw}}</td>
<td class="truncate" title="{{.SybSpec}}">{{.SybSpec}}</td>
<td>{{.InnerCode}}</td>
<td>{{.RemoteInnerCode}}</td>
<td><span class="status-pill {{.StatusClass}}">{{.StatusText}}</span></td>
<td class="inner-code-message"><span class="truncate" title="{{.ResultMessage}}">{{.ResultMessage}}</span></td>
<td>{{.UpdatedAt}}</td>
<td>
{{if .CanRecheck}}
<form method="post" action="/inner-codes/recheck">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="date" value="{{$.BusinessDate}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<button type="submit" class="link-button">重新核对</button>
</form>
{{else}}—{{end}}
</td>
</tr>
{{else}}
<tr class="empty"><td colspan="12">
{{if .IsFiltered}}
当前筛选条件下没有记录。<br><small>请清除状态或关键词后再试。</small>
{{else if .HasAny}}
当前业务日期没有记录。<br><small>请选择其他日期,或导入这个日期的 Excel。</small>
{{else}}
还没有档口入库码记录。<br><small>选择业务日期并导入线下 Excel 后开始匹配。</small>
{{end}}
</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="modal-backdrop" id="inner-code-apply-modal" hidden>
<section class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="inner-code-apply-title">
<div class="modal-head">
<h2 id="inner-code-apply-title">确认回写档口入库码</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,其中
<strong data-inner-code-replace-count>0</strong> 条有旧快递单号,需要先删除旧值再写入。</p>
<ul>
<li>每条写入前会重新读取顺运宝详情并核验商品。</li>
<li>写入后会再次读取;只有远端值一致才标记“已回写”。</li>
<li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li>
</ul>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary" form="inner-code-apply-form">确认回写</button>
</div>
</section>
</div>
{{template "footer" .}}
{{end}}
+2 -1
View File
@@ -6,7 +6,7 @@
<title>{{.Title}} · 采集采购管理端</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body{{if eq .Active "syb"}} class="syb-page-body"{{end}}>
<body{{if or (eq .Active "syb") (eq .Active "inner-codes")}} class="syb-page-body"{{end}}>
<nav class="nav" data-module-navigation data-ui-state-user="{{if .CurrentUser}}{{.CurrentUser.UserID}}{{end}}">
<span class="nav-brand">采集采购管理端</span>
@@ -15,6 +15,7 @@
<a href="/syb" data-module-root="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" data-module-root="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
<a href="/clients" data-module-root="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
<a href="/inner-codes" data-module-root="/inner-codes" class="{{if eq .Active "inner-codes"}}active{{end}}">档口入库码</a>
{{if and .CurrentUser .CurrentUser.IsAdmin}}
<a href="/shops" data-module-root="/shops" class="{{if eq .Active "shops"}}active{{end}}">店铺管理</a>
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>