feat avatar compress uploads to 20kb

This commit is contained in:
IanShaw027
2026-04-21 08:53:59 +08:00
parent 07f23aaa7d
commit 6da08262d7
8 changed files with 321 additions and 44 deletions

View File

@@ -39,10 +39,11 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4 github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0 go.uber.org/zap v1.24.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.49.0
golang.org/x/net v0.49.0 golang.org/x/image v0.39.0
golang.org/x/sync v0.19.0 golang.org/x/net v0.52.0
golang.org/x/term v0.40.0 golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3 modernc.org/sqlite v1.44.3
@@ -103,7 +104,6 @@ require (
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect
@@ -172,10 +172,10 @@ require (
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.43.0 // indirect
google.golang.org/grpc v1.75.1 // indirect google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -183,8 +181,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -220,8 +216,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -255,8 +249,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -286,8 +278,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -320,8 +310,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -413,16 +401,18 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -432,16 +422,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
@@ -9,11 +10,19 @@ import (
"fmt" "fmt"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"image"
"image/color"
stddraw "image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"log/slog" "log/slog"
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
"time" "time"
xdraw "golang.org/x/image/draw"
) )
var ( var (
@@ -31,6 +40,7 @@ var (
const ( const (
maxNotifyEmails = 3 // Maximum number of notification emails per user maxNotifyEmails = 3 // Maximum number of notification emails per user
maxInlineAvatarBytes = 100 * 1024 maxInlineAvatarBytes = 100 * 1024
targetAvatarBytes = 20 * 1024
// User-level rate limiting for notify email verification codes // User-level rate limiting for notify email verification codes
notifyCodeUserRateLimit = 5 notifyCodeUserRateLimit = 5
@@ -39,6 +49,11 @@ const (
defaultUserIdentityRedirect = "/settings/profile" defaultUserIdentityRedirect = "/settings/profile"
) )
var (
avatarScaleSteps = []float64{1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36}
avatarQualitySteps = []int{88, 80, 72, 64, 56, 48, 40, 32}
)
// UserListFilters contains all filter options for listing users // UserListFilters contains all filter options for listing users
type UserListFilters struct { type UserListFilters struct {
Status string // User status filter Status string // User status filter
@@ -432,6 +447,14 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
return UpsertUserAvatarInput{}, ErrAvatarTooLarge return UpsertUserAvatarInput{}, ErrAvatarTooLarge
} }
if len(decoded) > targetAvatarBytes {
decoded, contentType, err = compressInlineAvatar(decoded)
if err != nil {
return UpsertUserAvatarInput{}, err
}
raw = "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(decoded)
}
sum := sha256.Sum256(decoded) sum := sha256.Sum256(decoded)
return UpsertUserAvatarInput{ return UpsertUserAvatarInput{
StorageProvider: "inline", StorageProvider: "inline",
@@ -442,6 +465,38 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
}, nil }, nil
} }
func compressInlineAvatar(decoded []byte) ([]byte, string, error) {
src, _, err := image.Decode(bytes.NewReader(decoded))
if err != nil {
return nil, "", ErrAvatarInvalid
}
srcBounds := src.Bounds()
if srcBounds.Empty() {
return nil, "", ErrAvatarInvalid
}
for _, scale := range avatarScaleSteps {
width := max(1, int(float64(srcBounds.Dx())*scale))
height := max(1, int(float64(srcBounds.Dy())*scale))
dst := image.NewRGBA(image.Rect(0, 0, width, height))
stddraw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, stddraw.Over, nil)
for _, quality := range avatarQualitySteps {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality}); err != nil {
return nil, "", ErrAvatarInvalid
}
if buf.Len() <= targetAvatarBytes {
return buf.Bytes(), "image/jpeg", nil
}
}
}
return nil, "", ErrAvatarTooLarge
}
func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary { func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary {
summary := UserIdentitySummary{ summary := UserIdentitySummary{
Provider: "email", Provider: "email",

View File

@@ -3,11 +3,14 @@
package service package service
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"image"
"image/png"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -361,6 +364,57 @@ func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256) require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
} }
func TestUpdateProfile_CompressesInlineAvatarToTwentyKilobytes(t *testing.T) {
var encoded bytes.Buffer
for _, size := range []int{192, 224, 256, 288} {
encoded.Reset()
var img image.RGBA
img.Rect = image.Rect(0, 0, size, size)
img.Stride = size * 4
img.Pix = make([]byte, size*size*4)
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
offset := y*img.Stride + x*4
img.Pix[offset] = uint8((x*x + y*17) % 255)
img.Pix[offset+1] = uint8((y*y + x*29) % 255)
img.Pix[offset+2] = uint8(((x * y) + x*13 + y*7) % 255)
img.Pix[offset+3] = 0xff
}
}
require.NoError(t, png.Encode(&encoded, &img))
if encoded.Len() > 20*1024 && encoded.Len() <= maxInlineAvatarBytes {
break
}
}
require.Greater(t, encoded.Len(), 20*1024)
require.LessOrEqual(t, encoded.Len(), maxInlineAvatarBytes)
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())
repo := &mockUserRepo{
getByIDUser: &User{
ID: 17,
Email: "avatar-compress@example.com",
Username: "avatar-compress",
},
}
svc := NewUserService(repo, nil, nil, nil)
updated, err := svc.UpdateProfile(context.Background(), 17, UpdateProfileRequest{
AvatarURL: &dataURL,
})
require.NoError(t, err)
require.Len(t, repo.upsertAvatarArgs, 1)
require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
require.LessOrEqual(t, repo.upsertAvatarArgs[0].ByteSize, 20*1024)
require.Equal(t, "image/jpeg", repo.upsertAvatarArgs[0].ContentType)
require.Contains(t, repo.upsertAvatarArgs[0].URL, "data:image/jpeg;base64,")
require.Equal(t, "inline", updated.AvatarSource)
require.Equal(t, "image/jpeg", updated.AvatarMIME)
require.LessOrEqual(t, updated.AvatarByteSize, 20*1024)
require.Contains(t, updated.AvatarURL, "data:image/jpeg;base64,")
require.NotEmpty(t, updated.AvatarSHA256)
}
func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) { func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
raw := make([]byte, maxInlineAvatarBytes+1) raw := make([]byte, maxInlineAvatarBytes+1)
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw) dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)

