feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListLimit = 20
|
||||
maxRequestBytes = 21 << 20
|
||||
maxTitleRunes = 120
|
||||
maxTitleBytes = 2048
|
||||
maxSKUBytes = 512
|
||||
maxDescriptionBytes = 8192
|
||||
csrfCookieName = "cmroubao_admin_csrf"
|
||||
csrfFormField = "csrf_token"
|
||||
formContentType = "text/html; charset=utf-8"
|
||||
cssContentType = "text/css; charset=utf-8"
|
||||
javascriptContentType = "text/javascript; charset=utf-8"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
renderer *Renderer
|
||||
}
|
||||
|
||||
func NewHandler(service Service, renderer *Renderer) (*Handler, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("admin web service is required")
|
||||
}
|
||||
if renderer == nil {
|
||||
return nil, errors.New("admin web renderer is required")
|
||||
}
|
||||
return &Handler{
|
||||
service: service,
|
||||
renderer: renderer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Register(routes gin.IRoutes) {
|
||||
routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet)
|
||||
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
|
||||
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
|
||||
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
|
||||
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
||||
routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail)
|
||||
routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask)
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; base-uri 'none'; connect-src 'self'; "+
|
||||
"form-action 'self'; frame-ancestors 'none'; img-src 'self' blob: data:; "+
|
||||
"script-src 'self'; style-src 'self'",
|
||||
)
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.Header("Referrer-Policy", "no-referrer")
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Header("X-Frame-Options", "DENY")
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Stylesheet(ctx *gin.Context) {
|
||||
h.serveStatic(ctx, "admin.css", cssContentType)
|
||||
}
|
||||
|
||||
func (h *Handler) Script(ctx *gin.Context) {
|
||||
h.serveStatic(ctx, "admin.js", javascriptContentType)
|
||||
}
|
||||
|
||||
func (h *Handler) serveStatic(
|
||||
ctx *gin.Context,
|
||||
name string,
|
||||
contentType string,
|
||||
) {
|
||||
content, err := staticFile(name)
|
||||
if err != nil {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.Data(http.StatusOK, contentType, content)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTasks(ctx *gin.Context) {
|
||||
input := ListTasksInput{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
Cursor: strings.TrimSpace(ctx.Query("cursor")),
|
||||
Limit: defaultListLimit,
|
||||
}
|
||||
result, err := h.service.ListTasks(ctx.Request.Context(), input)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载任务列表,请稍后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]taskSummaryView, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
items = append(items, taskSummaryView{
|
||||
ID: item.ID,
|
||||
Title: item.Title,
|
||||
SKU: item.SKU,
|
||||
Status: item.Status,
|
||||
StatusLabel: statusLabel(item.Status),
|
||||
StatusClass: statusClass(item.Status),
|
||||
DeviceName: fallback(item.DeviceName, "尚未分配"),
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
statusOptions := newStatusOptions(input.Status)
|
||||
page := tasksPage{
|
||||
Page: pageView{
|
||||
Title: "采购任务",
|
||||
TasksCurrent: true,
|
||||
},
|
||||
Query: input.Query,
|
||||
Status: input.Status,
|
||||
StatusOptions: statusOptions,
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "tasks", page)
|
||||
}
|
||||
|
||||
func (h *Handler) NewTask(ctx *gin.Context) {
|
||||
token, err := h.csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
page, err := newTaskPage(token)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "task-new", page)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateTask(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(
|
||||
ctx.Writer,
|
||||
ctx.Request.Body,
|
||||
maxRequestBytes,
|
||||
)
|
||||
if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil {
|
||||
page := h.createPageFromRequest(ctx)
|
||||
page.Notice = "提交内容过大或格式不正确,请检查参考图片。"
|
||||
page.Errors.Image = "请选择符合大小限制的 JPG、PNG 或 WebP 图片。"
|
||||
h.render(ctx, http.StatusRequestEntityTooLarge, "task-new", page)
|
||||
return
|
||||
}
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
defer ctx.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
if !h.validCSRF(ctx) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
"请求已失效",
|
||||
"请返回新建任务页面后重新提交。",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
page := h.createPageFromRequest(ctx)
|
||||
if validateCreateForm(&page) {
|
||||
h.render(ctx, http.StatusUnprocessableEntity, "task-new", page)
|
||||
return
|
||||
}
|
||||
|
||||
asset := UploadedAsset{
|
||||
ID: strings.TrimSpace(ctx.PostForm("image_asset_id")),
|
||||
}
|
||||
if asset.ID == "" {
|
||||
uploaded, uploadErr := h.uploadReference(ctx, page.UploadKey)
|
||||
if uploadErr != nil {
|
||||
page.Notice = "参考图片未通过校验,请重新选择。"
|
||||
page.Errors.Image = "图片格式、大小或内容不符合要求。"
|
||||
status := http.StatusUnprocessableEntity
|
||||
if !errors.Is(uploadErr, ErrInvalidFile) &&
|
||||
!errors.Is(uploadErr, ErrValidation) {
|
||||
page.Notice = "参考图片上传失败,请稍后重试。"
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
h.render(ctx, status, "task-new", page)
|
||||
return
|
||||
}
|
||||
asset = uploaded
|
||||
page.UploadedAsset = uploadedAssetView{
|
||||
ID: uploaded.ID,
|
||||
}
|
||||
}
|
||||
|
||||
task, err := h.service.CreateTask(
|
||||
ctx.Request.Context(),
|
||||
CreateTaskInput{
|
||||
IdempotencyKey: page.CreateKey,
|
||||
Title: page.Form.Title,
|
||||
SKU: page.Form.SKU,
|
||||
Description: page.Form.Description,
|
||||
Quantity: page.Form.QuantityValue,
|
||||
MaxBudget: page.Form.MaxBudget,
|
||||
ImageAssetID: asset.ID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
page.UploadedAsset = uploadedAssetView{
|
||||
ID: asset.ID,
|
||||
}
|
||||
page.Notice = createErrorMessage(err)
|
||||
status := serviceErrorStatus(err)
|
||||
h.render(ctx, status, "task-new", page)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID))
|
||||
}
|
||||
|
||||
func (h *Handler) TaskDetail(ctx *gin.Context) {
|
||||
task, err := h.service.GetTask(
|
||||
ctx.Request.Context(),
|
||||
strings.TrimSpace(ctx.Param("id")),
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, tokenErr := h.csrfToken(ctx)
|
||||
if tokenErr != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
cancelKey, keyErr := newToken()
|
||||
if keyErr != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
page := taskDetailPage{
|
||||
Page: pageView{
|
||||
Title: "任务详情",
|
||||
TasksCurrent: true,
|
||||
},
|
||||
Task: taskDetailViewFrom(task),
|
||||
CSRFToken: token,
|
||||
CancelKey: cancelKey,
|
||||
Notice: detailNotice(ctx.Query("notice")),
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "task-detail", page)
|
||||
}
|
||||
|
||||
func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||
if !h.validCSRF(ctx) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
"请求已失效",
|
||||
"请返回任务详情后重新操作。",
|
||||
)
|
||||
return
|
||||
}
|
||||
taskID := strings.TrimSpace(ctx.Param("id"))
|
||||
cancelKey := strings.TrimSpace(ctx.PostForm("cancel_key"))
|
||||
if !validToken(cancelKey) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
"请求已失效",
|
||||
"请返回任务详情后重新操作。",
|
||||
)
|
||||
return
|
||||
}
|
||||
_, err := h.service.CancelPending(
|
||||
ctx.Request.Context(),
|
||||
CancelPendingInput{
|
||||
TaskID: taskID,
|
||||
IdempotencyKey: cancelKey,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrConflict) {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/tasks/"+pathEscape(taskID)+"?notice=cancel-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
h.renderServiceError(ctx, err, "取消失败,请稍后重试。")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/tasks/"+pathEscape(taskID)+"?notice=canceled",
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadReference(
|
||||
ctx *gin.Context,
|
||||
idempotencyKey string,
|
||||
) (UploadedAsset, error) {
|
||||
file, header, err := ctx.Request.FormFile("image")
|
||||
if err != nil {
|
||||
return UploadedAsset{}, ErrInvalidFile
|
||||
}
|
||||
defer file.Close()
|
||||
return h.service.UploadReference(
|
||||
ctx.Request.Context(),
|
||||
UploadReferenceInput{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
DeclaredType: header.Header.Get("Content-Type"),
|
||||
DeclaredSize: header.Size,
|
||||
Content: file,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
||||
token, err := h.csrfToken(ctx)
|
||||
if err != nil {
|
||||
token = ""
|
||||
}
|
||||
quantityText := strings.TrimSpace(ctx.PostForm("quantity"))
|
||||
quantity, _ := strconv.ParseInt(quantityText, 10, 64)
|
||||
return newTaskPageView{
|
||||
Page: pageView{
|
||||
Title: "新建采购任务",
|
||||
NewCurrent: true,
|
||||
},
|
||||
CSRFToken: token,
|
||||
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
|
||||
CreateKey: strings.TrimSpace(ctx.PostForm("create_key")),
|
||||
Form: createFormView{
|
||||
Title: strings.TrimSpace(ctx.PostForm("title")),
|
||||
SKU: strings.TrimSpace(ctx.PostForm("sku")),
|
||||
Description: strings.TrimSpace(ctx.PostForm("description")),
|
||||
Quantity: quantityText,
|
||||
QuantityValue: quantity,
|
||||
MaxBudget: strings.TrimSpace(ctx.PostForm("max_budget")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreateForm(page *newTaskPageView) bool {
|
||||
if !validToken(page.UploadKey) {
|
||||
page.Errors.Form = "提交标识缺失,请刷新页面后重试。"
|
||||
}
|
||||
if !validToken(page.CreateKey) {
|
||||
page.Errors.Form = "提交标识缺失,请刷新页面后重试。"
|
||||
}
|
||||
if page.CSRFToken == "" {
|
||||
page.Errors.Form = "页面已失效,请刷新后重试。"
|
||||
}
|
||||
if page.Form.Title == "" {
|
||||
page.Errors.Title = "请输入商品标题。"
|
||||
} else if !utf8.ValidString(page.Form.Title) ||
|
||||
utf8.RuneCountInString(page.Form.Title) > maxTitleRunes ||
|
||||
len([]byte(page.Form.Title)) > maxTitleBytes {
|
||||
page.Errors.Title = "商品标题不能超过 120 个字符。"
|
||||
}
|
||||
if page.Form.SKU == "" {
|
||||
page.Errors.SKU = "请输入 SKU。"
|
||||
} else if !utf8.ValidString(page.Form.SKU) ||
|
||||
len([]byte(page.Form.SKU)) > maxSKUBytes {
|
||||
page.Errors.SKU = "SKU 不能超过 512 个 UTF-8 字节。"
|
||||
}
|
||||
if !utf8.ValidString(page.Form.Description) ||
|
||||
len([]byte(page.Form.Description)) > maxDescriptionBytes {
|
||||
page.Errors.Description = "商品描述不能超过 8192 个 UTF-8 字节。"
|
||||
}
|
||||
if page.Form.Quantity == "" || page.Form.QuantityValue <= 0 {
|
||||
page.Errors.Quantity = "数量必须是大于 0 的整数。"
|
||||
} else if strconv.FormatInt(page.Form.QuantityValue, 10) != page.Form.Quantity {
|
||||
page.Errors.Quantity = "数量必须是大于 0 的整数。"
|
||||
}
|
||||
if !validBudget(page.Form.MaxBudget) {
|
||||
page.Errors.MaxBudget = "最高总预算必须大于 0,且最多两位小数。"
|
||||
}
|
||||
if page.UploadedAsset.ID == "" {
|
||||
// The file itself is validated by the asset service. This only gives
|
||||
// immediate feedback for a completely missing multipart field.
|
||||
}
|
||||
if page.Errors.any() {
|
||||
page.Notice = "请检查表单中的错误后再创建。"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validBudget(value string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
if len(value) > 20 || strings.HasPrefix(value, "+") ||
|
||||
strings.HasPrefix(value, "-") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 {
|
||||
return false
|
||||
}
|
||||
if len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
for _, character := range part {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
units, err := strconv.ParseUint(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
fraction := uint64(0)
|
||||
if len(parts) == 2 {
|
||||
fraction, err = strconv.ParseUint(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(parts[1]) == 1 {
|
||||
fraction *= 10
|
||||
}
|
||||
}
|
||||
return units > 0 || fraction > 0
|
||||
}
|
||||
|
||||
func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
|
||||
if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil &&
|
||||
validToken(cookie.Value) {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/tasks",
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (h *Handler) validCSRF(ctx *gin.Context) bool {
|
||||
cookie, err := ctx.Request.Cookie(csrfCookieName)
|
||||
if err != nil || !validToken(cookie.Value) {
|
||||
return false
|
||||
}
|
||||
formToken := strings.TrimSpace(ctx.PostForm(csrfFormField))
|
||||
if len(cookie.Value) != len(formToken) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(
|
||||
[]byte(cookie.Value),
|
||||
[]byte(formToken),
|
||||
) == 1
|
||||
}
|
||||
|
||||
func validToken(value string) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
value := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, value); err != nil {
|
||||
return "", errors.New("generate form token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func newTaskPage(token string) (newTaskPageView, error) {
|
||||
uploadKey, err := newToken()
|
||||
if err != nil {
|
||||
return newTaskPageView{}, err
|
||||
}
|
||||
createKey, err := newToken()
|
||||
if err != nil {
|
||||
return newTaskPageView{}, err
|
||||
}
|
||||
return newTaskPageView{
|
||||
Page: pageView{
|
||||
Title: "新建采购任务",
|
||||
NewCurrent: true,
|
||||
},
|
||||
CSRFToken: token,
|
||||
UploadKey: uploadKey,
|
||||
CreateKey: createKey,
|
||||
Form: createFormView{
|
||||
Quantity: "1",
|
||||
QuantityValue: 1,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) render(
|
||||
ctx *gin.Context,
|
||||
status int,
|
||||
name string,
|
||||
data any,
|
||||
) {
|
||||
var output bytes.Buffer
|
||||
if err := h.renderer.Execute(&output, name, data); err != nil {
|
||||
ctx.Data(
|
||||
http.StatusInternalServerError,
|
||||
formContentType,
|
||||
[]byte("页面暂时无法显示,请稍后重试。"),
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Data(status, formContentType, output.Bytes())
|
||||
}
|
||||
|
||||
func (h *Handler) renderServiceError(
|
||||
ctx *gin.Context,
|
||||
err error,
|
||||
fallbackMessage string,
|
||||
) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound), errors.Is(err, ErrForbidden):
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusNotFound,
|
||||
"任务不存在",
|
||||
"该任务不存在或当前不可访问。",
|
||||
)
|
||||
case errors.Is(err, ErrValidation):
|
||||
h.renderError(ctx, http.StatusBadRequest, "请求条件不正确", "请检查输入后重试。")
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.renderError(ctx, http.StatusConflict, "任务状态已变化", "请返回任务列表刷新状态。")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
h.renderError(ctx, http.StatusServiceUnavailable, "请求超时", "请稍后重试。")
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
h.renderError(ctx, http.StatusServiceUnavailable, "服务暂时不可用", "请稍后重试。")
|
||||
default:
|
||||
h.renderError(ctx, http.StatusInternalServerError, "操作失败", fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) renderError(
|
||||
ctx *gin.Context,
|
||||
status int,
|
||||
title string,
|
||||
message string,
|
||||
) {
|
||||
h.render(ctx, status, "error", errorPage{
|
||||
Page: pageView{
|
||||
Title: title,
|
||||
},
|
||||
Heading: title,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func serviceErrorStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile):
|
||||
return http.StatusUnprocessableEntity
|
||||
case errors.Is(err, ErrConflict):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return http.StatusServiceUnavailable
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func createErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrValidation):
|
||||
return "任务内容未通过校验,请检查后重试。已上传的参考图片会被复用。"
|
||||
case errors.Is(err, ErrConflict):
|
||||
return "提交标识与原请求不一致,请刷新页面后重试。已上传的参考图片会被保留。"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "创建结果暂时无法确认。请保留当前页面并使用相同提交标识重试。"
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
return "服务暂时不可用。已上传的参考图片会被保留,请稍后重试。"
|
||||
default:
|
||||
return "任务创建失败,请稍后重试。已上传的参考图片会被复用。"
|
||||
}
|
||||
}
|
||||
|
||||
func detailNotice(value string) string {
|
||||
switch value {
|
||||
case "canceled":
|
||||
return "任务已取消,不会自动恢复。"
|
||||
case "cancel-conflict":
|
||||
return "任务状态已变化,当前不能取消。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func fallback(value string, fallbackValue string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallbackValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type pageView struct {
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type tasksPage struct {
|
||||
Page pageView
|
||||
Query string
|
||||
Status string
|
||||
StatusOptions []statusOption
|
||||
Items []taskSummaryView
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type taskSummaryView struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Status string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
DeviceName string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type createFormView struct {
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity string
|
||||
QuantityValue int64
|
||||
MaxBudget string
|
||||
}
|
||||
|
||||
type createFormErrors struct {
|
||||
Form string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity string
|
||||
MaxBudget string
|
||||
Image string
|
||||
}
|
||||
|
||||
func (errors createFormErrors) any() bool {
|
||||
return errors.Form != "" ||
|
||||
errors.Title != "" ||
|
||||
errors.SKU != "" ||
|
||||
errors.Description != "" ||
|
||||
errors.Quantity != "" ||
|
||||
errors.MaxBudget != "" ||
|
||||
errors.Image != ""
|
||||
}
|
||||
|
||||
type uploadedAssetView struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type newTaskPageView struct {
|
||||
Page pageView
|
||||
CSRFToken string
|
||||
UploadKey string
|
||||
CreateKey string
|
||||
Form createFormView
|
||||
Errors createFormErrors
|
||||
Notice string
|
||||
UploadedAsset uploadedAssetView
|
||||
}
|
||||
|
||||
type taskDetailView struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CanCancel bool
|
||||
}
|
||||
|
||||
type taskDetailPage struct {
|
||||
Page pageView
|
||||
Task taskDetailView
|
||||
CSRFToken string
|
||||
CancelKey string
|
||||
Notice string
|
||||
}
|
||||
|
||||
type errorPage struct {
|
||||
Page pageView
|
||||
Heading string
|
||||
Message string
|
||||
}
|
||||
|
||||
func taskDetailViewFrom(task Task) taskDetailView {
|
||||
return taskDetailView{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
SKU: task.SKU,
|
||||
Description: task.Description,
|
||||
Quantity: task.Quantity,
|
||||
MaxBudget: task.MaxBudget,
|
||||
Status: task.Status,
|
||||
StatusLabel: statusLabel(task.Status),
|
||||
StatusClass: statusClass(task.Status),
|
||||
ReferenceAssetID: task.ReferenceAssetID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
CanCancel: task.Status == "PENDING",
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusOptions(selected string) []statusOption {
|
||||
values := []statusOption{
|
||||
{Label: "全部状态"},
|
||||
{Value: "PENDING", Label: "待领取"},
|
||||
{Value: "CLAIMED", Label: "已领取"},
|
||||
{Value: "RUNNING", Label: "执行中"},
|
||||
{Value: "WAITING_CONFIRMATION", Label: "等待人工确认"},
|
||||
{Value: "SUCCEEDED", Label: "验证完成"},
|
||||
{Value: "FAILED", Label: "失败"},
|
||||
{Value: "CANCELED", Label: "已取消"},
|
||||
}
|
||||
for index := range values {
|
||||
values[index].Selected = values[index].Value == selected
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func statusLabel(status string) string {
|
||||
labels := map[string]string{
|
||||
"PENDING": "待领取",
|
||||
"CLAIMED": "已领取",
|
||||
"RUNNING": "执行中",
|
||||
"WAITING_CONFIRMATION": "等待人工确认",
|
||||
"SUCCEEDED": "验证完成",
|
||||
"FAILED": "失败",
|
||||
"CANCELED": "已取消",
|
||||
}
|
||||
return fallback(labels[status], "未知状态")
|
||||
}
|
||||
|
||||
func statusClass(status string) string {
|
||||
switch status {
|
||||
case "PENDING", "CLAIMED", "WAITING_CONFIRMATION":
|
||||
return "status-warn"
|
||||
case "RUNNING":
|
||||
return "status-info"
|
||||
case "SUCCEEDED":
|
||||
return "status-success"
|
||||
case "FAILED", "CANCELED":
|
||||
return "status-danger"
|
||||
default:
|
||||
return "status-neutral"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const testTaskID = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
func TestListTasksRendersRealRowsEscapedWithSecurityHeaders(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC)
|
||||
service := &fakeService{
|
||||
listResult: TaskList{Items: []TaskSummary{{
|
||||
ID: testTaskID,
|
||||
Title: `<script>alert("private")</script>`,
|
||||
SKU: "SKU-1",
|
||||
Status: "PENDING",
|
||||
UpdatedAt: now,
|
||||
}}},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks?q=%3Cquery%3E&status=PENDING",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if strings.Contains(body, `<script>alert("private")</script>`) ||
|
||||
!strings.Contains(body, "<script>") {
|
||||
t.Fatalf("task title was not safely escaped: %s", body)
|
||||
}
|
||||
for _, text := range []string{
|
||||
"SKU-1",
|
||||
"待领取",
|
||||
"/tasks/" + testTaskID,
|
||||
"value=\"<query>\"",
|
||||
} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("body does not contain %q", text)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
if service.listInput.Query != "<query>" ||
|
||||
service.listInput.Status != "PENDING" ||
|
||||
service.listInput.Limit != defaultListLimit {
|
||||
t.Fatalf("list input = %+v", service.listInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksRendersHonestEmptyState(t *testing.T) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "没有符合条件的任务") ||
|
||||
!strings.Contains(body, "创建第一条采购任务") {
|
||||
t.Fatalf("empty state missing: %s", body)
|
||||
}
|
||||
for _, fake := range []string{"RB-DEMO", "演示设备", "演示任务"} {
|
||||
if strings.Contains(body, fake) {
|
||||
t.Fatalf("empty page contains fake data %q", fake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/new",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, response)
|
||||
if !cookie.HttpOnly ||
|
||||
cookie.SameSite != http.SameSiteStrictMode ||
|
||||
cookie.Path != "/tasks" {
|
||||
t.Fatalf("CSRF cookie = %+v", cookie)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(
|
||||
body,
|
||||
`name="csrf_token" value="`+cookie.Value+`"`,
|
||||
) {
|
||||
t.Fatal("form CSRF token does not match the cookie")
|
||||
}
|
||||
for _, required := range []string{
|
||||
`name="title"`,
|
||||
`name="sku"`,
|
||||
`name="quantity"`,
|
||||
`name="max_budget"`,
|
||||
`name="image"`,
|
||||
"最高总预算",
|
||||
} {
|
||||
if !strings.Contains(body, required) {
|
||||
t.Fatalf("new task form missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<style") ||
|
||||
strings.Contains(body, "<script>") {
|
||||
t.Fatal("page contains inline style or script incompatible with CSP")
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
router := newTestRouter(t, service)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": "invalid",
|
||||
"title": "标题",
|
||||
"sku": "SKU-1",
|
||||
"quantity": "1",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "image", "reference.jpg", []byte("not inspected"))
|
||||
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/tasks",
|
||||
body,
|
||||
contentType,
|
||||
)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
if service.uploadCalls != 0 || service.createCalls != 0 {
|
||||
t.Fatalf(
|
||||
"service calls = upload %d, create %d",
|
||||
service.uploadCalls,
|
||||
service.createCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskValidationRetainsEscapedSafeFields(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": cookie.Value,
|
||||
"title": "",
|
||||
"sku": "",
|
||||
"description": `<img src=x onerror="alert(1)">`,
|
||||
"quantity": "0",
|
||||
"max_budget": "1.001",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "", "", nil)
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
rendered := response.Body.String()
|
||||
for _, message := range []string{
|
||||
"请输入商品标题",
|
||||
"请输入 SKU",
|
||||
"数量必须是大于 0 的整数",
|
||||
"最多两位小数",
|
||||
"<img src=x onerror="alert(1)">",
|
||||
} {
|
||||
if !strings.Contains(rendered, message) {
|
||||
t.Fatalf("response missing %q", message)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, `<img src=x onerror="alert(1)">`) {
|
||||
t.Fatal("description was rendered as active HTML")
|
||||
}
|
||||
if service.uploadCalls != 0 || service.createCalls != 0 {
|
||||
t.Fatal("invalid form reached the service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidationUsesContractUTF8ByteLimits(t *testing.T) {
|
||||
valid := func() newTaskPageView {
|
||||
token := mustToken(t)
|
||||
return newTaskPageView{
|
||||
CSRFToken: token,
|
||||
UploadKey: mustToken(t),
|
||||
CreateKey: mustToken(t),
|
||||
Form: createFormView{
|
||||
Title: "标题",
|
||||
SKU: "SKU-1",
|
||||
Quantity: "1",
|
||||
QuantityValue: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
skuPage := valid()
|
||||
skuPage.Form.SKU = strings.Repeat("货", maxSKUBytes/3+1)
|
||||
if !validateCreateForm(&skuPage) ||
|
||||
!strings.Contains(skuPage.Errors.SKU, "512") {
|
||||
t.Fatalf("SKU errors = %+v", skuPage.Errors)
|
||||
}
|
||||
|
||||
descriptionPage := valid()
|
||||
descriptionPage.Form.Description = strings.Repeat(
|
||||
"说",
|
||||
maxDescriptionBytes/3+1,
|
||||
)
|
||||
if !validateCreateForm(&descriptionPage) ||
|
||||
!strings.Contains(descriptionPage.Errors.Description, "8192") {
|
||||
t.Fatalf("description errors = %+v", descriptionPage.Errors)
|
||||
}
|
||||
|
||||
titlePage := valid()
|
||||
titlePage.Form.Title = strings.Repeat("题", maxTitleRunes+1)
|
||||
if !validateCreateForm(&titlePage) ||
|
||||
!strings.Contains(titlePage.Errors.Title, "120") {
|
||||
t.Fatalf("title errors = %+v", titlePage.Errors)
|
||||
}
|
||||
|
||||
if maxRequestBytes < (20<<20)+(1<<20) {
|
||||
t.Fatalf("maxRequestBytes = %d, does not cover a 20 MiB image", maxRequestBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskUploadsThenRedirectsWithPRG(t *testing.T) {
|
||||
service := &fakeService{
|
||||
uploadResult: UploadedAsset{
|
||||
ID: "00000000-0000-4000-8000-000000000099",
|
||||
},
|
||||
createResult: Task{ID: testTaskID},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": cookie.Value,
|
||||
"title": " 桌面收纳盒 ",
|
||||
"sku": " SKU-1 ",
|
||||
"description": "浅灰色",
|
||||
"quantity": "2",
|
||||
"max_budget": "60.00",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "image", "reference.jpg", []byte("image bytes"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/tasks/"+testTaskID {
|
||||
t.Fatalf(
|
||||
"status/location = %d/%q, body = %s",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
response.Body,
|
||||
)
|
||||
}
|
||||
if service.uploadCalls != 1 || service.createCalls != 1 {
|
||||
t.Fatalf(
|
||||
"service calls = upload %d, create %d",
|
||||
service.uploadCalls,
|
||||
service.createCalls,
|
||||
)
|
||||
}
|
||||
if string(service.uploadBody) != "image bytes" ||
|
||||
service.createInput.Title != "桌面收纳盒" ||
|
||||
service.createInput.SKU != "SKU-1" ||
|
||||
service.createInput.Quantity != 2 ||
|
||||
service.createInput.MaxBudget != "60.00" ||
|
||||
service.createInput.ImageAssetID != service.uploadResult.ID {
|
||||
t.Fatalf(
|
||||
"upload/create input = %q / %+v",
|
||||
service.uploadBody,
|
||||
service.createInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskFailureRetainsUploadedAssetForRetry(t *testing.T) {
|
||||
assetID := "00000000-0000-4000-8000-000000000099"
|
||||
service := &fakeService{
|
||||
uploadResult: UploadedAsset{
|
||||
ID: assetID,
|
||||
},
|
||||
createErr: ErrUnavailable,
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := validCreateBody(t, cookie.Value, nil)
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
rendered := response.Body.String()
|
||||
for _, value := range []string{
|
||||
`name="image_asset_id" value="` + assetID + `"`,
|
||||
"再次提交会复用该图片",
|
||||
} {
|
||||
if !strings.Contains(rendered, value) {
|
||||
t.Fatalf("response missing %q", value)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, "reference.jpg") {
|
||||
t.Fatal("server response leaked the client file name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRetryReusesAssetWithoutUpload(t *testing.T) {
|
||||
assetID := "00000000-0000-4000-8000-000000000099"
|
||||
service := &fakeService{
|
||||
createResult: Task{ID: testTaskID},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := validCreateBody(t, cookie.Value, map[string]string{
|
||||
"image_asset_id": assetID,
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
if service.uploadCalls != 0 ||
|
||||
service.createInput.ImageAssetID != assetID {
|
||||
t.Fatalf(
|
||||
"upload calls / asset = %d / %q",
|
||||
service.uploadCalls,
|
||||
service.createInput.ImageAssetID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) {
|
||||
service := &fakeService{
|
||||
getResult: Task{
|
||||
ID: testTaskID,
|
||||
Title: "桌面收纳盒",
|
||||
SKU: "SKU-1",
|
||||
Description: "浅灰色",
|
||||
Quantity: 2,
|
||||
MaxBudget: "60.00",
|
||||
Status: "PENDING",
|
||||
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
|
||||
CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
detail := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
cancelKey := hiddenValue(t, detail.Body.String(), "cancel_key")
|
||||
for _, value := range []string{
|
||||
"桌面收纳盒",
|
||||
"最高总预算",
|
||||
"/api/v1/assets/00000000-0000-4000-8000-000000000009/content",
|
||||
"取消任务",
|
||||
"确认取消任务",
|
||||
} {
|
||||
if !strings.Contains(detail.Body.String(), value) {
|
||||
t.Fatalf("detail missing %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"cancel_key": {cancelKey},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tasks/"+testTaskID+"/cancel",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/tasks/"+testTaskID+"?notice=canceled" {
|
||||
t.Fatalf(
|
||||
"status/location = %d/%q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.cancelInput.TaskID != testTaskID ||
|
||||
service.cancelInput.IdempotencyKey != cancelKey {
|
||||
t.Fatalf("cancel input = %+v", service.cancelInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
|
||||
service := &fakeService{getErr: ErrForbidden}
|
||||
router := newTestRouter(t, service)
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "不存在或当前不可访问") {
|
||||
t.Fatalf("safe not-found message missing: %s", response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
for _, route := range []string{"/static/admin.css", "/static/admin.js"} {
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
route,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if response.Code != http.StatusOK || response.Body.Len() == 0 {
|
||||
t.Fatalf("%s status/bytes = %d/%d", route, response.Code, response.Body.Len())
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Execute(&output, "tasks", struct{}{}); err == nil {
|
||||
t.Fatal("Execute() with incomplete data error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
listErr error
|
||||
getResult Task
|
||||
getErr error
|
||||
uploadResult UploadedAsset
|
||||
uploadErr error
|
||||
uploadInput UploadReferenceInput
|
||||
uploadBody []byte
|
||||
uploadCalls int
|
||||
createResult Task
|
||||
createErr error
|
||||
createInput CreateTaskInput
|
||||
createCalls int
|
||||
cancelResult Task
|
||||
cancelErr error
|
||||
cancelInput CancelPendingInput
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
_ context.Context,
|
||||
input ListTasksInput,
|
||||
) (TaskList, error) {
|
||||
service.listInput = input
|
||||
return service.listResult, service.listErr
|
||||
}
|
||||
|
||||
func (service *fakeService) GetTask(
|
||||
context.Context,
|
||||
string,
|
||||
) (Task, error) {
|
||||
return service.getResult, service.getErr
|
||||
}
|
||||
|
||||
func (service *fakeService) UploadReference(
|
||||
_ context.Context,
|
||||
input UploadReferenceInput,
|
||||
) (UploadedAsset, error) {
|
||||
service.uploadCalls++
|
||||
service.uploadInput = input
|
||||
content, err := io.ReadAll(input.Content)
|
||||
if err != nil {
|
||||
return UploadedAsset{}, err
|
||||
}
|
||||
service.uploadBody = content
|
||||
return service.uploadResult, service.uploadErr
|
||||
}
|
||||
|
||||
func (service *fakeService) CreateTask(
|
||||
_ context.Context,
|
||||
input CreateTaskInput,
|
||||
) (Task, error) {
|
||||
service.createCalls++
|
||||
service.createInput = input
|
||||
return service.createResult, service.createErr
|
||||
}
|
||||
|
||||
func (service *fakeService) CancelPending(
|
||||
_ context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
service.cancelInput = input
|
||||
return service.cancelResult, service.cancelErr
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T, service Service) http.Handler {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
handler, err := NewHandler(service, renderer)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler() error = %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
handler.Register(router)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
method string,
|
||||
path string,
|
||||
body io.Reader,
|
||||
contentType string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, body)
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func getCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
response := performRequest(
|
||||
t,
|
||||
handler,
|
||||
http.MethodGet,
|
||||
"/tasks/new",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
return csrfCookie(t, response)
|
||||
}
|
||||
|
||||
func csrfCookie(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == csrfCookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("CSRF cookie not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func multipartBody(
|
||||
t *testing.T,
|
||||
fields map[string]string,
|
||||
fileField string,
|
||||
fileName string,
|
||||
content []byte,
|
||||
) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for name, value := range fields {
|
||||
if err := writer.WriteField(name, value); err != nil {
|
||||
t.Fatalf("WriteField(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if fileField != "" {
|
||||
part, err := writer.CreateFormFile(fileField, fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile(): %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart: %v", err)
|
||||
}
|
||||
return &body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func validCreateBody(
|
||||
t *testing.T,
|
||||
csrfToken string,
|
||||
overrides map[string]string,
|
||||
) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
fields := map[string]string{
|
||||
"csrf_token": csrfToken,
|
||||
"title": "桌面收纳盒",
|
||||
"sku": "SKU-1",
|
||||
"description": "浅灰色",
|
||||
"quantity": "2",
|
||||
"max_budget": "60.00",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}
|
||||
for name, value := range overrides {
|
||||
fields[name] = value
|
||||
}
|
||||
fileField := "image"
|
||||
fileName := "reference.jpg"
|
||||
content := []byte("image bytes")
|
||||
if fields["image_asset_id"] != "" {
|
||||
fileField = ""
|
||||
fileName = ""
|
||||
content = nil
|
||||
}
|
||||
return multipartBody(t, fields, fileField, fileName, content)
|
||||
}
|
||||
|
||||
func mustToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
t.Fatalf("newToken() error = %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func hiddenValue(t *testing.T, body string, name string) string {
|
||||
t.Helper()
|
||||
pattern := regexp.MustCompile(
|
||||
`name="` + regexp.QuoteMeta(name) + `" value="([^"]+)"`,
|
||||
)
|
||||
match := pattern.FindStringSubmatch(body)
|
||||
if len(match) != 2 {
|
||||
t.Fatalf("hidden field %q not found", name)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) {
|
||||
t.Helper()
|
||||
required := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
for name, want := range required {
|
||||
if got := response.Header().Get(name); got != want {
|
||||
t.Fatalf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
csp := response.Header().Get("Content-Security-Policy")
|
||||
for _, directive := range []string{
|
||||
"default-src 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
} {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Fatalf("CSP missing %q: %s", directive, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsecaseErrorMappingKeepsPublicSentinels(t *testing.T) {
|
||||
tests := []struct {
|
||||
kind usecase.ErrorKind
|
||||
code string
|
||||
want error
|
||||
}{
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "ASSET_IMAGE_INVALID",
|
||||
want: ErrInvalidFile,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "TASK_VALIDATION_FAILED",
|
||||
want: ErrValidation,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "TASK_CANCEL_INVALID",
|
||||
want: ErrNotFound,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindNotFound,
|
||||
code: "TASK_NOT_FOUND",
|
||||
want: ErrNotFound,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindConflict,
|
||||
code: "TASK_STATE_CONFLICT",
|
||||
want: ErrConflict,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindUnavailable,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
want: ErrUnavailable,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.code, func(t *testing.T) {
|
||||
mapped := mapUsecaseError(&usecase.Error{
|
||||
Kind: test.kind,
|
||||
Code: test.code,
|
||||
})
|
||||
if !errors.Is(mapped, test.want) {
|
||||
t.Fatalf("mapped error = %v, want %v", mapped, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.gohtml static/admin.css static/admin.js
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
type Renderer struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
templates, err := template.New("admin").
|
||||
Option("missingkey=error").
|
||||
Funcs(template.FuncMap{
|
||||
"displayTime": displayTime,
|
||||
"machineTime": machineTime,
|
||||
"pathPart": pathPart,
|
||||
}).
|
||||
ParseFS(embeddedFiles, "templates/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, errors.New("parse admin templates")
|
||||
}
|
||||
return &Renderer{templates: templates}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Execute(
|
||||
writer io.Writer,
|
||||
name string,
|
||||
data any,
|
||||
) error {
|
||||
if r == nil || r.templates == nil {
|
||||
return errors.New("admin renderer is not configured")
|
||||
}
|
||||
if err := r.templates.ExecuteTemplate(writer, name, data); err != nil {
|
||||
return errors.New("render admin template")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func staticFile(name string) ([]byte, error) {
|
||||
if name != "admin.css" && name != "admin.js" {
|
||||
return nil, errors.New("unknown admin static file")
|
||||
}
|
||||
content, err := embeddedFiles.ReadFile("static/" + name)
|
||||
if err != nil {
|
||||
return nil, errors.New("read admin static file")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func displayTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "尚未记录"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func machineTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func pathPart(value string) string {
|
||||
return pathEscape(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func pathEscape(value string) string {
|
||||
// Keep IDs as one opaque path component.
|
||||
if value == "" || value == "." || value == ".." ||
|
||||
path.Base(value) != value || strings.ContainsAny(value, `/\`) {
|
||||
return "invalid"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--canvas: #f3f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8faf9;
|
||||
--ink: #18201d;
|
||||
--muted: #5b6862;
|
||||
--line: #cfd7d3;
|
||||
--line-strong: #9eaaa4;
|
||||
--brand: #0b6b50;
|
||||
--brand-dark: #07513d;
|
||||
--danger: #9b3030;
|
||||
--danger-dark: #762222;
|
||||
--danger-soft: #fff1f1;
|
||||
--info: #185b8f;
|
||||
--info-soft: #eef7fd;
|
||||
--warn: #76520d;
|
||||
--warn-soft: #fff8e5;
|
||||
--success: #07513d;
|
||||
--success-soft: #eaf7f1;
|
||||
--focus: #1a73b8;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 280px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
summary {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid rgba(26, 115, 184, 0.3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 100;
|
||||
padding: 8px 12px;
|
||||
color: #fff;
|
||||
background: #18201d;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 clamp(16px, 4vw, 40px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
min-height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 13px;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #3d4b45;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-nav a[aria-current="page"] {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(calc(100% - 32px), 1180px);
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 60px;
|
||||
}
|
||||
|
||||
.narrow-page {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.section-note,
|
||||
.secondary,
|
||||
.field-help {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
color: #26352f;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: #68766f;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: var(--brand-dark);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: var(--danger-dark);
|
||||
}
|
||||
|
||||
.button.danger-outline {
|
||||
border-color: #c78484;
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) 220px auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field.compact label {
|
||||
color: #45534d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[aria-invalid="true"],
|
||||
textarea[aria-invalid="true"],
|
||||
select[aria-invalid="true"] {
|
||||
border-color: var(--danger);
|
||||
box-shadow: inset 0 0 0 1px var(--danger);
|
||||
}
|
||||
|
||||
.table-region {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 13px 12px;
|
||||
border-bottom: 1px solid #e1e6e3;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #45534d;
|
||||
background: var(--surface-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
th:nth-child(2) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
th:nth-child(3) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
th:nth-child(4) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fbfa;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-title,
|
||||
.secondary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
border-color: #d6b970;
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
border-color: #8eb7d4;
|
||||
color: var(--info);
|
||||
background: var(--info-soft);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: #7db6a3;
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
border-color: #d99c9c;
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
padding: 36px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 0 18px;
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid var(--info);
|
||||
background: var(--info-soft);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice p + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.notice-error {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.content-section {
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 15px 18px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.required-text {
|
||||
color: var(--danger-dark);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-help,
|
||||
.field-error {
|
||||
min-height: 20px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
.upload-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 240px;
|
||||
gap: 22px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-placeholder,
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-preview figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.retained-file {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #7db6a3;
|
||||
border-radius: 5px;
|
||||
background: var(--success-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 18px;
|
||||
border-left: 5px solid var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.success-banner h2,
|
||||
.success-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--success);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.requirement-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px, 220px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.reference-figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reference-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.reference-figure figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, auto) minmax(0, 1fr);
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.definition-list dt {
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error-page {
|
||||
margin-top: 50px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.error-page p {
|
||||
margin: 8px 0 22px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(calc(100% - 32px), 480px);
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.confirm-dialog::backdrop {
|
||||
background: rgba(24, 32, 29, 0.45);
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.noscript-cancel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d99c9c;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[aria-busy="true"] {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
width: min(calc(100% - 20px), 1180px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 0.6fr);
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
table,
|
||||
tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
td {
|
||||
display: block;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.upload-layout,
|
||||
.detail-layout,
|
||||
.requirement-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.site-header {
|
||||
gap: 8px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.detail-title {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title-row > .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-button,
|
||||
.span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
tr {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
td,
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.content-section {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.dialog-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions .button,
|
||||
.dialog-actions .button,
|
||||
.dialog-actions form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions form .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const focusError = document.querySelector("[data-error-field]");
|
||||
if (focusError) {
|
||||
focusError.focus();
|
||||
} else {
|
||||
const summary = document.querySelector("[data-error-summary]");
|
||||
if (summary) summary.focus();
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-loading-form]").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
form.setAttribute("aria-busy", "true");
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
button.textContent = button.dataset.loadingLabel || "正在加载…";
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-submit-form]").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
form.dataset.submitting = "true";
|
||||
const button = form.querySelector("[data-submit-button]");
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
button.textContent = button.dataset.loadingLabel || "正在提交…";
|
||||
});
|
||||
});
|
||||
|
||||
const fileInput = document.querySelector("input[type='file'][name='image']");
|
||||
const image = document.querySelector("[data-image-preview]");
|
||||
const placeholder = document.querySelector("[data-image-placeholder]");
|
||||
const imageName = document.querySelector("[data-image-name]");
|
||||
let previewURL = "";
|
||||
|
||||
if (fileInput && image && placeholder && imageName) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (previewURL) URL.revokeObjectURL(previewURL);
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
image.hidden = true;
|
||||
placeholder.hidden = false;
|
||||
imageName.textContent = "选择后在本地预览";
|
||||
return;
|
||||
}
|
||||
previewURL = URL.createObjectURL(file);
|
||||
image.src = previewURL;
|
||||
image.hidden = false;
|
||||
placeholder.hidden = true;
|
||||
imageName.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`;
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
if (previewURL) URL.revokeObjectURL(previewURL);
|
||||
});
|
||||
}
|
||||
|
||||
const dirtyForm = document.querySelector("[data-dirty-form]");
|
||||
const leaveDialog = document.querySelector("[data-leave-dialog]");
|
||||
let dirty = false;
|
||||
let destination = "";
|
||||
|
||||
if (dirtyForm && leaveDialog) {
|
||||
dirtyForm.addEventListener("input", () => {
|
||||
dirty = true;
|
||||
});
|
||||
dirtyForm.addEventListener("change", () => {
|
||||
dirty = true;
|
||||
});
|
||||
dirtyForm.addEventListener("submit", () => {
|
||||
dirty = false;
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[href]").forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (!dirty || link.hasAttribute("data-discard")) return;
|
||||
const target = new URL(link.href, window.location.href);
|
||||
if (target.origin !== window.location.origin) return;
|
||||
event.preventDefault();
|
||||
destination = target.href;
|
||||
leaveDialog.showModal();
|
||||
leaveDialog.querySelector("[data-stay]").focus();
|
||||
});
|
||||
});
|
||||
|
||||
leaveDialog.querySelector("[data-stay]").addEventListener("click", () => {
|
||||
leaveDialog.close();
|
||||
});
|
||||
leaveDialog.querySelector("[data-discard]").addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
dirty = false;
|
||||
window.location.assign(destination || "/tasks");
|
||||
});
|
||||
}
|
||||
|
||||
const openCancel = document.querySelector("[data-open-cancel]");
|
||||
const cancelDialog = document.querySelector("[data-cancel-dialog]");
|
||||
if (openCancel && cancelDialog) {
|
||||
openCancel.addEventListener("click", () => {
|
||||
cancelDialog.showModal();
|
||||
cancelDialog.querySelector("[data-keep-task]").focus();
|
||||
});
|
||||
cancelDialog.querySelector("[data-keep-task]").addEventListener("click", () => {
|
||||
cancelDialog.close();
|
||||
openCancel.focus();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "error"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page narrow-page">
|
||||
<section class="error-page" role="alert">
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<a class="button primary" href="/tasks">返回任务列表</a>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "document-head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<script src="/static/admin.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
{{define "site-header"}}
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/tasks" aria-label="采购任务管理首页">
|
||||
<span class="brand-mark" aria-hidden="true">采</span>
|
||||
<span>采购任务管理</span>
|
||||
</a>
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{end}}
|
||||
@@ -0,0 +1,106 @@
|
||||
{{define "task-detail"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Task.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<a class="back-link" href="/tasks">返回任务列表</a>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="notice" role="status" tabindex="-1" data-notice>{{.Notice}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="title-row detail-title">
|
||||
<div>
|
||||
<h1>{{.Task.Title}}</h1>
|
||||
<p class="subtitle">SKU:{{.Task.SKU}} · 任务编号:{{.Task.ID}}</p>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<span class="status {{.Task.StatusClass}}" title="状态码 {{.Task.Status}}">
|
||||
{{.Task.StatusLabel}}
|
||||
</span>
|
||||
{{if .Task.CanCancel}}
|
||||
<button class="button danger-outline" type="button" data-open-cancel>取消任务</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if eq .Task.Status "SUCCEEDED"}}
|
||||
<section class="success-banner" aria-labelledby="success-heading">
|
||||
<div aria-hidden="true" class="success-mark">✓</div>
|
||||
<div>
|
||||
<h2 id="success-heading">验证完成,未提交订单</h2>
|
||||
<p>采购人员已完成候选确认,系统只记录验证结果。</p>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="detail-layout">
|
||||
<section class="content-section" aria-labelledby="original-heading">
|
||||
<h2 id="original-heading">原始采购要求</h2>
|
||||
<p class="section-note">以下内容来自管理员输入,不会被 AI 派生结果覆盖。</p>
|
||||
<div class="requirement-layout">
|
||||
<figure class="reference-figure">
|
||||
<img class="reference-image"
|
||||
src="/api/v1/assets/{{pathPart .Task.ReferenceAssetID}}/content"
|
||||
alt="商品参考图">
|
||||
<figcaption>已规范化的参考图片</figcaption>
|
||||
</figure>
|
||||
<dl class="definition-list">
|
||||
<dt>商品标题</dt><dd>{{.Task.Title}}</dd>
|
||||
<dt>SKU</dt><dd>{{.Task.SKU}}</dd>
|
||||
<dt>描述</dt><dd>{{if .Task.Description}}{{.Task.Description}}{{else}}未填写{{end}}</dd>
|
||||
<dt>数量</dt><dd>{{.Task.Quantity}}</dd>
|
||||
<dt>最高总预算</dt>
|
||||
<dd>{{if .Task.MaxBudget}}{{.Task.MaxBudget}} 元{{else}}未设置{{end}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="content-section summary-section" aria-labelledby="summary-heading">
|
||||
<h2 id="summary-heading">任务摘要</h2>
|
||||
<dl class="definition-list">
|
||||
<dt>状态</dt><dd>{{.Task.StatusLabel}}({{.Task.Status}})</dd>
|
||||
<dt>创建时间</dt>
|
||||
<dd><time datetime="{{machineTime .Task.CreatedAt}}">{{displayTime .Task.CreatedAt}}</time></dd>
|
||||
<dt>最后更新</dt>
|
||||
<dd><time datetime="{{machineTime .Task.UpdatedAt}}">{{displayTime .Task.UpdatedAt}}</time></dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{if .Task.CanCancel}}
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
|
||||
<h2 id="noscript-cancel-heading">确认取消任务</h2>
|
||||
<p>取消后任务不会自动恢复。</p>
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit">确认取消</button>
|
||||
</form>
|
||||
</section>
|
||||
</noscript>
|
||||
|
||||
<dialog class="confirm-dialog" data-cancel-dialog aria-labelledby="cancel-title">
|
||||
<h2 id="cancel-title">确认取消任务?</h2>
|
||||
<p>任务取消后不会自动恢复,采购执行员将不能再领取。</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button" data-keep-task>保留任务</button>
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel" data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit" data-submit-button
|
||||
data-loading-label="正在取消…">确认取消</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,126 @@
|
||||
{{define "task-new"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page form-page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>新建采购任务</h1>
|
||||
<p class="subtitle">填写商品要求和不可覆盖的采购约束</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .Notice .Errors.Form}}
|
||||
<div class="notice notice-error" role="alert" tabindex="-1" data-error-summary>
|
||||
{{if .Notice}}<p>{{.Notice}}</p>{{end}}
|
||||
{{if .Errors.Form}}<p>{{.Errors.Form}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="task-form" method="post" action="/tasks" enctype="multipart/form-data"
|
||||
novalidate data-dirty-form data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="upload_key" value="{{.UploadKey}}">
|
||||
<input type="hidden" name="create_key" value="{{.CreateKey}}">
|
||||
<input type="hidden" name="image_asset_id" value="{{.UploadedAsset.ID}}">
|
||||
|
||||
<section class="form-section" aria-labelledby="requirement-heading">
|
||||
<h2 id="requirement-heading">商品要求</h2>
|
||||
<div class="form-grid">
|
||||
<div class="field span-2">
|
||||
<label for="title">商品标题 <span class="required-text">必填</span></label>
|
||||
<input id="title" name="title" value="{{.Form.Title}}" maxlength="120" required
|
||||
autocomplete="off" aria-describedby="title-error"
|
||||
{{if .Errors.Title}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="title-error" class="field-error">{{.Errors.Title}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label for="sku">SKU <span class="required-text">必填</span></label>
|
||||
<input id="sku" name="sku" value="{{.Form.SKU}}" maxlength="512" required
|
||||
autocomplete="off" aria-describedby="sku-help sku-error"
|
||||
{{if .Errors.SKU}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="sku-help" class="field-help">填写采购时用于核对的商品规格编号。</p>
|
||||
<p id="sku-error" class="field-error">{{.Errors.SKU}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label for="description">商品描述</label>
|
||||
<textarea id="description" name="description" maxlength="8192"
|
||||
aria-describedby="description-help description-error"
|
||||
{{if .Errors.Description}}aria-invalid="true" data-error-field{{end}}>{{.Form.Description}}</textarea>
|
||||
<p id="description-help" class="field-help">只填写采购判断所需信息,不填写订单、地址或个人信息。</p>
|
||||
<p id="description-error" class="field-error">{{.Errors.Description}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="quantity">数量 <span class="required-text">必填</span></label>
|
||||
<input id="quantity" name="quantity" type="number" value="{{.Form.Quantity}}"
|
||||
min="1" step="1" inputmode="numeric" required aria-describedby="quantity-error"
|
||||
{{if .Errors.Quantity}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="quantity-error" class="field-error">{{.Errors.Quantity}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="max-budget">最高总预算(元)</label>
|
||||
<input id="max-budget" name="max_budget" type="text" value="{{.Form.MaxBudget}}"
|
||||
inputmode="decimal" placeholder="不填写则不设上限"
|
||||
aria-describedby="budget-help budget-error"
|
||||
{{if .Errors.MaxBudget}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="budget-help" class="field-help">数量合计的最高预算,不是单件价格。</p>
|
||||
<p id="budget-error" class="field-error">{{.Errors.MaxBudget}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="image-heading">
|
||||
<h2 id="image-heading">参考图片</h2>
|
||||
<div class="upload-layout">
|
||||
<div class="field">
|
||||
<label for="image">商品参考图 <span class="required-text">必填</span></label>
|
||||
{{if .UploadedAsset.ID}}
|
||||
<p class="retained-file">已保留上传成功的参考图片</p>
|
||||
<p class="field-help">再次提交会复用该图片,不会重复上传。</p>
|
||||
{{else}}
|
||||
<input id="image" name="image" type="file"
|
||||
accept="image/png,image/jpeg,image/webp" required
|
||||
aria-describedby="image-help image-error"
|
||||
{{if .Errors.Image}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="image-help" class="field-help">支持 JPG、PNG、WebP;服务端会校验实际内容。</p>
|
||||
{{end}}
|
||||
<p id="image-error" class="field-error">{{.Errors.Image}}</p>
|
||||
</div>
|
||||
<figure class="image-preview">
|
||||
<div class="image-placeholder" data-image-placeholder>尚未选择图片</div>
|
||||
<img alt="待上传的商品参考图预览" data-image-preview hidden>
|
||||
<figcaption data-image-name>
|
||||
{{if .UploadedAsset.ID}}参考图片已保留{{else}}选择后仅在本地预览{{end}}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="button" href="/tasks">取消</a>
|
||||
<button class="button primary" type="submit" data-submit-button
|
||||
data-loading-label="正在创建…">创建任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<dialog class="confirm-dialog" data-leave-dialog aria-labelledby="leave-title">
|
||||
<h2 id="leave-title">放弃未提交内容?</h2>
|
||||
<p>已填写的商品要求不会保存。</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button" data-stay>继续填写</button>
|
||||
<a class="button danger" href="/tasks" data-discard>放弃并离开</a>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,93 @@
|
||||
{{define "tasks"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>采购任务</h1>
|
||||
<p class="subtitle">按最近创建时间查看采购任务状态</p>
|
||||
</div>
|
||||
<a class="button primary" href="/tasks/new">新建任务</a>
|
||||
</div>
|
||||
|
||||
<form class="filters" method="get" action="/tasks" data-loading-form>
|
||||
<div class="field compact">
|
||||
<label for="task-search">搜索</label>
|
||||
<input id="task-search" name="q" type="search" value="{{.Query}}"
|
||||
placeholder="任务编号、标题或 SKU">
|
||||
</div>
|
||||
<div class="field compact">
|
||||
<label for="task-status">状态</label>
|
||||
<select id="task-status" name="status">
|
||||
{{range .StatusOptions}}
|
||||
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button class="button filter-button" type="submit" data-loading-label="正在加载…">应用筛选</button>
|
||||
</form>
|
||||
|
||||
<section class="table-region" aria-labelledby="task-table-title" data-loading-region>
|
||||
<h2 id="task-table-title" class="visually-hidden">任务列表结果</h2>
|
||||
{{if .Items}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">任务</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">执行设备</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td data-label="任务">
|
||||
<strong class="task-title">{{.Title}}</strong>
|
||||
<span class="secondary">SKU:{{.SKU}}</span>
|
||||
<span class="secondary">编号:{{.ID}}</span>
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<span class="status {{.StatusClass}}" title="状态码 {{.Status}}">{{.StatusLabel}}</span>
|
||||
</td>
|
||||
<td data-label="执行设备">{{.DeviceName}}</td>
|
||||
<td data-label="更新时间">
|
||||
<time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time>
|
||||
</td>
|
||||
<td data-label="操作">
|
||||
<a class="detail-link" href="/tasks/{{pathPart .ID}}">查看详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h2>没有符合条件的任务</h2>
|
||||
{{if or .Query .Status}}
|
||||
<p>请调整搜索或状态筛选后重试。</p>
|
||||
<a class="button" href="/tasks">清除筛选</a>
|
||||
{{else}}
|
||||
<p>创建第一条采购任务后,它会显示在这里。</p>
|
||||
<a class="button primary" href="/tasks/new">新建任务</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .NextCursor}}
|
||||
<nav class="pagination" aria-label="任务分页">
|
||||
<a class="button" href="/tasks?q={{.Query}}&status={{.Status}}&cursor={{.NextCursor}}">下一页</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,87 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
)
|
||||
|
||||
// Service is the application boundary required by the server-rendered admin UI.
|
||||
// Composition code adapts the task use case to this transport-specific shape.
|
||||
type Service interface {
|
||||
ListTasks(context.Context, ListTasksInput) (TaskList, error)
|
||||
GetTask(context.Context, string) (Task, error)
|
||||
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
||||
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
||||
CancelPending(context.Context, CancelPendingInput) (Task, error)
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
Query string
|
||||
Status string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
Items []TaskSummary
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type TaskSummary struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Status string
|
||||
DeviceName string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type UploadReferenceInput struct {
|
||||
IdempotencyKey string
|
||||
DeclaredType string
|
||||
DeclaredSize int64
|
||||
Content io.Reader
|
||||
}
|
||||
|
||||
type UploadedAsset struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
IdempotencyKey string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
ImageAssetID string
|
||||
}
|
||||
|
||||
type CancelPendingInput struct {
|
||||
TaskID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
const localAdminSubject = "local-admin"
|
||||
|
||||
type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListTasks(
|
||||
ctx context.Context,
|
||||
input ListTasksInput,
|
||||
) (TaskList, error) {
|
||||
var status *string
|
||||
if input.Status != "" {
|
||||
value := input.Status
|
||||
status = &value
|
||||
}
|
||||
page, err := adapter.tasks.List(ctx, usecase.ListTasksQuery{
|
||||
CreatorSubject: localAdminSubject,
|
||||
Status: status,
|
||||
Query: input.Query,
|
||||
Limit: input.Limit,
|
||||
Cursor: input.Cursor,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskList{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]TaskSummary, 0, len(page.Items))
|
||||
for _, item := range page.Items {
|
||||
items = append(items, taskSummaryFrom(item))
|
||||
}
|
||||
return TaskList{
|
||||
Items: items,
|
||||
NextCursor: page.NextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetTask(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
) (Task, error) {
|
||||
detail, err := adapter.tasks.Get(ctx, localAdminSubject, taskID)
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromDetail(detail), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) UploadReference(
|
||||
ctx context.Context,
|
||||
input UploadReferenceInput,
|
||||
) (UploadedAsset, error) {
|
||||
result, err := adapter.assets.UploadTaskReference(
|
||||
ctx,
|
||||
usecase.UploadTaskReferenceCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
DeclaredMediaType: input.DeclaredType,
|
||||
Content: input.Content,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return UploadedAsset{}, mapUsecaseError(err)
|
||||
}
|
||||
return UploadedAsset{
|
||||
ID: result.Asset.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateTask(
|
||||
ctx context.Context,
|
||||
input CreateTaskInput,
|
||||
) (Task, error) {
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if input.Quantity > maxInt {
|
||||
return Task{}, ErrValidation
|
||||
}
|
||||
var budget *string
|
||||
if input.MaxBudget != "" {
|
||||
value := input.MaxBudget
|
||||
budget = &value
|
||||
}
|
||||
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
SKU: input.SKU,
|
||||
ImageAssetID: input.ImageAssetID,
|
||||
Quantity: int(input.Quantity),
|
||||
MaxBudget: budget,
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromPurchase(result.Task), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CancelPending(
|
||||
ctx context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
TaskID: input.TaskID,
|
||||
Reason: "管理员取消",
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromPurchase(task), nil
|
||||
}
|
||||
|
||||
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
|
||||
return TaskSummary{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
SKU: task.SKU,
|
||||
Status: string(task.Status),
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func taskFromPurchase(task domain.PurchaseTask) Task {
|
||||
budget := domain.FormatOptionalCNY(task.MaxBudgetCents)
|
||||
result := Task{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
SKU: task.SKU,
|
||||
Description: task.Description,
|
||||
Quantity: int64(task.Quantity),
|
||||
Status: string(task.Status),
|
||||
ReferenceAssetID: task.ImageAssetID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}
|
||||
if budget != nil {
|
||||
result.MaxBudget = *budget
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||
task := taskFromPurchase(detail.Task)
|
||||
task.ReferenceAssetID = detail.Asset.ID
|
||||
return task
|
||||
}
|
||||
|
||||
func mapUsecaseError(err error) error {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
return err
|
||||
}
|
||||
var public error
|
||||
switch typed.Kind {
|
||||
case usecase.ErrorKindInvalid:
|
||||
if typed.Code == "TASK_CANCEL_INVALID" {
|
||||
public = ErrNotFound
|
||||
} else if typed.Code == "ASSET_TOO_LARGE" ||
|
||||
typed.Code == "ASSET_MEDIA_TYPE_UNSUPPORTED" ||
|
||||
typed.Code == "ASSET_IMAGE_INVALID" ||
|
||||
typed.Code == "ASSET_FILE_REQUIRED" {
|
||||
public = ErrInvalidFile
|
||||
} else {
|
||||
public = ErrValidation
|
||||
}
|
||||
case usecase.ErrorKindNotFound:
|
||||
public = ErrNotFound
|
||||
case usecase.ErrorKindConflict:
|
||||
public = ErrConflict
|
||||
case usecase.ErrorKindUnavailable:
|
||||
public = ErrUnavailable
|
||||
default:
|
||||
return err
|
||||
}
|
||||
return &adapterError{
|
||||
public: public,
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
type adapterError struct {
|
||||
public error
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *adapterError) Error() string {
|
||||
return err.public.Error()
|
||||
}
|
||||
|
||||
func (err *adapterError) Unwrap() []error {
|
||||
return []error{err.public, err.cause}
|
||||
}
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
Reference in New Issue
Block a user