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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user