View File

@@ -176,6 +176,9 @@ const authStore = useAuthStore()
const appStore = useAppStore() const appStore = useAppStore()
const maxAvatarBytes = 100 * 1024 const maxAvatarBytes = 100 * 1024
const targetAvatarUploadBytes = 20 * 1024
const avatarScaleSteps = [1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36]
const avatarQualitySteps = [0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36]
const avatarDraft = ref(props.user?.avatar_url?.trim() || '') const avatarDraft = ref(props.user?.avatar_url?.trim() || '')
const avatarSaving = ref(false) const avatarSaving = ref(false)
@@ -341,6 +344,72 @@ function readFileAsDataURL(file: File): Promise<string> {
}) })
} }
function loadImage(dataURL: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(t('profile.avatar.readFailed')))
image.src = dataURL
})
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error(t('profile.avatar.compressFailed')))
return
}
resolve(blob)
}, type, quality)
})
}
async function compressAvatarFile(file: File): Promise<File> {
const sourceDataURL = await readFileAsDataURL(file)
const image = await loadImage(sourceDataURL)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error(t('profile.avatar.compressFailed'))
}
for (const scale of avatarScaleSteps) {
const width = Math.max(1, Math.round(image.naturalWidth * scale))
const height = Math.max(1, Math.round(image.naturalHeight * scale))
canvas.width = width
canvas.height = height
ctx.clearRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
for (const quality of avatarQualitySteps) {
const blob = await canvasToBlob(canvas, 'image/webp', quality)
if (blob.size <= targetAvatarUploadBytes) {
const fileName = file.name.replace(/\.[^.]+$/, '') || 'avatar'
return new File([blob], `${fileName}.webp`, { type: 'image/webp' })
}
}
}
throw new Error(t('profile.avatar.compressTooLarge'))
}
async function prepareAvatarUpload(file: File): Promise<File> {
if (!file.type.startsWith('image/')) {
throw new Error(t('profile.avatar.invalidType'))
}
if (file.type === 'image/gif') {
if (file.size > targetAvatarUploadBytes) {
throw new Error(t('profile.avatar.gifTooLarge'))
}
return file
}
if (file.size <= targetAvatarUploadBytes) {
return file
}
return compressAvatarFile(file)
}
async function handleAvatarFileChange(event: Event) { async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement | null const input = event.target as HTMLInputElement | null
const file = input?.files?.[0] const file = input?.files?.[0]
@@ -360,7 +429,8 @@ async function handleAvatarFileChange(event: Event) {
} }
try { try {
const dataURL = await readFileAsDataURL(file) const preparedFile = await prepareAvatarUpload(file)
const dataURL = await readFileAsDataURL(preparedFile)
const normalized = validateAvatarInput(dataURL) const normalized = validateAvatarInput(dataURL)
if (!normalized) { if (!normalized) {
return return

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import type { User } from '@/types' import type { User } from '@/types'
@@ -51,11 +51,15 @@ vi.mock('vue-i18n', async (importOriginal) => {
if (key === 'profile.avatar.inputLabel') return 'Avatar URL or data URL' if (key === 'profile.avatar.inputLabel') return 'Avatar URL or data URL'
if (key === 'profile.avatar.inputPlaceholder') return 'https://cdn.example.com/avatar.png' if (key === 'profile.avatar.inputPlaceholder') return 'https://cdn.example.com/avatar.png'
if (key === 'profile.avatar.uploadAction') return 'Upload image' if (key === 'profile.avatar.uploadAction') return 'Upload image'
if (key === 'profile.avatar.uploadHint') return 'Images must be 100KB or smaller' if (key === 'profile.avatar.uploadHint') return 'Images must be 100KB or smaller and will be compressed to 20KB'
if (key === 'profile.avatar.saveSuccess') return 'Avatar updated' if (key === 'profile.avatar.saveSuccess') return 'Avatar updated'
if (key === 'profile.avatar.deleteSuccess') return 'Avatar removed' if (key === 'profile.avatar.deleteSuccess') return 'Avatar removed'
if (key === 'profile.avatar.invalidType') return 'Please choose an image file' if (key === 'profile.avatar.invalidType') return 'Please choose an image file'
if (key === 'profile.avatar.fileTooLarge') return 'Avatar image must be 100KB or smaller' if (key === 'profile.avatar.fileTooLarge') return 'Avatar image must be 100KB or smaller'
if (key === 'profile.avatar.gifTooLarge') return 'GIF avatars must already be 20KB or smaller'
if (key === 'profile.avatar.compressTooLarge') return 'Unable to compress this image below 20KB'
if (key === 'profile.avatar.compressFailed') return 'Failed to compress the selected image'
if (key === 'profile.avatar.readFailed') return 'Failed to read the selected image'
if (key === 'profile.avatar.invalidValue') return 'Enter a valid avatar URL or image data URL' if (key === 'profile.avatar.invalidValue') return 'Enter a valid avatar URL or image data URL'
if (key === 'profile.avatar.emptyDeleteHint') return 'Avatar already removed' if (key === 'profile.avatar.emptyDeleteHint') return 'Avatar already removed'
if (key === 'profile.authBindings.providers.email') return 'Email' if (key === 'profile.authBindings.providers.email') return 'Email'
@@ -92,6 +96,63 @@ function createUser(overrides: Partial<User> = {}): User {
} }
} }
async function flushAsyncWork(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
const originalFileReader = globalThis.FileReader
const originalImage = globalThis.Image
const originalCreateElement = document.createElement.bind(document)
function installAvatarCompressionMocks() {
class MockFileReader {
result: string | ArrayBuffer | null = null
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null
error: DOMException | null = null
readAsDataURL(blob: Blob) {
if (blob.type === 'image/webp') {
this.result = 'data:image/webp;base64,' + Buffer.from('compressed-avatar').toString('base64')
} else {
this.result = 'data:image/png;base64,' + Buffer.from('original-avatar').toString('base64')
}
this.onload?.call(this as unknown as FileReader, new ProgressEvent('load'))
}
}
class MockImage {
naturalWidth = 1200
naturalHeight = 1200
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
this.onload?.()
}
}
globalThis.FileReader = MockFileReader as unknown as typeof FileReader
globalThis.Image = MockImage as unknown as typeof Image
vi.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => {
if (tagName === 'canvas') {
return {
width: 0,
height: 0,
getContext: () => ({
clearRect: vi.fn(),
drawImage: vi.fn(),
}),
toBlob: (callback: BlobCallback) => {
callback(new Blob([new Uint8Array(8 * 1024)], { type: 'image/webp' }))
},
} as unknown as HTMLCanvasElement
}
return originalCreateElement(tagName, options)
}) as typeof document.createElement)
}
describe('ProfileInfoCard', () => { describe('ProfileInfoCard', () => {
beforeEach(() => { beforeEach(() => {
updateProfileMock.mockReset() updateProfileMock.mockReset()
@@ -100,6 +161,12 @@ describe('ProfileInfoCard', () => {
authStoreState.user = null authStoreState.user = null
}) })
afterEach(() => {
globalThis.FileReader = originalFileReader
globalThis.Image = originalImage
vi.restoreAllMocks()
})
it('saves a remote avatar URL and updates the auth store', async () => { it('saves a remote avatar URL and updates the auth store', async () => {
const updatedUser = createUser({ avatar_url: 'https://cdn.example.com/new.png' }) const updatedUser = createUser({ avatar_url: 'https://cdn.example.com/new.png' })
updateProfileMock.mockResolvedValue(updatedUser) updateProfileMock.mockResolvedValue(updatedUser)
@@ -148,6 +215,39 @@ describe('ProfileInfoCard', () => {
expect(showErrorMock).toHaveBeenCalledWith('Avatar image must be 100KB or smaller') expect(showErrorMock).toHaveBeenCalledWith('Avatar image must be 100KB or smaller')
}) })
it('compresses uploaded images under 100KB before saving', async () => {
installAvatarCompressionMocks()
const updatedUser = createUser({ avatar_url: 'data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI=' })
updateProfileMock.mockResolvedValue(updatedUser)
authStoreState.user = createUser()
const wrapper = mount(ProfileInfoCard, {
props: {
user: authStoreState.user
},
global: {
stubs: {
Icon: true,
ProfileIdentityBindingsSection: true
}
}
})
const fileInput = wrapper.get('[data-testid="profile-avatar-file-input"]')
Object.defineProperty(fileInput.element, 'files', {
value: [new File([new Uint8Array(80 * 1024)], 'avatar.png', { type: 'image/png' })],
configurable: true
})
await fileInput.trigger('change')
await flushAsyncWork()
await wrapper.get('[data-testid="profile-avatar-save"]').trigger('click')
expect(updateProfileMock).toHaveBeenCalledWith({
avatar_url: 'data:image/webp;base64,Y29tcHJlc3NlZC1hdmF0YXI='
})
})
it('deletes the current avatar', async () => { it('deletes the current avatar', async () => {
const updatedUser = createUser({ avatar_url: null }) const updatedUser = createUser({ avatar_url: null })
updateProfileMock.mockResolvedValue(updatedUser) updateProfileMock.mockResolvedValue(updatedUser)

View File

@@ -943,15 +943,19 @@ export default {
}, },
avatar: { avatar: {
title: 'Profile Avatar', title: 'Profile Avatar',
description: 'Set your avatar with a remote image URL or upload a small image.', description: 'Set your avatar with a remote image URL or upload an image under 100KB. Uploaded images are compressed to 20KB.',
inputLabel: 'Avatar URL or data URL', inputLabel: 'Avatar URL or data URL',
inputPlaceholder: 'https://cdn.example.com/avatar.png', inputPlaceholder: 'https://cdn.example.com/avatar.png',
uploadAction: 'Upload image', uploadAction: 'Upload image',
uploadHint: 'Images must be 100KB or smaller', uploadHint: 'Uploaded images must be 100KB or smaller. Static images are compressed to 20KB.',
saveSuccess: 'Avatar updated', saveSuccess: 'Avatar updated',
deleteSuccess: 'Avatar removed', deleteSuccess: 'Avatar removed',
invalidType: 'Please choose an image file', invalidType: 'Please choose an image file',
fileTooLarge: 'Avatar image must be 100KB or smaller', fileTooLarge: 'Avatar image must be 100KB or smaller',
gifTooLarge: 'GIF avatars must already be 20KB or smaller',
compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.',
compressFailed: 'Failed to compress the selected image.',
readFailed: 'Failed to read the selected image.',
invalidValue: 'Enter a valid avatar URL or image data URL', invalidValue: 'Enter a valid avatar URL or image data URL',
emptyDeleteHint: 'Avatar is already empty', emptyDeleteHint: 'Avatar is already empty',
}, },

View File

@@ -947,15 +947,19 @@ export default {
}, },
avatar: { avatar: {
title: '资料头像', title: '资料头像',
description: '支持填写远程图片 URL或上传不超过 100KB 的头像图片。', description: '支持填写远程图片 URL或上传不超过 100KB 的头像图片;上传图片会自动压缩到 20KB 以内。',
inputLabel: '头像 URL 或 data URL', inputLabel: '头像 URL 或 data URL',
inputPlaceholder: 'https://cdn.example.com/avatar.png', inputPlaceholder: 'https://cdn.example.com/avatar.png',
uploadAction: '上传图片', uploadAction: '上传图片',
uploadHint: '图片大小需不超过 100KB', uploadHint: '上传图片需不超过 100KB,静态图片会自动压缩到 20KB 以内',
saveSuccess: '头像已更新', saveSuccess: '头像已更新',
deleteSuccess: '头像已删除', deleteSuccess: '头像已删除',
invalidType: '请选择图片文件', invalidType: '请选择图片文件',
fileTooLarge: '头像图片必须不超过 100KB', fileTooLarge: '头像图片必须不超过 100KB',
gifTooLarge: 'GIF 头像必须在 20KB 以内',
compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片',
compressFailed: '压缩所选图片失败',
readFailed: '读取所选图片失败',
invalidValue: '请输入有效的头像 URL 或图片 data URL', invalidValue: '请输入有效的头像 URL 或图片 data URL',
emptyDeleteHint: '当前没有可删除的头像', emptyDeleteHint: '当前没有可删除的头像',
}, },