diff --git a/backend/go.mod b/backend/go.mod index b32a689..8be24ee 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -67,6 +67,7 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/image v0.32.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0ed681e..430490d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -254,7 +254,13 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -266,6 +272,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -289,6 +297,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -302,6 +312,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -324,6 +336,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -348,6 +362,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -362,6 +378,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/modules/file/dto.go b/backend/internal/modules/file/dto.go index 8a54529..df0bf60 100644 --- a/backend/internal/modules/file/dto.go +++ b/backend/internal/modules/file/dto.go @@ -1,9 +1,11 @@ package file type UploadDTO struct { - ObjectKey string `json:"object_key"` - URL string `json:"url"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - Size int64 `json:"size"` + ObjectKey string `json:"object_key"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + MediumURL string `json:"medium_url,omitempty"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` } diff --git a/backend/internal/modules/file/image_variants.go b/backend/internal/modules/file/image_variants.go new file mode 100644 index 0000000..9fd5e60 --- /dev/null +++ b/backend/internal/modules/file/image_variants.go @@ -0,0 +1,132 @@ +package file + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/jpeg" + _ "image/png" + "math" + "path" + "strings" + + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +const ( + ImageVariantThumb = "thumb" + ImageVariantMedium = "medium" +) + +const ( + thumbMaxSide = 480 + mediumMaxSide = 1280 +) + +type generatedImageVariant struct { + Key string + Content []byte + ContentType string +} + +type imageVariantConfig struct { + Name string + MaxSide int + Quality int +} + +var imageVariantConfigs = []imageVariantConfig{ + {Name: ImageVariantThumb, MaxSide: thumbMaxSide, Quality: 76}, + {Name: ImageVariantMedium, MaxSide: mediumMaxSide, Quality: 82}, +} + +func ImageVariantKey(key string, variant string) string { + ext := path.Ext(key) + base := strings.TrimSuffix(key, ext) + if base == "" { + base = key + } + return base + "." + variant + ".jpg" +} + +func ImageVariantFallbackKeys(key string, variant string) []string { + if key == "" { + return nil + } + keys := []string{ImageVariantKey(key, variant)} + if strings.HasSuffix(key, ".medium.jpg") && variant == ImageVariantThumb { + keys = append(keys, strings.TrimSuffix(key, ".medium.jpg")+"."+ImageVariantThumb+".jpg") + } + keys = append(keys, key) + return dedupeKeys(keys) +} + +func generateImageVariants(key string, data []byte, contentType string) []generatedImageVariant { + if !strings.HasPrefix(contentType, "image/") || contentType == "image/svg+xml" { + return nil + } + source, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil + } + var variants []generatedImageVariant + for _, config := range imageVariantConfigs { + rendered := resizeForVariant(source, config.MaxSide) + encoded, err := encodeJPEG(rendered, config.Quality) + if err != nil { + continue + } + variants = append(variants, generatedImageVariant{ + Key: ImageVariantKey(key, config.Name), + Content: encoded, + ContentType: "image/jpeg", + }) + } + return variants +} + +func resizeForVariant(source image.Image, maxSide int) image.Image { + bounds := source.Bounds() + width := bounds.Dx() + height := bounds.Dy() + if width <= 0 || height <= 0 || maxSide <= 0 { + return source + } + scale := math.Min(float64(maxSide)/float64(width), float64(maxSide)/float64(height)) + if scale > 1 { + scale = 1 + } + targetWidth := max(1, int(math.Round(float64(width)*scale))) + targetHeight := max(1, int(math.Round(float64(height)*scale))) + target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight)) + draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) + xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil) + return target +} + +func encodeJPEG(source image.Image, quality int) ([]byte, error) { + var buffer bytes.Buffer + err := jpeg.Encode(&buffer, source, &jpeg.Options{Quality: quality}) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func dedupeKeys(keys []string) []string { + seen := make(map[string]struct{}, len(keys)) + result := make([]string, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, key) + } + return result +} diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 1d0f386..9d0e428 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -1,9 +1,12 @@ package file import ( + "bytes" "context" "errors" + "io" "mime/multipart" + "net/http" "net/url" "strings" ) @@ -34,32 +37,78 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { if s.storage == nil { return nil, ErrDependencyUnavailable } - if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 || req.Header.Size > maxUploadSize { + if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 { return nil, ErrInvalidFile } - contentType := req.ContentType - if contentType == "" { - contentType = req.Header.Header.Get("Content-Type") + data, err := readUploadData(req.Reader) + if err != nil || len(data) == 0 || len(data) > maxUploadSize { + return nil, ErrInvalidFile } + contentType := normalizeContentType(req.ContentType, data) scene := normalizeScene(req.Scene) if !allowedContentTypes[contentType] || (scene == "chat" && !strings.HasPrefix(contentType, "image/")) { return nil, ErrInvalidFile } - key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType) + key, err := newObjectKey(scene, req.Header.Filename) if err != nil { return nil, err } + if err := s.storage.PutObject(req.Context, key, bytes.NewReader(data), int64(len(data)), contentType, map[string]string{ + "original-filename": req.Header.Filename, + }); err != nil { + return nil, err + } + var thumbnailURL string + var mediumURL string + for _, variant := range generateImageVariants(key, data, contentType) { + err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{ + "source-object": key, + }) + if err != nil { + continue + } + if strings.Contains(variant.Key, "."+ImageVariantThumb+".") { + thumbnailURL = fileURLForScene(scene, variant.Key) + } + if strings.Contains(variant.Key, "."+ImageVariantMedium+".") { + mediumURL = fileURLForScene(scene, variant.Key) + } + } + fileURL := fileURLForScene(scene, key) + return &UploadDTO{ + ObjectKey: key, + URL: fileURL, + ThumbnailURL: thumbnailURL, + MediumURL: mediumURL, + Filename: req.Header.Filename, + ContentType: contentType, + Size: int64(len(data)), + }, nil +} + +func readUploadData(reader multipart.File) ([]byte, error) { + limited := io.LimitReader(reader, maxUploadSize+1) + return io.ReadAll(limited) +} + +func normalizeContentType(contentType string, data []byte) string { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + if allowedContentTypes[contentType] { + return contentType + } + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + if allowedContentTypes[detected] { + return detected + } + return contentType +} + +func fileURLForScene(scene string, key string) string { fileURL := "/api/files/object?key=" + url.QueryEscape(key) if scene == "home-banner" || scene == "avatar" { fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) } - return &UploadDTO{ - ObjectKey: key, - URL: fileURL, - Filename: req.Header.Filename, - ContentType: contentType, - Size: req.Header.Size, - }, nil + return fileURL } func normalizeScene(scene string) string { diff --git a/backend/internal/modules/file/storage.go b/backend/internal/modules/file/storage.go index dbcd89c..13f610e 100644 --- a/backend/internal/modules/file/storage.go +++ b/backend/internal/modules/file/storage.go @@ -53,18 +53,22 @@ func (s *Storage) Put(ctx context.Context, scene string, header *multipart.FileH if err != nil { return "", err } - _, err = s.client.PutObject(ctx, s.bucket, key, reader, header.Size, minio.PutObjectOptions{ - ContentType: contentType, - UserMetadata: map[string]string{ - "original-filename": header.Filename, - }, - }) - if err != nil { + if err := s.PutObject(ctx, key, reader, header.Size, contentType, map[string]string{ + "original-filename": header.Filename, + }); err != nil { return "", err } return key, nil } +func (s *Storage) PutObject(ctx context.Context, key string, reader io.Reader, size int64, contentType string, metadata map[string]string) error { + _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ + ContentType: contentType, + UserMetadata: metadata, + }) + return err +} + func (s *Storage) Get(ctx context.Context, key string) (*Object, error) { object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) if err != nil { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 9177ef1..ad420ff 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -65,6 +65,49 @@ type AdminListResult struct { PageSize int `json:"page_size"` } +type PublicListQuery struct { + Page int + PageSize int + Keyword string + Sort string + Zone string + Server []string + Region []string + LoginMethod []string + Rank []string + Insurance []string + Stamina []string + Load []string + SkinGroup []string + SkinName []string + MinCoin *float64 + MaxCoin *float64 + MinPrice *float64 + MaxPrice *float64 + MinDeposit *float64 + MaxDeposit *float64 + MinTotal *float64 + MaxTotal *float64 + MinFireLevel *float64 + MaxFireLevel *float64 + MinSecretKD *float64 + MaxSecretKD *float64 + ResourceRanges map[string]NumberRange +} + +type NumberRange struct { + Min *float64 + Max *float64 +} + +type PublicListResult struct { + Items []ListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + ZoneCounts map[string]int64 `json:"zone_counts"` +} + type AdminActionRequest struct { Reason string `json:"reason" binding:"required"` } diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 06ae9b7..b1bd219 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "strings" "hfb_sys/backend/internal/middleware" filemodule "hfb_sys/backend/internal/modules/file" @@ -196,12 +197,12 @@ func (h *Handler) Offline(c *gin.Context) { } func (h *Handler) ListPublic(c *gin.Context) { - items, err := h.service.ListPublic() + items, err := h.service.ListPublic(parsePublicListQuery(c)) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, items) } func (h *Handler) FindPublic(c *gin.Context) { @@ -227,7 +228,7 @@ func (h *Handler) Cover(c *gin.Context) { writeListingError(c, err) return } - h.writePublicObject(c, key) + h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantThumb)...) } func (h *Handler) Screenshot(c *gin.Context) { @@ -245,16 +246,23 @@ func (h *Handler) Screenshot(c *gin.Context) { writeListingError(c, err) return } - h.writePublicObject(c, key) + h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantMedium)...) } -func (h *Handler) writePublicObject(c *gin.Context, key string) { +func (h *Handler) writePublicObject(c *gin.Context, keys ...string) { if h.storage == nil { response.ServiceUnavailable(c, "文件存储未连接") return } - object, err := h.storage.Get(c.Request.Context(), key) - if err != nil { + var object *filemodule.Object + for _, key := range keys { + nextObject, err := h.storage.Get(c.Request.Context(), key) + if err == nil { + object = nextObject + break + } + } + if object == nil { response.Error(c, http.StatusNotFound, "not_found", "图片不存在或暂不可访问") return } @@ -368,6 +376,124 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { return query, true } +func parsePublicListQuery(c *gin.Context) PublicListQuery { + query := PublicListQuery{ + Page: parsePositiveInt(c.DefaultQuery("page", "1"), 1), + PageSize: parsePositiveInt(c.DefaultQuery("page_size", "20"), 20), + Keyword: strings.TrimSpace(c.Query("keyword")), + Sort: strings.TrimSpace(c.Query("sort")), + Zone: strings.TrimSpace(c.Query("zone")), + Server: parseCSVQuery(c.Query("server")), + Region: parseCSVQuery(c.Query("region")), + LoginMethod: parseCSVQuery(firstNonEmpty(c.Query("login_method"), c.Query("login"))), + Rank: parseCSVQuery(c.Query("rank")), + Insurance: parseCSVQuery(c.Query("insurance")), + Stamina: parseCSVQuery(c.Query("stamina")), + Load: parseCSVQuery(c.Query("load")), + SkinGroup: parseCSVQuery(c.Query("skin_group")), + SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))), + MinCoin: parseOptionalFloat(c.Query("min_coin")), + MaxCoin: parseOptionalFloat(c.Query("max_coin")), + MinPrice: parseOptionalFloat(c.Query("min_price")), + MaxPrice: parseOptionalFloat(c.Query("max_price")), + MinDeposit: parseOptionalFloat(c.Query("min_deposit")), + MaxDeposit: parseOptionalFloat(c.Query("max_deposit")), + MinTotal: parseOptionalFloat(c.Query("min_total")), + MaxTotal: parseOptionalFloat(c.Query("max_total")), + MinFireLevel: parseOptionalFloat(c.Query("min_fire_level")), + MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), + MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), + MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), + ResourceRanges: parseResourceRanges(c), + } + if query.PageSize > 50 { + query.PageSize = 50 + } + return query +} + +func parsePositiveInt(value string, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func parseOptionalFloat(value string) *float64 { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return nil + } + return &parsed +} + +func parseCSVQuery(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + return result +} + +func parseResourceRanges(c *gin.Context) map[string]NumberRange { + ranges := make(map[string]NumberRange) + for key, values := range c.Request.URL.Query() { + if !strings.HasPrefix(key, "resource_") { + continue + } + var resourceKey string + var isMin bool + switch { + case strings.HasSuffix(key, "_min"): + resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_min") + isMin = true + case strings.HasSuffix(key, "_max"): + resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_max") + default: + continue + } + if resourceKey == "" || len(values) == 0 { + continue + } + value := parseOptionalFloat(values[0]) + if value == nil { + continue + } + numberRange := ranges[resourceKey] + if isMin { + numberRange.Min = value + } else { + numberRange.Max = value + } + ranges[resourceKey] = numberRange + } + return ranges +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func writeListingError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 1ce5783..02cf4bf 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/url" + "sort" "strconv" "strings" "time" @@ -385,17 +386,56 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error { }) } -func (r *Repository) ListPublic() ([]ListingDTO, error) { +func (r *Repository) ListPublic(query PublicListQuery) (*PublicListResult, error) { var rows []listingRow err := r.baseQuery(). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Order("l.published_at DESC, l.id DESC"). - Limit(100). Scan(&rows).Error if err != nil { return nil, err } - return publicListings(rowsToDTO(rows)), nil + items := publicListings(rowsToDTO(rows)) + baseQuery := query + baseQuery.Zone = "" + items = filterPublicListings(items, baseQuery) + zoneCounts := publicZoneCounts(items) + if query.Zone != "" && query.Zone != "all" { + items = filterPublicListings(items, query) + } + sortPublicListings(items, query.Sort) + total := int64(len(items)) + page := query.Page + if page <= 0 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + start := (page - 1) * pageSize + if start < 0 { + start = 0 + } + if start >= len(items) { + items = []ListingDTO{} + } else { + end := start + pageSize + if end > len(items) { + end = len(items) + } + items = items[start:end] + } + return &PublicListResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + ZoneCounts: zoneCounts, + }, nil } func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) { @@ -410,6 +450,379 @@ func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) { return sellerListings(rowsToDTO(rows)), nil } +func filterPublicListings(items []ListingDTO, query PublicListQuery) []ListingDTO { + filtered := make([]ListingDTO, 0, len(items)) + for _, item := range items { + if !matchesPublicQuery(item, query) { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool { + if !matchesPublicZone(item, query.Zone) { + return false + } + if keyword := strings.ToLower(strings.TrimSpace(query.Keyword)); keyword != "" && !strings.Contains(publicSearchText(item), keyword) { + return false + } + if !matchesAny(query.Server, strings.TrimSpace(item.ServerRegion)) { + return false + } + if len(query.Region) > 0 && !intersects(query.Region, assetRegionsFromSummary(item.AssetSummary)) { + return false + } + if !matchesAny(query.LoginMethod, strings.TrimSpace(item.LoginPlatform)) { + return false + } + if !matchesAny(query.Rank, strings.TrimSpace(item.RankLevel)) { + return false + } + if !matchesAny(query.Insurance, readAssetString(item.AssetSummary, "season_insurance")) { + return false + } + if !matchesAny(query.Stamina, readAssetString(item.AssetSummary, "stamina_level")) { + return false + } + if !matchesAny(query.Load, readAssetString(item.AssetSummary, "load_level")) { + return false + } + if len(query.SkinName) > 0 { + if len(query.SkinGroup) > 0 { + if !skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName) { + return false + } + } else if !intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary)) { + return false + } + } else if len(query.SkinGroup) > 0 && !skinGroupsHaveAny(item.AssetSummary, query.SkinGroup) { + return false + } + + price := item.Price + deposit := item.DepositAmount + total := price + deposit + coinM := coinMFromListing(item) + if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) { + return false + } + if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) { + return false + } + if !numberInRange(deposit, NumberRange{Min: query.MinDeposit, Max: query.MaxDeposit}) { + return false + } + if !numberInRange(total, NumberRange{Min: query.MinTotal, Max: query.MaxTotal}) { + return false + } + if !numberInRange(readSummaryNumber(item.AssetSummary["fire_level"]), NumberRange{Min: query.MinFireLevel, Max: query.MaxFireLevel}) { + return false + } + if !numberInRange(readSummaryNumber(item.AssetSummary["secret_kd"]), NumberRange{Min: query.MinSecretKD, Max: query.MaxSecretKD}) { + return false + } + for resourceKey, resourceRange := range query.ResourceRanges { + if !numberInRange(resourceQuantity(item.AssetSummary, resourceKey), resourceRange) { + return false + } + } + return true +} + +func sortPublicListings(items []ListingDTO, sortKey string) { + sort.SliceStable(items, func(i, j int) bool { + a := items[i] + b := items[j] + switch sortKey { + case "priceAsc": + return a.Price < b.Price + case "priceDesc": + return a.Price > b.Price + case "coinDesc": + return a.HafCoinAmount > b.HafCoinAmount + case "awmDesc": + aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo") + bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo") + if aAmmo != bAmmo { + return aAmmo > bAmmo + } + return a.HafCoinAmount > b.HafCoinAmount + case "published", "recommended", "comprehensive", "": + return publicRecentLess(a, b) + default: + return publicRecentLess(a, b) + } + }) +} + +func publicRecentLess(a ListingDTO, b ListingDTO) bool { + aTime := time.Time{} + bTime := time.Time{} + if a.PublishedAt != nil { + aTime = *a.PublishedAt + } + if b.PublishedAt != nil { + bTime = *b.PublishedAt + } + if !aTime.Equal(bTime) { + return aTime.After(bTime) + } + return a.ID > b.ID +} + +func matchesPublicZone(item ListingDTO, zone string) bool { + switch zone { + case "", "all": + return true + case "sale": + return item.IsAccelerated + case "gift": + return hasGiftResourcesSummary(item.AssetSummary) + case "night": + return isNightAvailableSummary(item.AssetSummary) + case "password": + return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码") + case "highCoin": + return coinMFromListing(item) >= 100 + default: + return true + } +} + +func publicZoneCounts(items []ListingDTO) map[string]int64 { + counts := map[string]int64{ + "all": int64(len(items)), + "sale": 0, + "gift": 0, + "night": 0, + "password": 0, + "highCoin": 0, + } + for _, item := range items { + for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} { + if matchesPublicZone(item, zone) { + counts[zone]++ + } + } + } + return counts +} + +func publicSearchText(item ListingDTO) string { + parts := []string{ + item.Title, + item.Description, + item.RankLevel, + item.ServerRegion, + item.LoginPlatform, + } + parts = append(parts, assetRegionsFromSummary(item.AssetSummary)...) + parts = append(parts, skinNamesFromSummary(item.AssetSummary)...) + return strings.ToLower(strings.Join(parts, " ")) +} + +func matchesAny(options []string, value string) bool { + if len(options) == 0 { + return true + } + for _, option := range options { + if option == value { + return true + } + } + return false +} + +func intersects(options []string, values []string) bool { + if len(options) == 0 { + return true + } + valueSet := make(map[string]struct{}, len(values)) + for _, value := range values { + valueSet[value] = struct{}{} + } + for _, option := range options { + if _, ok := valueSet[option]; ok { + return true + } + } + return false +} + +func numberInRange(value float64, numberRange NumberRange) bool { + if numberRange.Min != nil && value < *numberRange.Min { + return false + } + if numberRange.Max != nil && value > *numberRange.Max { + return false + } + return true +} + +func coinMFromListing(item ListingDTO) float64 { + return float64(item.HafCoinAmount) / 1000000 +} + +func readAssetString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, _ := summary[key].(string) + return strings.TrimSpace(value) +} + +func assetRegionsFromSummary(summary map[string]any) []string { + if summary == nil { + return nil + } + values, ok := summary["common_regions"].([]any) + if !ok { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if ok && strings.TrimSpace(text) != "" { + result = append(result, strings.TrimSpace(text)) + } + } + return result +} + +func skinNamesFromSummary(summary map[string]any) []string { + groups := skinGroupsFromSummary(summary) + result := make([]string, 0) + for _, skins := range groups { + result = append(result, skins...) + } + return result +} + +func skinGroupsContainAny(summary map[string]any, groupKeys []string, skinNames []string) bool { + groups := skinGroupsFromSummary(summary) + for _, groupKey := range groupKeys { + if intersects(skinNames, groups[groupKey]) { + return true + } + } + return false +} + +func skinGroupsHaveAny(summary map[string]any, groupKeys []string) bool { + groups := skinGroupsFromSummary(summary) + for _, groupKey := range groupKeys { + if len(groups[groupKey]) > 0 { + return true + } + } + return false +} + +func skinGroupsFromSummary(summary map[string]any) map[string][]string { + result := make(map[string][]string) + if summary == nil { + return result + } + rawGroups, ok := summary["skin_groups"].(map[string]any) + if !ok { + return result + } + for key, rawSkins := range rawGroups { + values, ok := rawSkins.([]any) + if !ok { + continue + } + for _, value := range values { + text, ok := value.(string) + if ok && strings.TrimSpace(text) != "" { + result[key] = append(result[key], strings.TrimSpace(text)) + } + } + } + return result +} + +func resourceQuantity(summary map[string]any, resourceKey string) float64 { + if summary == nil { + return 0 + } + resources, ok := summary["resources"].([]any) + if !ok { + return 0 + } + for _, resource := range resources { + row, ok := resource.(map[string]any) + if !ok { + continue + } + if rowKey, _ := row["key"].(string); rowKey == resourceKey { + return readSummaryNumber(row["quantity"]) + } + } + return 0 +} + +func hasGiftResourcesSummary(summary map[string]any) bool { + if summary == nil { + return false + } + resources, ok := summary["resources"].([]any) + if !ok { + return false + } + for _, resource := range resources { + row, ok := resource.(map[string]any) + if !ok { + continue + } + if mode, _ := row["mode"].(string); mode == "赠送" && readSummaryNumber(row["quantity"]) > 0 { + return true + } + } + return false +} + +func isNightAvailableSummary(summary map[string]any) bool { + if summary == nil { + return false + } + onlineTime, ok := summary["online_time"].(map[string]any) + if !ok { + return false + } + start, okStart := parseTimeHourValue(onlineTime["start"]) + end, okEnd := parseTimeHourValue(onlineTime["end"]) + if !okStart || !okEnd { + return false + } + return timeRangeCoversHour(start, end, 22) || timeRangeCoversHour(start, end, 23) || timeRangeCoversHour(start, end, 0) +} + +func parseTimeHourValue(value any) (int, bool) { + text, ok := value.(string) + if !ok { + return 0, false + } + parts := strings.Split(text, ":") + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, false + } + return hour, true +} + +func timeRangeCoversHour(start int, end int, hour int) bool { + if start == end { + return true + } + if start < end { + return hour >= start && hour <= end + } + return hour >= start || hour <= end +} + func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) { dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 669f0a7..a9ece21 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -163,11 +163,11 @@ func (s *Service) Offline(ownerID uint64, id uint64) error { return s.repo.Offline(ownerID, id) } -func (s *Service) ListPublic() ([]ListingDTO, error) { +func (s *Service) ListPublic(query PublicListQuery) (*PublicListResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListPublic() + return s.repo.ListPublic(query) } func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) { diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 6a01691..1c6d8dc 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -1,17 +1,21 @@ import { apiClient } from './client' import type { ApiResponse } from './types' +import { optimizeImageForUpload } from '@/utils/imageUpload' export interface UploadedFile { object_key: string url: string + thumbnail_url?: string + medium_url?: string filename: string content_type: string size: number } export async function uploadFile(file: File, scene: string) { + const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() - form.append('file', file) + form.append('file', uploadTarget) form.append('scene', scene) const { data } = await apiClient.post>('/files/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, @@ -20,8 +24,9 @@ export async function uploadFile(file: File, scene: string) { } export async function uploadAdminFile(file: File, scene: string) { + const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() - form.append('file', file) + form.append('file', uploadTarget) form.append('scene', scene) const { data } = await apiClient.post>('/admin/files/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index 6144b3f..1b52665 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -43,9 +43,67 @@ export interface ListingPayload { deposit_amount: number } -export async function fetchListings() { - const { data } = await apiClient.get>('/listings') - return data.data.items +export interface PublicListingQuery { + page?: number + page_size?: number + keyword?: string + sort?: string + zone?: string + server?: string + region?: string + login_method?: string + rank?: string + insurance?: string + stamina?: string + load?: string + skin_group?: string + skin_name?: string + min_coin?: number + max_coin?: number + min_price?: number + max_price?: number + min_deposit?: number + max_deposit?: number + min_total?: number + max_total?: number + min_fire_level?: number + max_fire_level?: number + min_secret_kd?: number + max_secret_kd?: number + [key: `resource_${string}_min`]: number | undefined + [key: `resource_${string}_max`]: number | undefined +} + +export interface PublicListingPage { + items: Listing[] + total: number + page: number + page_size: number + zone_counts: Record +} + +export async function fetchListings(query: PublicListingQuery = {}) { + const page = await fetchListingsPage(query) + return page.items +} + +export async function fetchListingsPage(query: PublicListingQuery = {}) { + const params = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null) + ) + const { data } = await apiClient.get>>('/listings', { params }) + return normalizePublicListingPage(data.data, query) +} + +function normalizePublicListingPage(data: Partial, query: PublicListingQuery): PublicListingPage { + const items = Array.isArray(data.items) ? data.items : [] + return { + items, + total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, + page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1), + page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 20), + zone_counts: data.zone_counts && typeof data.zone_counts === 'object' ? data.zone_counts : {}, + } } export async function fetchListing(id: string | number) { diff --git a/frontend/src/components/ChatAttachmentImage.vue b/frontend/src/components/ChatAttachmentImage.vue index 735f6b0..a8ef44f 100644 --- a/frontend/src/components/ChatAttachmentImage.vue +++ b/frontend/src/components/ChatAttachmentImage.vue @@ -55,7 +55,7 @@ onBeforeUnmount(revokeCurrentURL) diff --git a/frontend/src/utils/imageUpload.ts b/frontend/src/utils/imageUpload.ts new file mode 100644 index 0000000..e9ada14 --- /dev/null +++ b/frontend/src/utils/imageUpload.ts @@ -0,0 +1,84 @@ +const IMAGE_UPLOAD_MAX_SIDE: Record = { + avatar: 512, + chat: 1280, + "home-banner": 1920, + listing: 1920, + dispute: 1920, + handoff: 1920, + realname: 1920, +}; + +const IMAGE_UPLOAD_QUALITY: Record = { + avatar: 0.82, + chat: 0.8, + "home-banner": 0.84, + listing: 0.84, + dispute: 0.86, + handoff: 0.86, + realname: 0.86, +}; + +export async function optimizeImageForUpload(file: File, scene: string) { + if (!file.type.startsWith("image/")) return file; + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) return file; + if (typeof document === "undefined") return file; + + try { + const image = await loadImage(file); + const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] || 1600; + const quality = IMAGE_UPLOAD_QUALITY[scene] || 0.82; + const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return file; + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + const blob = await canvasToBlob(canvas, "image/webp", quality); + if (!blob || blob.size >= file.size) return file; + return new File([blob], replaceFileExt(file.name, "webp"), { + type: "image/webp", + lastModified: Date.now(), + }); + } catch { + return file; + } +} + +function loadImage(file: File) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("图片读取失败")); + }; + image.src = url; + }); +} + +function fitSize(width: number, height: number, maxSide: number) { + if (width <= 0 || height <= 0) return { width: 1, height: 1 }; + const scale = Math.min(1, maxSide / Math.max(width, height)); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) { + return new Promise((resolve) => { + canvas.toBlob(resolve, type, quality); + }); +} + +function replaceFileExt(filename: string, ext: string) { + const base = filename.replace(/\.[^.]+$/, ""); + return `${base || "image"}.${ext}`; +} diff --git a/frontend/src/views/account/ChatView.vue b/frontend/src/views/account/ChatView.vue index 62d593c..0e1f4d8 100644 --- a/frontend/src/views/account/ChatView.vue +++ b/frontend/src/views/account/ChatView.vue @@ -147,7 +147,7 @@ async function handleImageChange(event: Event) { uploading.value = true try { for (const file of files.slice(0, slots)) { - if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) { ElMessage.warning(`${file.name} 不符合图片规则`) continue } diff --git a/frontend/src/views/admin/AdminChatsView.vue b/frontend/src/views/admin/AdminChatsView.vue index 490ef45..ba760a4 100644 --- a/frontend/src/views/admin/AdminChatsView.vue +++ b/frontend/src/views/admin/AdminChatsView.vue @@ -182,7 +182,7 @@ async function handleImageChange(event: Event) { uploading.value = true try { for (const file of files.slice(0, slots)) { - if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) { ElMessage.warning(`${file.name} 不符合图片规则`) continue } diff --git a/frontend/src/views/mobile/MobileChatView.vue b/frontend/src/views/mobile/MobileChatView.vue index e1f3d2f..2543676 100644 --- a/frontend/src/views/mobile/MobileChatView.vue +++ b/frontend/src/views/mobile/MobileChatView.vue @@ -145,7 +145,7 @@ async function handleImageChange(event: Event) { uploading.value = true try { for (const file of files.slice(0, slots)) { - if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) { showToast(`${file.name} 不符合图片规则`) continue } diff --git a/frontend/src/views/mobile/MobileHomeView.css b/frontend/src/views/mobile/MobileHomeView.css index c06b63f..17cc9bd 100644 --- a/frontend/src/views/mobile/MobileHomeView.css +++ b/frontend/src/views/mobile/MobileHomeView.css @@ -544,6 +544,14 @@ white-space: nowrap; } +.mobile-load-state { + padding: 4px 0 18px; + color: #8a96a8; + font-size: 12px; + font-weight: 700; + text-align: center; +} + @media (max-width: 374px) { .mobile-card { grid-template-columns: 112px minmax(0, 1fr); diff --git a/frontend/src/views/mobile/MobileHomeView.vue b/frontend/src/views/mobile/MobileHomeView.vue index a3036f5..84ef6e5 100644 --- a/frontend/src/views/mobile/MobileHomeView.vue +++ b/frontend/src/views/mobile/MobileHomeView.vue @@ -1,5 +1,5 @@ @@ -1043,6 +1037,14 @@ function timeRangeCoversHour(start: number, end: number, hour: number) { min-width: 0; } +.infinite-load-state { + padding: 6px 0 18px; + color: #94a3b8; + font-size: 13px; + font-weight: 700; + text-align: center; +} + .hero-section { display: grid; grid-template-columns: minmax(0, 1fr) 320px; diff --git a/frontend/src/views/public/ListingDetailView.vue b/frontend/src/views/public/ListingDetailView.vue index 41d73b7..d69604f 100644 --- a/frontend/src/views/public/ListingDetailView.vue +++ b/frontend/src/views/public/ListingDetailView.vue @@ -264,7 +264,7 @@ function listingPrice(item: Listing) {
- +
{{ shot.label }}
diff --git a/frontend/src/views/public/ListingsView.vue b/frontend/src/views/public/ListingsView.vue index 7d3db36..c261255 100644 --- a/frontend/src/views/public/ListingsView.vue +++ b/frontend/src/views/public/ListingsView.vue @@ -708,6 +708,8 @@ function parseQuantityUnit(price: string) { v-if="item.cover_url" :src="item.cover_url" :alt="getListingTitle(item)" + loading="lazy" + decoding="async" />