feat: 更新商城前后端代码
This commit is contained in:
2
dev.bat
2
dev.bat
@@ -3,7 +3,7 @@ setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
rem Backend (new window)
|
||||
start "mengyastore-backend" cmd /k "cd /d ""%~dp0mengyastore-backend"" && go run ."
|
||||
start "mengyastore-backend-go" cmd /k "cd /d ""%~dp0mengyastore-backend-go"" && go run ."
|
||||
|
||||
rem Frontend (new window)
|
||||
start "mengyastore-frontend" cmd /k "cd /d ""%~dp0mengyastore-frontend"" && npm run dev"
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
module mengyastore-backend
|
||||
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
module mengyastore-backend
|
||||
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,115 +1,123 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
|
||||
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
|
||||
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -50,8 +50,8 @@ type ProductRow struct {
|
||||
TotalSold int `gorm:"default:0"`
|
||||
ViewCount int `gorm:"default:0"`
|
||||
DeliveryMode string `gorm:"size:20;default:'auto'"`
|
||||
ShowNote bool `gorm:"default:false"`
|
||||
ShowContact bool `gorm:"default:false"`
|
||||
ShowNote bool `gorm:"default:true"`
|
||||
ShowContact bool `gorm:"default:true"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/cache"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -30,7 +32,19 @@ type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// VerifyAdminToken 验证管理员令牌是否正确,返回 {"valid": true/false},不暴露实际令牌值。
|
||||
// invalidateProductCache removes the cached product list so that the next
|
||||
// public request rebuilds it from the freshest DB state (Write-Invalidate).
|
||||
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
|
||||
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
@@ -84,7 +98,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
@@ -93,6 +107,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
@@ -127,7 +142,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
@@ -136,6 +151,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -154,6 +170,7 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -166,6 +183,7 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,295 +1,529 @@
|
||||
package handlers
|
||||
|
||||
|
||||
|
||||
import (
|
||||
|
||||
"fmt"
|
||||
|
||||
"log"
|
||||
|
||||
"net/http"
|
||||
|
||||
"net/url"
|
||||
|
||||
"strings"
|
||||
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
const qrSize = "320x320"
|
||||
|
||||
|
||||
|
||||
type OrderHandler struct {
|
||||
|
||||
productStore *storage.ProductStore
|
||||
|
||||
orderStore *storage.OrderStore
|
||||
|
||||
siteStore *storage.SiteStore
|
||||
|
||||
authClient *auth.SproutGateClient
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
type checkoutPayload struct {
|
||||
|
||||
ProductID string `json:"productId"`
|
||||
|
||||
Quantity int `json:"quantity"`
|
||||
|
||||
Note string `json:"note"`
|
||||
|
||||
ContactPhone string `json:"contactPhone"`
|
||||
|
||||
ContactEmail string `json:"contactEmail"`
|
||||
|
||||
NotifyEmail string `json:"notifyEmail"`
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func NewOrderHandler(productStore *storage.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient) *OrderHandler {
|
||||
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) sendOrderNotify(toEmail, toName, productName, orderID string, qty int, codes []string, isManual bool) {
|
||||
|
||||
if toEmail == "" {
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
cfg, err := h.siteStore.GetSMTPConfig()
|
||||
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
emailCfg := email.Config{
|
||||
|
||||
SMTPHost: cfg.Host,
|
||||
|
||||
SMTPPort: cfg.Port,
|
||||
|
||||
From: cfg.Email,
|
||||
|
||||
Password: cfg.Password,
|
||||
|
||||
FromName: cfg.FromName,
|
||||
|
||||
}
|
||||
|
||||
if err := email.SendOrderNotify(emailCfg, email.OrderNotifyData{
|
||||
|
||||
ToEmail: toEmail,
|
||||
|
||||
ToName: toName,
|
||||
|
||||
ProductName: productName,
|
||||
|
||||
OrderID: orderID,
|
||||
|
||||
Quantity: qty,
|
||||
|
||||
Codes: codes,
|
||||
|
||||
IsManual: isManual,
|
||||
|
||||
}); err != nil {
|
||||
|
||||
log.Printf("[Email] 发送通知失败 order=%s to=%s: %v", orderID, toEmail, err)
|
||||
|
||||
} else {
|
||||
|
||||
log.Printf("[Email] 发送通知成功 order=%s to=%s", orderID, toEmail)
|
||||
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, username, userEmail string) {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
|
||||
return "", "", ""
|
||||
|
||||
}
|
||||
|
||||
userToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
result, err := h.authClient.VerifyToken(userToken)
|
||||
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
|
||||
return "", "", ""
|
||||
|
||||
}
|
||||
|
||||
return result.User.Account, result.User.Username, result.User.Email
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
|
||||
|
||||
|
||||
|
||||
var payload checkoutPayload
|
||||
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
payload.ProductID = strings.TrimSpace(payload.ProductID)
|
||||
|
||||
if payload.ProductID == "" {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if payload.Quantity <= 0 {
|
||||
|
||||
payload.Quantity = 1
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
product, err := h.productStore.GetByID(payload.ProductID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if !product.Active {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "商品暂时无法购买"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.RequireLogin && userAccount == "" {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "该商品需要登录后才能购买"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.MaxPerAccount > 0 && userAccount != "" {
|
||||
|
||||
purchased, err := h.orderStore.CountPurchasedByAccount(userAccount, product.ID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if purchased+payload.Quantity > product.MaxPerAccount {
|
||||
|
||||
remain := product.MaxPerAccount - purchased
|
||||
|
||||
if remain <= 0 {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您已达上限", product.MaxPerAccount)})
|
||||
|
||||
} else {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您还可购买 %d 个", product.MaxPerAccount, remain)})
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.Quantity < payload.Quantity {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
isManual := product.DeliveryMode == "manual"
|
||||
|
||||
|
||||
var deliveredCodes []string
|
||||
var updatedProduct models.Product
|
||||
|
||||
if isManual {
|
||||
updatedProduct = product
|
||||
} else {
|
||||
var ok bool
|
||||
deliveredCodes, ok = extractCodes(&product, payload.Quantity)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
return
|
||||
}
|
||||
product.Quantity = len(product.Codes)
|
||||
updatedProduct, err = h.productStore.Update(product.ID, product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deliveredCodes, ok := extractCodes(&product, payload.Quantity)
|
||||
|
||||
if !ok {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
deliveryMode := product.DeliveryMode
|
||||
if deliveryMode == "" {
|
||||
deliveryMode = "auto"
|
||||
product.Quantity = len(product.Codes)
|
||||
|
||||
updatedProduct, err := h.productStore.Update(product.ID, product)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
// 通知邮箱优先级:
|
||||
// 1. SproutGate 账号邮箱(已登录用户,最可靠)
|
||||
// 2. 前端传入的 notifyEmail(来自 authState.email)
|
||||
// 3. 用户在结账表单填写的联系邮箱
|
||||
// 4. 均为空则不发送
|
||||
|
||||
|
||||
deliveryMode := "auto"
|
||||
|
||||
|
||||
|
||||
notifyEmail := strings.TrimSpace(userEmail)
|
||||
|
||||
if notifyEmail == "" {
|
||||
|
||||
notifyEmail = strings.TrimSpace(payload.NotifyEmail)
|
||||
}
|
||||
if notifyEmail == "" {
|
||||
notifyEmail = strings.TrimSpace(payload.ContactEmail)
|
||||
|
||||
}
|
||||
|
||||
// 自动发货订单立即设为已完成(卡密已提取);手动发货订单初始状态为待处理,由管理员确认。
|
||||
orderStatus := "pending"
|
||||
if !isManual {
|
||||
orderStatus = "completed"
|
||||
if notifyEmail == "" {
|
||||
|
||||
notifyEmail = strings.TrimSpace(payload.ContactEmail)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
order := models.Order{
|
||||
|
||||
ProductID: updatedProduct.ID,
|
||||
|
||||
ProductName: updatedProduct.Name,
|
||||
|
||||
UserAccount: userAccount,
|
||||
|
||||
UserName: userName,
|
||||
|
||||
Quantity: payload.Quantity,
|
||||
|
||||
DeliveredCodes: deliveredCodes,
|
||||
Status: orderStatus,
|
||||
|
||||
Status: "completed",
|
||||
|
||||
DeliveryMode: deliveryMode,
|
||||
|
||||
Note: strings.TrimSpace(payload.Note),
|
||||
|
||||
ContactPhone: strings.TrimSpace(payload.ContactPhone),
|
||||
|
||||
ContactEmail: strings.TrimSpace(payload.ContactEmail),
|
||||
|
||||
NotifyEmail: notifyEmail,
|
||||
}
|
||||
created, err := h.orderStore.Create(order)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if !isManual {
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
}
|
||||
// 自动发货:立即发送卡密通知邮件
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
} else {
|
||||
// 手动发货:告知用户订单已收到,等待发货
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, nil, true)
|
||||
created, err := h.orderStore.Create(order)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
|
||||
}
|
||||
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
|
||||
|
||||
|
||||
qrPayload := fmt.Sprintf("order:%s:%s", created.ID, created.ProductID)
|
||||
|
||||
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
"data": gin.H{
|
||||
|
||||
"orderId": created.ID,
|
||||
|
||||
"qrCodeUrl": qrURL,
|
||||
|
||||
"productId": created.ProductID,
|
||||
|
||||
"productQty": created.Quantity,
|
||||
|
||||
"viewCount": updatedProduct.ViewCount,
|
||||
|
||||
"status": created.Status,
|
||||
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
|
||||
orderID := c.Param("id")
|
||||
|
||||
order, err := h.orderStore.Confirm(orderID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
isManual := order.DeliveryMode == "manual"
|
||||
|
||||
// 手动发货确认后,发送"已发货"通知邮件
|
||||
|
||||
|
||||
if isManual {
|
||||
|
||||
confirmNotifyEmail := order.NotifyEmail
|
||||
|
||||
if confirmNotifyEmail == "" {
|
||||
|
||||
confirmNotifyEmail = order.ContactEmail
|
||||
|
||||
}
|
||||
|
||||
h.sendOrderNotify(confirmNotifyEmail, order.UserName, order.ProductName, order.ID, order.Quantity, order.DeliveredCodes, false)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
"data": gin.H{
|
||||
|
||||
"orderId": order.ID,
|
||||
|
||||
"status": order.Status,
|
||||
|
||||
"deliveryMode": order.DeliveryMode,
|
||||
|
||||
"deliveredCodes": order.DeliveredCodes,
|
||||
|
||||
"isManual": isManual,
|
||||
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ListMyOrders(c *gin.Context) {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
userToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
result, err := h.authClient.VerifyToken(userToken)
|
||||
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
orders, err := h.orderStore.ListByAccount(result.User.Account)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": orders})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func extractCodes(product *models.Product, count int) ([]string, bool) {
|
||||
|
||||
if count <= 0 {
|
||||
|
||||
return nil, false
|
||||
|
||||
}
|
||||
|
||||
if len(product.Codes) < count {
|
||||
|
||||
return nil, false
|
||||
|
||||
}
|
||||
|
||||
delivered := make([]string, count)
|
||||
|
||||
copy(delivered, product.Codes[:count])
|
||||
|
||||
product.Codes = product.Codes[count:]
|
||||
|
||||
return delivered, true
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -59,4 +59,4 @@ func sanitizeForPublic(items []models.Product) []models.Product {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -240,6 +240,32 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
|
||||
return rowToModel(row, nil), true, nil
|
||||
}
|
||||
|
||||
// IncrementViewCount atomically increments view_count by 1 and returns the
|
||||
// updated product (without codes). It is called by the handler when Redis-based
|
||||
// deduplication has already confirmed this is a new view, so no in-memory lock
|
||||
// is needed here.
|
||||
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
|
||||
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
return rowToModel(row, nil), nil
|
||||
}
|
||||
|
||||
// GetViewState returns the current product state without modifying any counter.
|
||||
// Used by the handler when a view is detected as duplicate via Redis.
|
||||
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
return rowToModel(row, nil), nil
|
||||
}
|
||||
|
||||
func (s *ProductStore) Delete(id string) error {
|
||||
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
||||
return err
|
||||
@@ -272,7 +298,7 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
item.Quantity = len(item.Codes)
|
||||
if item.DeliveryMode == "" {
|
||||
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
|
||||
item.DeliveryMode = "auto"
|
||||
}
|
||||
return item
|
||||
|
||||
594
mengyastore-frontend/package-lock.json
generated
594
mengyastore-frontend/package-lock.json
generated
@@ -15,8 +15,10 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^5.2.7",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
@@ -2925,6 +2927,290 @@
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.8.1",
|
||||
"@emnapi/runtime": "^1.8.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"tailwindcss": "4.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3799,6 +4085,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -5034,6 +5334,279 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
@@ -6152,6 +6725,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^5.2.7",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
|
||||
@@ -1,65 +1,105 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="relative flex flex-col min-h-screen">
|
||||
<!-- Random background -->
|
||||
<div class="app-randbg" aria-hidden="true">
|
||||
<img
|
||||
v-if="randBgUrl"
|
||||
class="app-randbg-img"
|
||||
:src="randBgUrl"
|
||||
alt=""
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PWA Splash Screen -->
|
||||
<SplashScreen :visible="showSplash" />
|
||||
|
||||
<header class="top-bar">
|
||||
<div class="brand" @click="onLogoClick">
|
||||
<div class="brand-mark">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" />
|
||||
<!-- Top navigation bar -->
|
||||
<header class="flex items-center justify-between gap-5 px-[5vw] py-4 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] sticky top-0 z-[100] max-md:flex-row max-md:flex-nowrap max-md:px-[3vw] max-md:py-2 max-md:gap-1.5">
|
||||
<!-- Brand -->
|
||||
<div class="flex gap-4 items-center cursor-pointer select-none max-md:gap-2 max-md:flex-shrink-0" @click="onLogoClick">
|
||||
<div class="w-[46px] h-[46px] rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center max-md:w-8 max-md:h-8">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>萌芽小店</h1>
|
||||
<h1 class="text-[28px] tracking-wide max-md:text-base max-md:whitespace-nowrap">萌芽小店</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 items-center max-md:gap-1 max-md:flex-shrink-0 max-md:overflow-hidden">
|
||||
<button class="ghost" @click="goHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button class="ghost wishlist-nav" @click="goWishlist">
|
||||
<span class="wishlist-icon">☆</span>
|
||||
<button class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span class="max-md:text-xs">☆</span>
|
||||
收藏夹
|
||||
<span v-if="wishlistCount > 0" class="wishlist-badge">{{ wishlistCount }}</span>
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none max-md:min-w-[14px] max-md:h-[14px] max-md:text-[9px]"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div class="user-badge" @click="goProfile">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors max-md:px-1.5 max-md:gap-1"
|
||||
@click="goProfile"
|
||||
>
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="user-avatar"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/35 max-md:w-5 max-md:h-5"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div v-else class="user-avatar user-avatar-placeholder">
|
||||
<div
|
||||
v-else
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35 max-md:w-5 max-md:h-5 max-md:text-xs"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="user-name">{{ authState.username || authState.account }}</span>
|
||||
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap max-md:text-xs max-md:max-w-[50px]">
|
||||
{{ authState.username || authState.account }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a>
|
||||
<a
|
||||
v-else
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] transition-transform hover:-translate-y-px max-md:text-xs max-md:px-2 max-md:py-1.5 max-md:whitespace-nowrap"
|
||||
:href="loginUrl"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Admin modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showAdminModal" class="admin-modal-overlay" @click.self="showAdminModal = false">
|
||||
<div class="admin-modal">
|
||||
<button class="admin-modal-close" @click="showAdminModal = false">×</button>
|
||||
<div class="admin-modal-icon">
|
||||
<div
|
||||
v-if="showAdminModal"
|
||||
class="admin-modal fixed inset-0 z-[1000] flex items-center justify-center bg-apptext/35 backdrop-blur-lg"
|
||||
@click.self="showAdminModal = false"
|
||||
>
|
||||
<div class="admin-modal relative w-[360px] max-w-[90vw] px-8 pt-9 pb-7 glass-strong rounded-xl shadow-[0_24px_60px_rgba(33,33,40,0.22)] text-center">
|
||||
<button
|
||||
class="absolute top-3 right-4 bg-transparent border-none text-[22px] text-muted hover:text-apptext cursor-pointer px-2 py-1 rounded leading-none transition-colors"
|
||||
@click="showAdminModal = false"
|
||||
>×</button>
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-[10px] bg-gradient-to-br from-accent to-accent2 text-white mb-4">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<h3>管理员验证</h3>
|
||||
<p class="admin-modal-hint">请输入管理员令牌以访问后台</p>
|
||||
<h3 class="text-[22px] mb-1.5">管理员验证</h3>
|
||||
<p class="text-[15px] text-muted mb-5">请输入管理员令牌以访问后台</p>
|
||||
<form @submit.prevent="submitAdminToken">
|
||||
<input
|
||||
ref="tokenInputRef"
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
class="admin-modal-input"
|
||||
class="w-full px-4 py-3 rounded-lg border border-white/35 bg-white/65 text-center tracking-widest text-[18px] outline-none transition focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)]"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入 Token…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="tokenError" class="admin-modal-error">{{ tokenError }}</p>
|
||||
<button type="submit" class="primary admin-modal-btn" :disabled="!tokenInput.trim()">
|
||||
<p v-if="tokenError" class="text-[15px] text-[#d4566a] mt-2">{{ tokenError }}</p>
|
||||
<button type="submit" class="primary w-full mt-4 py-3 text-[17px]" :disabled="!tokenInput.trim()">
|
||||
进入后台
|
||||
</button>
|
||||
</form>
|
||||
@@ -68,38 +108,49 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="app-body">
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
<!-- Page body -->
|
||||
<div class="flex-1 flex flex-col px-[5vw] py-7 max-md:px-[2vw] max-md:py-3">
|
||||
<main class="flex-1 py-7 min-h-0">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img src="/logo.png" alt="萌芽小店" class="footer-logo" />
|
||||
<span class="footer-title">萌芽小店</span>
|
||||
<!-- Footer -->
|
||||
<footer class="mt-3 px-[26px] py-6 glass rounded-[var(--radius-default)] shadow-[0_-4px_30px_rgba(33,33,40,0.06)] max-md:px-3.5 max-md:py-5">
|
||||
<div class="flex flex-col items-center gap-3.5">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/logo.png" alt="萌芽小店" class="w-7 h-7 rounded-[5px] object-contain border border-white/50 shadow-[0_4px_12px_rgba(33,33,40,0.1)]" />
|
||||
<span class="text-[19px] font-semibold text-apptext tracking-[0.5px]" style="font-family: var(--font-serif);">萌芽小店</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-10 h-0.5 rounded-full bg-gradient-to-r from-accent to-accent2 opacity-50"></div>
|
||||
|
||||
<!-- Links / stats -->
|
||||
<div class="flex items-center gap-6 flex-wrap justify-center max-md:flex-row max-md:flex-wrap max-md:gap-2.5 max-md:justify-center">
|
||||
<a
|
||||
class="inline-flex items-center gap-1.5 text-[#3d5a88] no-underline text-[15px] font-semibold px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)] hover:bg-[rgba(145,168,208,0.22)] transition-colors"
|
||||
href="mailto:mail@smyhub.com"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
mail@smyhub.com
|
||||
</a>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#6b4d85] px-3 py-1.5 rounded bg-[rgba(180,154,203,0.12)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
累计下单 {{ stats.totalOrders }} 次
|
||||
</span>
|
||||
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#3d5a88] px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
累计访问 {{ stats.totalVisits }} 次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-[14px] text-[#45424a]/95 tracking-[0.3px]">© 2025 – 2026 萌芽小店 · All rights reserved</p>
|
||||
</div>
|
||||
<div class="footer-divider"></div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-mail" href="mailto:mail@smyhub.com">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
mail@smyhub.com
|
||||
</a>
|
||||
<span v-if="statsLoaded" class="footer-stat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
累计下单 {{ stats.totalOrders }} 次
|
||||
</span>
|
||||
<span v-if="statsLoaded" class="footer-stat footer-stat-visit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
累计访问 {{ stats.totalVisits }} 次
|
||||
</span>
|
||||
</div>
|
||||
<p class="footer-copy">© 2025 – 2026 萌芽小店 · All rights reserved</p>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Floating chat widget (visible to logged-in users only) -->
|
||||
<!-- Floating chat widget -->
|
||||
<ChatWidget
|
||||
v-if="loggedIn && authState.token"
|
||||
:user-token="authState.token"
|
||||
@@ -109,9 +160,15 @@
|
||||
|
||||
<!-- PWA update toast -->
|
||||
<Transition name="pwa-toast">
|
||||
<div v-if="needRefresh" class="pwa-update-toast">
|
||||
<div
|
||||
v-if="needRefresh"
|
||||
class="fixed bottom-20 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-3 px-[18px] py-2.5 rounded-3xl bg-[#1a1a1a] text-white text-[13px] shadow-[0_4px_24px_rgba(0,0,0,0.25)] whitespace-nowrap"
|
||||
>
|
||||
<span>发现新版本</span>
|
||||
<button @click="updateServiceWorker(true)">立即更新</button>
|
||||
<button
|
||||
class="bg-[#4ade80] text-[#111] border-none rounded-xl px-3.5 py-1 text-xs font-semibold cursor-pointer"
|
||||
@click="updateServiceWorker(true)"
|
||||
>立即更新</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -127,6 +184,8 @@ import ChatWidget from './modules/chat/ChatWidget.vue'
|
||||
import SplashScreen from './modules/shared/SplashScreen.vue'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
|
||||
const RAND_BG_JSON_URL = 'https://randbg.api.smyhub.com/api/random?format=json'
|
||||
|
||||
const { needRefresh, updateServiceWorker } = useRegisterSW({
|
||||
onRegisteredSW(swUrl, r) {
|
||||
if (r) setInterval(() => r.update(), 1000 * 60 * 60)
|
||||
@@ -134,6 +193,7 @@ const { needRefresh, updateServiceWorker } = useRegisterSW({
|
||||
})
|
||||
|
||||
const showSplash = ref(true)
|
||||
const randBgUrl = ref('')
|
||||
|
||||
const router = useRouter()
|
||||
const stats = reactive({ totalOrders: 0, totalVisits: 0 })
|
||||
@@ -200,6 +260,13 @@ const SPLASH_MIN_MS = 1400
|
||||
onMounted(async () => {
|
||||
const splashStart = Date.now()
|
||||
|
||||
fetch(RAND_BG_JSON_URL)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data && typeof data.url === 'string') randBgUrl.value = data.url
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await loadWishlist()
|
||||
try {
|
||||
recordSiteVisit().then((result) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,42 @@
|
||||
<template>
|
||||
<section class="admin-layout">
|
||||
<section class="flex glass border border-white/35 rounded-[var(--radius-default)] shadow-[var(--shadow-card)] backdrop-blur-[16px] overflow-hidden min-h-[calc(100vh-120px)] max-md:flex-col max-md:min-h-auto">
|
||||
<!-- Sidebar -->
|
||||
<nav class="admin-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<span>管理后台</span>
|
||||
<nav class="w-[180px] flex-shrink-0 bg-white/65 border-r border-white/35 flex flex-col max-md:w-full max-md:border-r-0 max-md:border-b max-md:border-white/35">
|
||||
<div class="px-[18px] py-5 pb-3.5 text-base font-bold text-apptext border-b border-white/35 tracking-[0.3px] max-md:px-4 max-md:py-3 max-md:text-[15px]">
|
||||
管理后台
|
||||
</div>
|
||||
<div class="sidebar-nav">
|
||||
<div class="flex flex-col py-2.5 gap-0.5 max-md:flex-row max-md:py-0 max-md:gap-0 max-md:overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<button
|
||||
v-for="item in NAV_ITEMS"
|
||||
:key="item.id"
|
||||
:class="['nav-item', activeSection === item.id ? 'nav-item--active' : '']"
|
||||
class="flex items-center gap-2.5 px-[18px] py-2.5 text-[15px] font-medium text-muted bg-transparent border-none cursor-pointer text-left rounded-none transition whitespace-nowrap hover:bg-[rgba(180,154,203,0.1)] hover:text-apptext max-md:flex-col max-md:gap-0.5 max-md:px-3.5 max-md:py-2 max-md:text-xs max-md:flex-shrink-0 max-md:items-center"
|
||||
:class="activeSection === item.id ? '!bg-[rgba(180,154,203,0.18)] !text-apptext font-bold border-r-[3px] border-accent max-md:border-r-0 max-md:border-b-[3px]' : ''"
|
||||
@click="activeSection = item.id"
|
||||
type="button"
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span class="flex items-center opacity-70 flex-shrink-0" :class="activeSection === item.id ? '!opacity-100' : ''" v-html="item.icon"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="admin-content">
|
||||
<div class="flex-1 min-w-0 p-[22px_24px] overflow-auto max-md:p-3.5">
|
||||
<!-- Top bar -->
|
||||
<div class="admin-topbar">
|
||||
<div class="topbar-title">{{ currentNavLabel }}</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="ghost small-btn" @click="refresh">
|
||||
<div class="flex items-center justify-between mb-[18px] max-md:mb-3">
|
||||
<div class="text-xl font-bold text-apptext max-md:text-base">{{ currentNavLabel }}</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<button class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5" @click="refresh">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
<button v-if="activeSection === 'products'" class="primary small-btn" @click="openCreate">
|
||||
<button v-if="activeSection === 'products'" class="primary inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5" @click="openCreate">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
添加商品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token row (always visible) -->
|
||||
<AdminTokenRow
|
||||
:show="!token || !!message"
|
||||
v-model:token="token"
|
||||
@@ -44,28 +44,19 @@
|
||||
:inline-message="token && message ? message : ''"
|
||||
/>
|
||||
|
||||
<!-- Section: Products -->
|
||||
<div v-if="activeSection === 'products'">
|
||||
<p class="section-tip tag">共 {{ products.length }} 件商品</p>
|
||||
<AdminProductTable
|
||||
:products="products"
|
||||
@edit="openEdit"
|
||||
@toggle="toggle"
|
||||
@remove="remove"
|
||||
/>
|
||||
<p class="text-[13px] text-muted mb-2.5">共 {{ products.length }} 件商品</p>
|
||||
<AdminProductTable :products="products" @edit="openEdit" @toggle="toggle" @remove="remove" />
|
||||
</div>
|
||||
|
||||
<!-- Section: Orders -->
|
||||
<div v-else-if="activeSection === 'orders'">
|
||||
<AdminOrderTable :orders="orders" @remove="removeOrder" />
|
||||
</div>
|
||||
|
||||
<!-- Section: Chat -->
|
||||
<div v-else-if="activeSection === 'chat'">
|
||||
<AdminChatPanel :admin-token="token" />
|
||||
</div>
|
||||
|
||||
<!-- Section: Settings -->
|
||||
<div v-else-if="activeSection === 'settings'">
|
||||
<AdminMaintenanceRow
|
||||
v-model:enabled="maintenanceEnabled"
|
||||
@@ -73,11 +64,11 @@
|
||||
:message="maintenanceMsg"
|
||||
@save="saveMaintenance"
|
||||
/>
|
||||
<AdminSMTPRow
|
||||
:config="smtpConfig"
|
||||
:message="smtpMsg"
|
||||
@save="saveSMTPConfig"
|
||||
/>
|
||||
<AdminSMTPRow :config="smtpConfig" :message="smtpMsg" @save="saveSMTPConfig" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'status'">
|
||||
<AdminSystemStatusPanel ref="statusPanelRef" :admin-token="token" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -91,20 +82,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
fetchAdminProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
toggleProduct,
|
||||
deleteProduct,
|
||||
fetchAdminOrders,
|
||||
deleteAdminOrder,
|
||||
fetchSiteMaintenance,
|
||||
setSiteMaintenance,
|
||||
fetchSMTPConfig,
|
||||
setSMTPConfig
|
||||
fetchAdminProducts, createProduct, updateProduct, toggleProduct, deleteProduct,
|
||||
fetchAdminOrders, deleteAdminOrder,
|
||||
fetchSiteMaintenance, setSiteMaintenance,
|
||||
fetchSMTPConfig, setSMTPConfig
|
||||
} from '../shared/api'
|
||||
import AdminTokenRow from './components/AdminTokenRow.vue'
|
||||
import AdminMaintenanceRow from './components/AdminMaintenanceRow.vue'
|
||||
@@ -113,382 +97,114 @@ import AdminProductTable from './components/AdminProductTable.vue'
|
||||
import AdminProductModal from './components/AdminProductModal.vue'
|
||||
import AdminOrderTable from './components/AdminOrderTable.vue'
|
||||
import AdminChatPanel from './components/AdminChatPanel.vue'
|
||||
import AdminSystemStatusPanel from './components/AdminSystemStatusPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
id: 'products',
|
||||
label: '商品管理',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>'
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
label: '订单记录',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>'
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: '用户消息',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '站点设置',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
|
||||
}
|
||||
{ id: 'products', label: '商品管理', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>' },
|
||||
{ id: 'orders', label: '订单记录', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>' },
|
||||
{ id: 'chat', label: '用户消息', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' },
|
||||
{ id: 'settings', label: '站点设置', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>' },
|
||||
{ id: 'status', label: '系统状态', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><polyline points="6 9 10 13 14 9 18 13"/></svg>' }
|
||||
]
|
||||
|
||||
const activeSection = ref('products')
|
||||
const currentNavLabel = computed(() => NAV_ITEMS.find(i => i.id === activeSection.value)?.label || '')
|
||||
|
||||
const statusPanelRef = ref(null)
|
||||
const token = ref(route.query.token || '')
|
||||
const products = ref([])
|
||||
const orders = ref([])
|
||||
const message = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
const maintenanceEnabled = ref(false)
|
||||
const maintenanceReason = ref('')
|
||||
const maintenanceMsg = ref('')
|
||||
|
||||
const smtpConfig = ref({})
|
||||
const smtpMsg = ref('')
|
||||
|
||||
const syncQuery = () => {
|
||||
if (token.value) {
|
||||
router.replace({ query: { token: token.value } })
|
||||
}
|
||||
}
|
||||
const syncQuery = () => { if (token.value) router.replace({ query: { token: token.value } }) }
|
||||
|
||||
const refresh = async () => {
|
||||
if (!token.value) {
|
||||
message.value = '请先输入 token'
|
||||
if (activeSection.value === 'status') {
|
||||
await nextTick()
|
||||
statusPanelRef.value?.load()
|
||||
return
|
||||
}
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
try {
|
||||
const [prods, ords] = await Promise.all([
|
||||
fetchAdminProducts(token.value),
|
||||
fetchAdminOrders(token.value)
|
||||
])
|
||||
const [prods, ords] = await Promise.all([fetchAdminProducts(token.value), fetchAdminOrders(token.value)])
|
||||
products.value = prods
|
||||
orders.value = ords
|
||||
message.value = '数据已更新'
|
||||
} catch {
|
||||
message.value = '获取失败,请检查 token'
|
||||
}
|
||||
} catch { message.value = '获取失败,请检查 token' }
|
||||
}
|
||||
|
||||
|
||||
const loadMaintenance = async () => {
|
||||
try {
|
||||
const { maintenance, reason } = await fetchSiteMaintenance()
|
||||
maintenanceEnabled.value = maintenance
|
||||
maintenanceReason.value = reason || ''
|
||||
} catch {
|
||||
maintenanceMsg.value = '加载维护状态失败'
|
||||
}
|
||||
} catch { maintenanceMsg.value = '加载维护状态失败' }
|
||||
}
|
||||
|
||||
const loadSMTPConfig = async () => {
|
||||
if (!token.value) return
|
||||
try {
|
||||
smtpConfig.value = await fetchSMTPConfig(token.value)
|
||||
} catch {
|
||||
smtpMsg.value = '加载 SMTP 配置失败'
|
||||
}
|
||||
try { smtpConfig.value = await fetchSMTPConfig(token.value) } catch { smtpMsg.value = '加载 SMTP 配置失败' }
|
||||
}
|
||||
|
||||
const saveSMTPConfig = async (cfg) => {
|
||||
if (!token.value) {
|
||||
smtpMsg.value = '请先输入 token'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await setSMTPConfig(token.value, cfg)
|
||||
smtpMsg.value = '配置已保存'
|
||||
await loadSMTPConfig()
|
||||
} catch {
|
||||
smtpMsg.value = '保存失败,请检查 token'
|
||||
}
|
||||
if (!token.value) { smtpMsg.value = '请先输入 token'; return }
|
||||
try { await setSMTPConfig(token.value, cfg); smtpMsg.value = '配置已保存'; await loadSMTPConfig() }
|
||||
catch { smtpMsg.value = '保存失败,请检查 token' }
|
||||
}
|
||||
|
||||
const saveMaintenance = async () => {
|
||||
if (!token.value) {
|
||||
maintenanceMsg.value = '请先输入 token'
|
||||
return
|
||||
}
|
||||
if (!token.value) { maintenanceMsg.value = '请先输入 token'; return }
|
||||
try {
|
||||
await setSiteMaintenance(token.value, maintenanceEnabled.value, maintenanceReason.value)
|
||||
maintenanceMsg.value = maintenanceEnabled.value ? '维护模式已开启' : '维护模式已关闭'
|
||||
} catch {
|
||||
maintenanceMsg.value = '保存失败,请检查 token'
|
||||
}
|
||||
} catch { maintenanceMsg.value = '保存失败,请检查 token' }
|
||||
}
|
||||
|
||||
const handleFormSubmit = async (payload) => {
|
||||
if (!token.value) {
|
||||
message.value = '请先输入 token'
|
||||
return
|
||||
}
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
try {
|
||||
if (payload.id) {
|
||||
await updateProduct(token.value, payload.id, payload)
|
||||
message.value = '已更新商品'
|
||||
} else {
|
||||
await createProduct(token.value, payload)
|
||||
message.value = '已新增商品'
|
||||
}
|
||||
if (payload.id) { await updateProduct(token.value, payload.id, payload); message.value = '已更新商品' }
|
||||
else { await createProduct(token.value, payload); message.value = '已新增商品' }
|
||||
closeEditor()
|
||||
await refresh()
|
||||
} catch {
|
||||
message.value = '操作失败,请检查输入'
|
||||
}
|
||||
} catch { message.value = '操作失败,请检查输入' }
|
||||
}
|
||||
|
||||
const toggle = async (item) => {
|
||||
if (!token.value) {
|
||||
message.value = '请先输入 token'
|
||||
return
|
||||
}
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
await toggleProduct(token.value, item.id, !item.active)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const remove = async (item) => {
|
||||
if (!token.value) {
|
||||
message.value = '请先输入 token'
|
||||
return
|
||||
}
|
||||
if (!token.value) { message.value = '请先输入 token'; return }
|
||||
await deleteProduct(token.value, item.id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const removeOrder = async (orderId) => {
|
||||
if (!token.value) return
|
||||
try {
|
||||
await deleteAdminOrder(token.value, orderId)
|
||||
orders.value = orders.value.filter((o) => o.id !== orderId)
|
||||
} catch {
|
||||
message.value = '删除订单失败'
|
||||
}
|
||||
try { await deleteAdminOrder(token.value, orderId); orders.value = orders.value.filter((o) => o.id !== orderId) }
|
||||
catch { message.value = '删除订单失败' }
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
selectedItem.value = null
|
||||
editorOpen.value = true
|
||||
}
|
||||
const openCreate = () => { selectedItem.value = null; editorOpen.value = true }
|
||||
const openEdit = (item) => { selectedItem.value = item; editorOpen.value = true }
|
||||
const closeEditor = () => { editorOpen.value = false; selectedItem.value = null }
|
||||
|
||||
const openEdit = (item) => {
|
||||
selectedItem.value = item
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
editorOpen.value = false
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
watch(token, (val) => {
|
||||
syncQuery()
|
||||
if (val) loadSMTPConfig()
|
||||
})
|
||||
watch(token, (val) => { syncQuery(); if (val) loadSMTPConfig() })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMaintenance()
|
||||
if (token.value) {
|
||||
await Promise.all([refresh(), loadSMTPConfig()])
|
||||
}
|
||||
if (token.value) await Promise.all([refresh(), loadSMTPConfig()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ── */
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 120px);
|
||||
gap: 0;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.admin-sidebar {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 20px 18px 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--line);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(180, 154, 203, 0.1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item--active {
|
||||
background: rgba(180, 154, 203, 0.18);
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
border-right: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item--active .nav-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Content area ── */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 22px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
padding: 7px 13px;
|
||||
}
|
||||
|
||||
.section-tip {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── Mobile: sidebar becomes top tabs ── */
|
||||
@media (max-width: 900px) {
|
||||
.admin-layout {
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 12px 16px 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
border-right: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-item--active {
|
||||
border-right: none;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
background: rgba(180, 154, 203, 0.12);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,103 @@
|
||||
<template>
|
||||
<div class="chat-panel-section">
|
||||
<div class="chat-panel-header">
|
||||
<h3>用户消息</h3>
|
||||
<button class="refresh-btn" @click="load" :disabled="loading">刷新</button>
|
||||
<div class="mt-8 max-md:mt-4">
|
||||
<div class="flex items-center gap-3 mb-3.5">
|
||||
<h3 class="text-lg font-bold text-apptext">用户消息</h3>
|
||||
<button
|
||||
class="px-3.5 py-1.5 rounded border border-white/35 bg-white/70 text-[13px] cursor-pointer text-apptext disabled:opacity-50"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="load" :disabled="loading"
|
||||
>刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="chat-empty">加载中…</div>
|
||||
<div v-else-if="Object.keys(conversations).length === 0" class="chat-empty">暂无用户消息</div>
|
||||
<div v-if="loading" class="py-6 text-center text-muted text-sm">加载中…</div>
|
||||
<div v-else-if="Object.keys(conversations).length === 0" class="py-6 text-center text-muted text-sm">暂无用户消息</div>
|
||||
|
||||
<div v-else class="chat-layout">
|
||||
<!-- Left: conversation list -->
|
||||
<div class="conv-list">
|
||||
<div class="conv-list-title">对话列表</div>
|
||||
<div v-else class="grid h-[520px] rounded-xl border border-white/35 overflow-hidden bg-white/40 shadow-[0_8px_24px_rgba(33,33,40,0.08)] max-md:grid-cols-1 max-md:grid-rows-[auto,1fr]" style="grid-template-columns: 200px 1fr;">
|
||||
<!-- Conversation list -->
|
||||
<div class="border-r border-white/35 overflow-y-auto bg-white/65 flex flex-col max-md:border-r-0 max-md:border-b max-md:overflow-x-auto max-md:overflow-y-hidden max-md:flex-row max-md:max-h-[72px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="px-3.5 py-3 pb-2 text-[11px] font-bold tracking-[0.8px] uppercase text-muted border-b border-white/35 flex-shrink-0 max-md:hidden">对话列表</div>
|
||||
<div
|
||||
v-for="(msgs, account) in conversations"
|
||||
:key="account"
|
||||
:class="['conv-item', selectedAccount === account ? 'conv-item--active' : '']"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer border-b border-white/50 last:border-b-0 transition-colors hover:bg-[rgba(180,154,203,0.08)] max-md:flex-col max-md:flex-shrink-0 max-md:items-center max-md:min-w-[70px] max-md:max-w-20 max-md:px-1.5 max-md:py-2 max-md:border-b-0 max-md:border-r max-md:border-white/35 max-md:text-center"
|
||||
:class="selectedAccount === account ? '!bg-[rgba(180,154,203,0.14)] border-l-[3px] border-accent pl-[9px] max-md:border-l-0 max-md:border-b-[2px] max-md:pl-1.5' : ''"
|
||||
@click="selectConv(account, msgs)"
|
||||
>
|
||||
<div class="conv-avatar">{{ (getDisplayName(msgs) || account).slice(0, 1).toUpperCase() }}</div>
|
||||
<div class="conv-info">
|
||||
<div class="conv-name">{{ getDisplayName(msgs) || account }}</div>
|
||||
<div class="conv-preview">{{ latestMsg(msgs) }}</div>
|
||||
<div class="w-[34px] h-[34px] rounded-full bg-[#e8e8e8] text-[#444] text-sm font-bold flex items-center justify-center flex-shrink-0 max-md:w-7 max-md:h-7 max-md:text-xs">
|
||||
{{ (getDisplayName(msgs) || account).slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 max-md:flex max-md:flex-col max-md:items-center">
|
||||
<div class="text-[13px] font-semibold text-apptext whitespace-nowrap overflow-hidden text-ellipsis max-md:text-[11px] max-md:max-w-16">{{ getDisplayName(msgs) || account }}</div>
|
||||
<div class="text-[11px] text-muted mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis max-md:hidden">{{ latestMsg(msgs) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: message thread -->
|
||||
<div class="conv-thread" v-if="selectedAccount">
|
||||
<div class="thread-top">
|
||||
<div class="thread-user">
|
||||
<div class="thread-avatar">{{ (displayName || selectedAccount).slice(0, 1).toUpperCase() }}</div>
|
||||
<div class="thread-user-info">
|
||||
<span class="thread-name">{{ displayName || selectedAccount }}</span>
|
||||
<span class="thread-account">{{ selectedAccount }}</span>
|
||||
<!-- Thread -->
|
||||
<div v-if="selectedAccount" class="flex flex-col overflow-hidden bg-white/30">
|
||||
<!-- Thread top -->
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/35 bg-white/75 flex-shrink-0">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-[#e8e8e8] text-[#444] text-[13px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{{ (displayName || selectedAccount).slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-0">
|
||||
<span class="text-sm font-bold text-apptext leading-[1.2]">{{ displayName || selectedAccount }}</span>
|
||||
<span class="text-[11px] text-muted">{{ selectedAccount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="clear-btn" @click="clearConv" :disabled="clearing">清除记录</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded border border-[rgba(210,70,70,0.3)] bg-[rgba(210,70,70,0.06)] text-[#c04040] text-xs cursor-pointer whitespace-nowrap hover:bg-[rgba(210,70,70,0.14)] transition disabled:opacity-50"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="clearConv" :disabled="clearing"
|
||||
>清除记录</button>
|
||||
</div>
|
||||
|
||||
<div class="thread-messages" ref="threadEl">
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref="threadEl">
|
||||
<div
|
||||
v-for="msg in currentMessages"
|
||||
:key="msg.id"
|
||||
:class="['msg-row', msg.fromAdmin ? 'msg-row--admin' : 'msg-row--user']"
|
||||
class="flex"
|
||||
:class="msg.fromAdmin ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div class="msg-bubble">
|
||||
<div class="msg-meta">
|
||||
<span class="msg-from">{{ msg.fromAdmin ? '客服' : (msg.accountName || msg.accountId) }}</span>
|
||||
<span class="msg-time">{{ formatTime(msg.sentAt) }}</span>
|
||||
<div class="flex flex-col gap-0.5 max-w-[72%]" :class="msg.fromAdmin ? 'items-end' : ''">
|
||||
<div class="flex gap-1.5 items-baseline px-0.5">
|
||||
<span class="text-[11px] font-semibold text-muted">{{ msg.fromAdmin ? '客服' : (msg.accountName || msg.accountId) }}</span>
|
||||
<span class="text-[10px] text-muted opacity-75">{{ formatTime(msg.sentAt) }}</span>
|
||||
</div>
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
<div
|
||||
class="px-3.5 py-2.5 rounded-xl text-sm leading-[1.55] whitespace-pre-wrap break-words"
|
||||
:class="msg.fromAdmin
|
||||
? 'bg-[#2c2b2d] text-white rounded-br-[4px]'
|
||||
: 'bg-white/85 text-apptext border border-white/35 rounded-bl-[4px]'"
|
||||
>{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="reply-row" @submit.prevent="sendReply">
|
||||
<!-- Reply input -->
|
||||
<form class="flex gap-2 px-3.5 py-2.5 border-t border-white/35 bg-white/70 flex-shrink-0" @submit.prevent="sendReply">
|
||||
<input
|
||||
v-model="replyText"
|
||||
class="reply-input"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-white/35 text-sm bg-white/90 text-apptext outline-none focus:border-accent transition"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入回复内容…"
|
||||
maxlength="500"
|
||||
:disabled="replying"
|
||||
/>
|
||||
<button class="reply-send-btn" type="submit" :disabled="replying || !replyText.trim()">发送</button>
|
||||
<button
|
||||
class="px-[18px] py-2 rounded-lg bg-[#2c2b2d] text-white border-none cursor-pointer text-sm font-semibold disabled:opacity-45 disabled:cursor-default hover:not-disabled:opacity-80 transition"
|
||||
style="font-family: var(--font-serif);"
|
||||
type="submit" :disabled="replying || !replyText.trim()"
|
||||
>发送</button>
|
||||
</form>
|
||||
<p v-if="replyError" class="reply-error">{{ replyError }}</p>
|
||||
<p v-if="replyError" class="text-xs text-[#d64848] px-3.5 pb-2 text-right">{{ replyError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="conv-thread conv-thread--empty" v-else>
|
||||
<div class="empty-hint">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div v-else class="flex items-center justify-center text-muted text-sm bg-white/30">
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-30"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<p>选择左侧对话</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,16 +107,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
fetchAdminAllConversations,
|
||||
fetchAdminConversation,
|
||||
adminSendChatReply,
|
||||
adminClearConversation
|
||||
} from '../../shared/api.js'
|
||||
import { fetchAdminAllConversations, fetchAdminConversation, adminSendChatReply, adminClearConversation } from '../../shared/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
adminToken: { type: String, default: '' }
|
||||
})
|
||||
const props = defineProps({ adminToken: { type: String, default: '' } })
|
||||
|
||||
const conversations = ref({})
|
||||
const loading = ref(false)
|
||||
@@ -106,8 +126,7 @@ let pollTimer = null
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const getDisplayName = (msgs) => {
|
||||
@@ -125,9 +144,7 @@ const latestMsg = (msgs) => {
|
||||
|
||||
const scrollThreadBottom = async () => {
|
||||
await nextTick()
|
||||
if (threadEl.value) {
|
||||
threadEl.value.scrollTop = threadEl.value.scrollHeight
|
||||
}
|
||||
if (threadEl.value) threadEl.value.scrollTop = threadEl.value.scrollHeight
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
@@ -135,14 +152,11 @@ const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
conversations.value = await fetchAdminAllConversations(props.adminToken)
|
||||
// 若当前已选中某个会话,则刷新其消息列表
|
||||
if (selectedAccount.value) {
|
||||
currentMessages.value = conversations.value[selectedAccount.value] || []
|
||||
await scrollThreadBottom()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const selectConv = async (account, msgs) => {
|
||||
@@ -169,9 +183,7 @@ const sendReply = async () => {
|
||||
}
|
||||
} catch (err) {
|
||||
replyError.value = err?.response?.data?.error || '发送失败'
|
||||
} finally {
|
||||
replying.value = false
|
||||
}
|
||||
} finally { replying.value = false }
|
||||
}
|
||||
|
||||
const clearConv = async () => {
|
||||
@@ -183,423 +195,10 @@ const clearConv = async () => {
|
||||
delete conversations.value[selectedAccount.value]
|
||||
selectedAccount.value = ''
|
||||
currentMessages.value = []
|
||||
} catch {
|
||||
alert('清除失败,请重试')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
} catch { alert('清除失败,请重试') }
|
||||
finally { clearing.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
pollTimer = setInterval(load, 8000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
onMounted(() => { load(); pollTimer = setInterval(load, 8000) })
|
||||
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-panel-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.chat-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-panel-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
height: 520px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 8px 24px rgba(33, 33, 40, 0.08);
|
||||
}
|
||||
|
||||
/* Conversation list */
|
||||
.conv-list {
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conv-list-title {
|
||||
padding: 12px 14px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.5);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.conv-item:hover {
|
||||
background: rgba(180, 154, 203, 0.08);
|
||||
}
|
||||
|
||||
.conv-item--active {
|
||||
background: rgba(180, 154, 203, 0.14);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.conv-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: #e8e8e8;
|
||||
color: #444;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Message thread */
|
||||
.conv-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.conv-thread--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.thread-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thread-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thread-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e8e8e8;
|
||||
color: #444;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thread-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.thread-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.thread-account {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(210, 70, 70, 0.3);
|
||||
background: rgba(210, 70, 70, 0.06);
|
||||
color: #c04040;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clear-btn:hover:not(:disabled) {
|
||||
background: rgba(210, 70, 70, 0.14);
|
||||
}
|
||||
|
||||
.thread-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.msg-row--user {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.msg-row--admin {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-width: 72%;
|
||||
}
|
||||
|
||||
.msg-row--admin .msg-bubble {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.msg-from {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
padding: 9px 13px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg-row--user .msg-content {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-row--admin .msg-content {
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.reply-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.reply-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reply-input:focus {
|
||||
border-color: var(--accent, #7c6af0);
|
||||
}
|
||||
|
||||
.reply-send-btn {
|
||||
padding: 8px 18px;
|
||||
border-radius: 7px;
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.reply-send-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reply-send-btn:not(:disabled):hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.reply-error {
|
||||
font-size: 12px;
|
||||
color: #d64848;
|
||||
padding: 0 14px 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-panel-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-direction: row;
|
||||
max-height: 72px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.conv-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conv-list-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 70px;
|
||||
max-width: 80px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conv-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: 11px;
|
||||
max-width: 64px;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conv-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
<template>
|
||||
<div class="maintenance-row">
|
||||
<div class="maintenance-left">
|
||||
<span class="maintenance-label">站点维护模式</span>
|
||||
<div class="flex items-center gap-4 flex-wrap mb-4 px-[18px] py-3.5 bg-white/50 border border-white/35 rounded-lg max-md:flex-col max-md:items-stretch max-md:px-3 max-md:py-2.5 max-md:gap-2.5">
|
||||
<!-- Left: label + toggle -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="text-[15px] font-semibold text-apptext whitespace-nowrap">站点维护模式</span>
|
||||
<button
|
||||
:class="['maintenance-toggle', enabled ? 'toggle-on' : 'toggle-off']"
|
||||
class="px-4 py-1.5 rounded-full text-sm font-semibold border-none cursor-pointer transition"
|
||||
:class="enabled
|
||||
? 'bg-[rgba(201,90,106,0.15)] text-[#c95a6a] hover:bg-[rgba(201,90,106,0.25)]'
|
||||
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68] hover:bg-[rgba(100,185,140,0.25)]'"
|
||||
style="font-family: var(--font-serif);"
|
||||
type="button"
|
||||
@click="$emit('update:enabled', !enabled)"
|
||||
>
|
||||
{{ enabled ? '维护中' : '正常运行' }}
|
||||
</button>
|
||||
>{{ enabled ? '维护中' : '正常运行' }}</button>
|
||||
<span
|
||||
v-if="message"
|
||||
class="msg-tag"
|
||||
:class="{ error: message.includes('失败') }"
|
||||
class="text-[15px] px-2.5 py-1 rounded whitespace-nowrap"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
|
||||
>{{ message }}</span>
|
||||
</div>
|
||||
<div class="maintenance-right">
|
||||
<!-- Right: reason input + save -->
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0 max-md:flex-nowrap">
|
||||
<input
|
||||
:value="reason"
|
||||
@input="$emit('update:reason', $event.target.value)"
|
||||
class="maintenance-reason-input"
|
||||
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-white/35 bg-white/70 text-[15px] outline-none focus:border-accent transition max-md:text-sm max-md:py-1.5"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="维护原因(选填)"
|
||||
/>
|
||||
<button class="ghost" type="button" @click="$emit('save')">保存</button>
|
||||
@@ -33,123 +38,5 @@ defineProps({
|
||||
reason: { type: String, default: '' },
|
||||
message: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['update:enabled', 'update:reason', 'save'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.maintenance-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.maintenance-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maintenance-toggle {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
}
|
||||
|
||||
.toggle-on {
|
||||
background: rgba(201, 90, 106, 0.15);
|
||||
color: #c95a6a;
|
||||
}
|
||||
|
||||
.toggle-on:hover {
|
||||
background: rgba(201, 90, 106, 0.25);
|
||||
}
|
||||
|
||||
.toggle-off {
|
||||
background: rgba(100, 185, 140, 0.15);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.toggle-off:hover {
|
||||
background: rgba(100, 185, 140, 0.25);
|
||||
}
|
||||
|
||||
.maintenance-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maintenance-reason-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.maintenance-reason-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.msg-tag {
|
||||
font-size: 15px;
|
||||
color: var(--accent-2);
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: rgba(145, 168, 208, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-tag.error {
|
||||
color: #c95a6a;
|
||||
background: rgba(201, 90, 106, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.maintenance-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.maintenance-right {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.maintenance-reason-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,94 @@
|
||||
<template>
|
||||
<div class="orders-section">
|
||||
<div class="orders-header">
|
||||
<h3>下单记录</h3>
|
||||
<div class="mt-7 max-md:mt-3">
|
||||
<div class="flex items-baseline gap-3 mb-3">
|
||||
<h3 class="text-lg font-bold text-apptext">下单记录</h3>
|
||||
<p class="tag">共 {{ orders.length }} 条订单(含未登录用户)</p>
|
||||
</div>
|
||||
|
||||
<div v-if="orders.length === 0" class="empty-tip tag">暂无订单记录</div>
|
||||
<div v-if="orders.length === 0" class="py-5 text-center text-muted text-sm">暂无订单记录</div>
|
||||
|
||||
<div v-else class="table-wrap">
|
||||
<!-- Pagination toolbar (top) -->
|
||||
<div class="pagination-bar" v-if="totalPages > 1">
|
||||
<button class="page-btn" :disabled="currentPage === 1" @click="currentPage--">‹</button>
|
||||
<span class="page-info">第 {{ currentPage }} / {{ totalPages }} 页(共 {{ orders.length }} 条)</span>
|
||||
<button class="page-btn" :disabled="currentPage === totalPages" @click="currentPage++">›</button>
|
||||
<div v-else class="rounded-lg overflow-hidden border border-white/35">
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center gap-3 px-3.5 py-2.5 border-b border-white/35 bg-white/70 max-md:gap-2 max-md:px-2.5">
|
||||
<button
|
||||
class="px-3 py-1 rounded border border-white/35 bg-white/80 text-base cursor-pointer text-apptext disabled:opacity-35 disabled:cursor-default hover:not-disabled:bg-[rgba(124,106,240,0.1)] transition leading-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
:disabled="currentPage === 1" @click="currentPage--"
|
||||
>‹</button>
|
||||
<span class="text-[13px] text-muted max-md:text-xs">第 {{ currentPage }} / {{ totalPages }} 页(共 {{ orders.length }} 条)</span>
|
||||
<button
|
||||
class="px-3 py-1 rounded border border-white/35 bg-white/80 text-base cursor-pointer text-apptext disabled:opacity-35 disabled:cursor-default hover:not-disabled:bg-[rgba(124,106,240,0.1)] transition leading-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
:disabled="currentPage === totalPages" @click="currentPage++"
|
||||
>›</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th>用户</th>
|
||||
<th class="col-num">数量</th>
|
||||
<th>发货</th>
|
||||
<th>状态</th>
|
||||
<th>下单时间</th>
|
||||
<th class="col-action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="order in pagedOrders" :key="order.id">
|
||||
<tr :class="{ 'row-pending': order.status === 'pending' }">
|
||||
<td>
|
||||
<div class="order-product">
|
||||
<span class="order-product-name">{{ order.productName }}</span>
|
||||
<span class="order-id">{{ order.id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="order.userAccount" class="user-info">
|
||||
<span class="user-name">{{ order.userName || order.userAccount }}</span>
|
||||
<span class="user-account">{{ order.userAccount }}</span>
|
||||
</div>
|
||||
<span v-else class="anon-badge">匿名</span>
|
||||
</td>
|
||||
<td class="col-num">{{ order.quantity }}</td>
|
||||
<td>
|
||||
<span :class="['delivery-badge', order.deliveryMode === 'manual' ? 'delivery-manual' : 'delivery-auto']">
|
||||
{{ order.deliveryMode === 'manual' ? '手动' : '自动' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', order.status === 'completed' ? 'status-done' : 'status-wait']">
|
||||
{{ order.status === 'completed' ? '已完成' : '待付款' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-time">{{ formatTime(order.createdAt) }}</td>
|
||||
<td class="col-action">
|
||||
<button class="del-btn" @click="remove(order.id)" title="删除此订单">✕</button>
|
||||
</td>
|
||||
|
||||
<div class="overflow-x-auto max-md:[webkit-overflow-scrolling:touch]">
|
||||
<table class="table bg-white/45 max-md:min-w-[560px]">
|
||||
<thead>
|
||||
<tr class="bg-white/70 border-b-2 border-white/35">
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">商品</th>
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">用户</th>
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 text-center max-md:px-2 max-md:py-1.5 max-md:text-xs">数量</th>
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">发货</th>
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">状态</th>
|
||||
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:hidden">下单时间</th>
|
||||
<th class="w-10 text-center px-3.5 py-2.5"></th>
|
||||
</tr>
|
||||
<tr v-if="order.note || order.contactPhone || order.contactEmail" class="extra-row">
|
||||
<td colspan="7">
|
||||
<div class="extra-info">
|
||||
<span v-if="order.note" class="extra-item">
|
||||
<span class="extra-label">备注:</span>{{ order.note }}
|
||||
</span>
|
||||
<span v-if="order.contactPhone" class="extra-item">
|
||||
<span class="extra-label">手机:</span>{{ order.contactPhone }}
|
||||
</span>
|
||||
<span v-if="order.contactEmail" class="extra-item">
|
||||
<span class="extra-label">邮箱:</span>{{ order.contactEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="order in pagedOrders" :key="order.id">
|
||||
<tr
|
||||
class="border-b border-white/35 last:border-b-0 transition-colors hover:bg-white/70"
|
||||
:class="{ 'opacity-70': order.status === 'pending' }"
|
||||
>
|
||||
<td class="px-3.5 py-3 text-[15px] align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-apptext text-[15px] max-md:text-xs">{{ order.productName }}</span>
|
||||
<span class="text-xs text-muted font-mono">{{ order.id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3.5 py-3 text-[15px] align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">
|
||||
<div v-if="order.userAccount" class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-apptext max-md:text-xs">{{ order.userName || order.userAccount }}</span>
|
||||
<span class="text-xs text-muted">{{ order.userAccount }}</span>
|
||||
</div>
|
||||
<span v-else class="px-2.5 py-0.5 rounded-full bg-[rgba(140,140,145,0.12)] text-muted text-[13px]">匿名</span>
|
||||
</td>
|
||||
<td class="px-3.5 py-3 text-center align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">{{ order.quantity }}</td>
|
||||
<td class="px-3.5 py-3 align-middle max-md:px-2 max-md:py-1.5">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
:class="order.deliveryMode === 'manual' ? 'bg-[rgba(90,120,200,0.12)] text-[#5a78c8]' : 'bg-[rgba(100,185,140,0.12)] text-[#3a9a68]'"
|
||||
>{{ order.deliveryMode === 'manual' ? '手动' : '自动' }}</span>
|
||||
</td>
|
||||
<td class="px-3.5 py-3 align-middle max-md:px-2 max-md:py-1.5">
|
||||
<span
|
||||
class="inline-block px-2.5 py-0.5 rounded-full text-[13px] font-semibold"
|
||||
:class="order.status === 'completed' ? 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]' : 'bg-[rgba(220,178,90,0.15)] text-[#b87e20]'"
|
||||
>{{ order.status === 'completed' ? '已完成' : '待付款' }}</span>
|
||||
</td>
|
||||
<td class="px-3.5 py-3 text-[13px] text-muted whitespace-nowrap align-middle max-md:hidden">{{ formatTime(order.createdAt) }}</td>
|
||||
<td class="px-3.5 py-3 text-center align-middle">
|
||||
<button
|
||||
class="bg-transparent border-none text-muted text-[13px] cursor-pointer px-1.5 py-0.5 rounded leading-none hover:text-[#d64848] hover:bg-[rgba(214,72,72,0.1)] transition"
|
||||
@click="remove(order.id)" title="删除此订单"
|
||||
>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="order.note || order.contactPhone || order.contactEmail" class="border-b border-white/35 last:border-b-0">
|
||||
<td colspan="7" class="px-3.5 pb-2.5 pt-1.5 bg-[rgba(250,250,255,0.5)] max-md:px-2">
|
||||
<div class="flex flex-wrap gap-3 gap-x-5 text-[13px] text-apptext">
|
||||
<span v-if="order.note" class="inline-flex gap-0.5"><span class="text-muted font-semibold">备注:</span>{{ order.note }}</span>
|
||||
<span v-if="order.contactPhone" class="inline-flex gap-0.5"><span class="text-muted font-semibold">手机:</span>{{ order.contactPhone }}</span>
|
||||
<span v-if="order.contactEmail" class="inline-flex gap-0.5"><span class="text-muted font-semibold">邮箱:</span>{{ order.contactEmail }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,10 +96,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
orders: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const props = defineProps({ orders: { type: Array, default: () => [] } })
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
@@ -98,307 +108,14 @@ const pagedOrders = computed(() => {
|
||||
return props.orders.slice(start, start + PAGE_SIZE)
|
||||
})
|
||||
|
||||
// 订单列表变化时重置到第一页
|
||||
watch(() => props.orders.length, () => { currentPage.value = 1 })
|
||||
|
||||
const remove = (id) => {
|
||||
if (confirm('确认删除此订单记录?此操作不可撤销。')) {
|
||||
emit('remove', id)
|
||||
}
|
||||
if (confirm('确认删除此订单记录?此操作不可撤销。')) emit('remove', id)
|
||||
}
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.orders-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.orders-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.orders-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.table thead tr {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.table tbody tr.row-pending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.col-num {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-time {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.order-product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-account {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.anon-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(140, 140, 145, 0.12);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
background: rgba(100, 185, 140, 0.15);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.status-wait {
|
||||
background: rgba(220, 178, 90, 0.15);
|
||||
color: #b87e20;
|
||||
}
|
||||
|
||||
.delivery-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delivery-auto {
|
||||
background: rgba(100, 185, 140, 0.12);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.delivery-manual {
|
||||
background: rgba(90, 120, 200, 0.12);
|
||||
color: #5a78c8;
|
||||
}
|
||||
|
||||
.extra-row td {
|
||||
padding: 6px 14px 10px;
|
||||
background: rgba(250, 250, 255, 0.5);
|
||||
}
|
||||
|
||||
.extra-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.extra-item {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.extra-label {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-action {
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.del-btn:hover {
|
||||
color: #d64848;
|
||||
background: rgba(214, 72, 72, 0.1);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
background: rgba(124, 106, 240, 0.1);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.orders-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.col-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-mask" @click.self="$emit('close')">
|
||||
<section class="modal-card">
|
||||
<div class="modal-header">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto overscroll-contain p-6 bg-[rgba(30,28,32,0.35)] backdrop-blur-lg max-md:p-3"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<section
|
||||
class="flex w-[min(920px,100%)] max-h-[min(calc(100dvh-32px),calc(100vh-32px))] flex-col min-h-0 my-4 shrink-0 overflow-hidden rounded-xl border border-white/35 bg-white/95 p-7 shadow-[0_24px_60px_rgba(33,33,40,0.2)] max-md:my-3 max-md:max-h-[min(calc(100dvh-24px),calc(100vh-24px))] max-md:p-[18px]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-start justify-between gap-4 pb-[22px]">
|
||||
<div>
|
||||
<h3>{{ form.id ? '编辑商品' : '添加新商品' }}</h3>
|
||||
<p class="tag">封面图和最多 5 张商品截图共用这一套编辑表单。</p>
|
||||
@@ -9,12 +17,14 @@
|
||||
<button class="ghost" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Body (scrolls; keeps footer actions reachable) -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1">
|
||||
<div class="form-field">
|
||||
<label>商品名称</label>
|
||||
<input v-model="form.name" placeholder="商品名称" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div class="form-field">
|
||||
<label>原价(元)</label>
|
||||
<input v-model.number="form.price" type="number" step="0.01" />
|
||||
@@ -25,38 +35,40 @@
|
||||
<p class="tag">留空或不小于原价时,将不启用折扣。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>封面链接(http)</label>
|
||||
<input v-model="form.coverUrl" placeholder="http://..." />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="field-head">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<label>商品库存</label>
|
||||
<span class="tag">共 {{ form.inventoryItems.length }} 条</span>
|
||||
<button class="ghost small" type="button" @click="addInventoryItem">添加商品库存</button>
|
||||
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加商品库存</button>
|
||||
</div>
|
||||
<p class="tag">每个输入框保存一条可发放内容,购买后会直接展示给用户。</p>
|
||||
<div class="inventory-list">
|
||||
<div v-for="(_, index) in form.inventoryItems" :key="index" class="inventory-row">
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div v-for="(_, index) in form.inventoryItems" :key="index" class="flex items-center gap-2 max-md:flex-col max-md:items-stretch">
|
||||
<input
|
||||
:ref="(el) => setInventoryInputRef(el, index)"
|
||||
v-model="form.inventoryItems[index]"
|
||||
class="flex-1"
|
||||
placeholder="库存内容,可填卡密、下载链接等"
|
||||
/>
|
||||
<button
|
||||
v-if="form.inventoryItems.length > 1"
|
||||
class="ghost small"
|
||||
class="ghost px-3.5 py-1.5 text-[13px] rounded-full"
|
||||
type="button"
|
||||
@click="removeInventoryItem(index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>商品截图链接(最多 5 个)</label>
|
||||
<div class="screenshot-grid">
|
||||
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
|
||||
<input
|
||||
v-for="(_, index) in screenshotInputSlots"
|
||||
:key="index"
|
||||
@@ -65,26 +77,30 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>商品介绍(Markdown)</label>
|
||||
<textarea v-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>商品标签(英文逗号分隔)</label>
|
||||
<input v-model="form.tagsText" placeholder="例如: chatgpt, linux, 域名" />
|
||||
<p class="tag">用于前端搜索与筛选,多个标签用英文逗号分开</p>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>是否上架</label>
|
||||
<select v-model="form.active">
|
||||
<select v-model="form.active" class="rounded-lg border border-white/35 bg-white/70 px-3 py-2.5 text-[15px] outline-none" style="font-family: var(--font-serif);">
|
||||
<option :value="true">上架</option>
|
||||
<option :value="false">下架</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div class="form-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.requireLogin" class="inline-checkbox" />
|
||||
<label class="flex items-center font-semibold text-apptext cursor-default">
|
||||
<input type="checkbox" v-model="form.requireLogin" class="w-auto mr-1.5 accent-accent cursor-pointer" />
|
||||
需要登录才能购买
|
||||
</label>
|
||||
<p class="tag">开启后未登录用户将无法完成购买</p>
|
||||
@@ -94,26 +110,25 @@
|
||||
<input v-model.number="form.maxPerAccount" type="number" min="0" step="1" placeholder="0 表示不限制" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label>发货模式</label>
|
||||
<select v-model="form.deliveryMode">
|
||||
<option value="auto">自动发货(下单后自动提取内容)</option>
|
||||
<option value="manual">手动发货(管理员手动处理)</option>
|
||||
</select>
|
||||
<p class="tag">手动发货不会自动提取库存内容</p>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>发货模式</label>
|
||||
<p class="rounded-lg border border-white/35 bg-white/70 px-3 py-2.5 text-[15px] text-apptext m-0" style="font-family: var(--font-serif);">
|
||||
自动发货(下单后自动提取库存内容)
|
||||
</p>
|
||||
<p class="tag">手动发货已暂时关闭,后续版本再开放</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div class="form-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.showNote" class="inline-checkbox" />
|
||||
<label class="flex items-center font-semibold text-apptext cursor-default">
|
||||
<input type="checkbox" v-model="form.showNote" class="w-auto mr-1.5 accent-accent cursor-pointer" />
|
||||
下单时显示备注输入框
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.showContact" class="inline-checkbox" />
|
||||
<label class="flex items-center font-semibold text-apptext cursor-default">
|
||||
<input type="checkbox" v-model="form.showContact" class="w-auto mr-1.5 accent-accent cursor-pointer" />
|
||||
下单时显示联系方式输入框
|
||||
</label>
|
||||
<p class="tag">包含手机号和邮箱两个输入框</p>
|
||||
@@ -121,7 +136,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 gap-3 border-t border-white/35 bg-white/95 pt-4 mt-5">
|
||||
<button class="primary" @click="handleSubmit">{{ form.id ? '更新商品' : '新增商品' }}</button>
|
||||
<button class="ghost" @click="resetForm">重置表单</button>
|
||||
</div>
|
||||
@@ -144,86 +160,45 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
|
||||
const createScreenshotSlots = (values = []) =>
|
||||
Array.from({ length: MAX_SCREENSHOT_URLS }, (_, i) => values[i] || '')
|
||||
const createScreenshotSlots = (values = []) => Array.from({ length: MAX_SCREENSHOT_URLS }, (_, i) => values[i] || '')
|
||||
|
||||
const createInventoryItems = (values = []) => {
|
||||
const items = values
|
||||
.map((v) => (v || '').trim())
|
||||
.filter((v) => v)
|
||||
.slice(0, MAX_INVENTORY_ITEMS)
|
||||
const items = values.map((v) => (v || '').trim()).filter((v) => v).slice(0, MAX_INVENTORY_ITEMS)
|
||||
return items.length ? items : ['']
|
||||
}
|
||||
|
||||
const normalizeInventoryItems = (values = []) =>
|
||||
values
|
||||
.map((item) => (item || '').trim())
|
||||
.filter((item) => item)
|
||||
.slice(0, MAX_INVENTORY_ITEMS)
|
||||
values.map((item) => (item || '').trim()).filter((item) => item).slice(0, MAX_INVENTORY_ITEMS)
|
||||
|
||||
const normalizeScreenshotUrls = (values = []) =>
|
||||
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_SCREENSHOT_URLS)
|
||||
|
||||
const makeEmptyForm = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
discountPrice: 0,
|
||||
tagsText: '',
|
||||
coverUrl: '',
|
||||
screenshotUrls: createScreenshotSlots(),
|
||||
inventoryItems: createInventoryItems(),
|
||||
description: '',
|
||||
active: true,
|
||||
requireLogin: false,
|
||||
maxPerAccount: 0,
|
||||
deliveryMode: 'auto',
|
||||
showNote: false,
|
||||
showContact: false
|
||||
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
|
||||
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
|
||||
description: '', active: true, requireLogin: false, maxPerAccount: 0,
|
||||
deliveryMode: 'auto', showNote: true, showContact: true
|
||||
})
|
||||
|
||||
const form = reactive(makeEmptyForm())
|
||||
|
||||
const fillForm = (item) => {
|
||||
Object.assign(form, {
|
||||
id: item?.id || '',
|
||||
name: item?.name || '',
|
||||
price: item?.price || 0,
|
||||
discountPrice: item?.discountPrice || 0,
|
||||
tagsText: (item?.tags || []).join(','),
|
||||
coverUrl: item?.coverUrl || '',
|
||||
id: item?.id || '', name: item?.name || '', price: item?.price || 0, discountPrice: item?.discountPrice || 0,
|
||||
tagsText: (item?.tags || []).join(','), coverUrl: item?.coverUrl || '',
|
||||
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
|
||||
inventoryItems: createInventoryItems(item?.codes || []),
|
||||
description: item?.description || '',
|
||||
active: item?.active ?? true,
|
||||
requireLogin: item?.requireLogin ?? false,
|
||||
maxPerAccount: item?.maxPerAccount ?? 0,
|
||||
deliveryMode: item?.deliveryMode || 'auto',
|
||||
showNote: item?.showNote ?? false,
|
||||
showContact: item?.showContact ?? false
|
||||
description: item?.description || '', active: item?.active ?? true,
|
||||
requireLogin: item?.requireLogin ?? false, maxPerAccount: item?.maxPerAccount ?? 0,
|
||||
deliveryMode: 'auto', showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, makeEmptyForm())
|
||||
}
|
||||
const resetForm = () => { Object.assign(form, makeEmptyForm()) }
|
||||
|
||||
watch(
|
||||
() => props.editItem,
|
||||
(item) => {
|
||||
if (item) {
|
||||
fillForm(item)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(() => props.editItem, (item) => { if (item) { fillForm(item) } else { resetForm() } }, { immediate: true })
|
||||
|
||||
const setInventoryInputRef = (el, index) => {
|
||||
if (!el) return
|
||||
inventoryInputRefs.value[index] = el
|
||||
}
|
||||
const setInventoryInputRef = (el, index) => { if (!el) return; inventoryInputRefs.value[index] = el }
|
||||
|
||||
const addInventoryItem = async () => {
|
||||
form.inventoryItems.push('')
|
||||
@@ -235,10 +210,7 @@ const addInventoryItem = async () => {
|
||||
}
|
||||
|
||||
const removeInventoryItem = async (index) => {
|
||||
if (form.inventoryItems.length <= 1) {
|
||||
form.inventoryItems[0] = ''
|
||||
return
|
||||
}
|
||||
if (form.inventoryItems.length <= 1) { form.inventoryItems[0] = ''; return }
|
||||
form.inventoryItems.splice(index, 1)
|
||||
inventoryInputRefs.value.splice(index, 1)
|
||||
await nextTick()
|
||||
@@ -249,183 +221,13 @@ const removeInventoryItem = async (index) => {
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
price: form.price,
|
||||
discountPrice: form.discountPrice || 0,
|
||||
tags: form.tagsText,
|
||||
coverUrl: form.coverUrl,
|
||||
id: form.id, name: form.name, price: form.price, discountPrice: form.discountPrice || 0,
|
||||
tags: form.tagsText, coverUrl: form.coverUrl,
|
||||
codes: normalizeInventoryItems(form.inventoryItems),
|
||||
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
|
||||
description: form.description,
|
||||
active: form.active,
|
||||
requireLogin: form.requireLogin,
|
||||
maxPerAccount: form.maxPerAccount || 0,
|
||||
deliveryMode: form.deliveryMode || 'auto',
|
||||
showNote: form.showNote,
|
||||
showContact: form.showContact
|
||||
description: form.description, active: form.active, requireLogin: form.requireLogin,
|
||||
maxPerAccount: form.maxPerAccount || 0, deliveryMode: 'auto',
|
||||
showNote: form.showNote, showContact: form.showContact
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(30, 28, 32, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(920px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px 12px;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
}
|
||||
|
||||
.form-field textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px 12px;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.screenshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 7px 13px;
|
||||
font-size: 13px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inline-checkbox {
|
||||
width: auto;
|
||||
margin-right: 6px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.modal-mask {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 18px;
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.screenshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inventory-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,73 @@
|
||||
<template>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<div class="rounded-lg overflow-x-auto border border-white/35 max-md:[webkit-overflow-scrolling:touch]">
|
||||
<table class="table bg-white/45 max-md:min-w-[580px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th>价格</th>
|
||||
<th class="col-num">库存</th>
|
||||
<th class="col-num">浏览量</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
<tr class="bg-white/70 border-b-2 border-white/35">
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">商品</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">价格</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">浏览量</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">状态</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in products" :key="item.id" :class="{ 'row-inactive': !item.active }">
|
||||
<td>
|
||||
<div class="admin-product-cell">
|
||||
<img class="admin-product-thumb" :src="item.coverUrl" :alt="item.name" />
|
||||
<div class="admin-product-info">
|
||||
<span class="product-name">{{ item.name }}</span>
|
||||
<span class="product-id">{{ item.id }}</span>
|
||||
<tr
|
||||
v-for="item in products"
|
||||
:key="item.id"
|
||||
class="border-b border-white/35 last:border-b-0 transition-colors hover:bg-white/70"
|
||||
:class="{ 'opacity-55': !item.active }"
|
||||
>
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
class="w-12 h-12 rounded-md object-cover border border-white/35 flex-shrink-0 shadow-[0_4px_10px_rgba(33,33,40,0.1)] max-md:w-9 max-md:h-9"
|
||||
:src="item.coverUrl" :alt="item.name"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-base font-semibold text-apptext max-md:text-[13px]">{{ item.name }}</span>
|
||||
<span class="text-[13px] text-muted font-mono max-md:text-[11px]">{{ item.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="item.price === 0" class="price-cell">
|
||||
<span class="price-free">免费</span>
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
<div v-if="item.price === 0" class="font-black text-[#3a9a68] text-[17px]">免费</div>
|
||||
<div v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="flex flex-col gap-0.5">
|
||||
<span class="line-through text-muted text-sm">¥{{ item.price.toFixed(2) }}</span>
|
||||
<span class="font-bold text-[#e8826a] text-[17px]">¥{{ item.discountPrice.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
|
||||
class="price-cell"
|
||||
>
|
||||
<span class="price-original">¥{{ item.price.toFixed(2) }}</span>
|
||||
<span class="price-discount">¥{{ item.discountPrice.toFixed(2) }}</span>
|
||||
</div>
|
||||
<span v-else class="price-normal">¥{{ item.price.toFixed(2) }}</span>
|
||||
<span v-else class="font-semibold text-apptext">¥{{ item.price.toFixed(2) }}</span>
|
||||
</td>
|
||||
<td class="col-num">
|
||||
<span :class="['stock-badge', item.quantity === 0 ? 'stock-empty' : 'stock-ok']">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
<td class="px-4 py-3.5 text-center align-middle max-md:px-2.5 max-md:py-2">
|
||||
<span
|
||||
class="inline-block px-2.5 py-0.5 rounded-full text-[15px] font-semibold"
|
||||
:class="item.quantity === 0 ? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]' : 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
|
||||
>{{ item.quantity }}</span>
|
||||
</td>
|
||||
<td class="col-num">
|
||||
<span class="view-count">{{ item.viewCount || 0 }}</span>
|
||||
<td class="px-4 py-3.5 text-center text-[15px] text-muted align-middle max-md:px-2.5 max-md:py-2">{{ item.viewCount || 0 }}</td>
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold tracking-[0.3px]"
|
||||
:class="item.active ? 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]' : 'bg-[rgba(140,140,145,0.12)] text-muted'"
|
||||
>{{ item.active ? '上架' : '下架' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', item.active ? 'status-on' : 'status-off']">
|
||||
{{ item.active ? '上架' : '下架' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="act-edit" @click="$emit('edit', item)">编辑</button>
|
||||
<button class="act-toggle" @click="$emit('toggle', item)">
|
||||
{{ item.active ? '下架' : '上架' }}
|
||||
</button>
|
||||
<button class="act-delete" @click="$emit('remove', item)">删除</button>
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(145,168,208,0.15)] text-accent2 hover:bg-[rgba(145,168,208,0.28)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="$emit('edit', item)"
|
||||
>编辑</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(180,154,203,0.12)] text-accent hover:bg-[rgba(180,154,203,0.24)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="$emit('toggle', item)"
|
||||
>{{ item.active ? '下架' : '上架' }}</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(201,90,106,0.1)] text-[#c95a6a] hover:bg-[rgba(201,90,106,0.2)] max-md:px-2 max-md:py-1 max-md:text-xs"
|
||||
style="font-family: var(--font-serif);"
|
||||
@click="$emit('remove', item)"
|
||||
>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -64,254 +77,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
products: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineProps({ products: { type: Array, default: () => [] } })
|
||||
defineEmits(['edit', 'toggle', 'remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.table thead tr {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.table tbody tr.row-inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.col-num {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-product-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 10px rgba(33, 33, 40, 0.1);
|
||||
}
|
||||
|
||||
.admin-product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.product-id {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.price-discount {
|
||||
font-weight: 700;
|
||||
color: #e8826a;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
font-weight: 900;
|
||||
color: #3a9a68;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.price-normal {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stock-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-ok {
|
||||
background: rgba(100, 185, 140, 0.15);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.stock-empty {
|
||||
background: rgba(201, 90, 106, 0.12);
|
||||
color: #c95a6a;
|
||||
}
|
||||
|
||||
.view-count {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.status-on {
|
||||
background: rgba(100, 185, 140, 0.15);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.status-off {
|
||||
background: rgba(140, 140, 145, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.act-edit,
|
||||
.act-toggle,
|
||||
.act-delete {
|
||||
padding: 6px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
}
|
||||
|
||||
.act-edit {
|
||||
background: rgba(145, 168, 208, 0.15);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.act-edit:hover {
|
||||
background: rgba(145, 168, 208, 0.28);
|
||||
}
|
||||
|
||||
.act-toggle {
|
||||
background: rgba(180, 154, 203, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.act-toggle:hover {
|
||||
background: rgba(180, 154, 203, 0.24);
|
||||
}
|
||||
|
||||
.act-delete {
|
||||
background: rgba(201, 90, 106, 0.1);
|
||||
color: #c95a6a;
|
||||
}
|
||||
|
||||
.act-delete:hover {
|
||||
background: rgba(201, 90, 106, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table {
|
||||
min-width: 580px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-product-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.product-id {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.act-edit,
|
||||
.act-toggle,
|
||||
.act-delete {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
<template>
|
||||
<div class="smtp-row">
|
||||
<div class="smtp-header">
|
||||
<span class="smtp-label">邮件通知配置</span>
|
||||
<span class="smtp-desc tag">下单/发货时自动给用户发送通知邮件(支持 QQ / 163 / Gmail / 自定义域名邮箱)</span>
|
||||
<span v-if="message" class="msg-tag" :class="{ error: message.includes('失败') }">{{ message }}</span>
|
||||
<div class="px-[18px] py-3.5 bg-white/50 border border-white/35 rounded-lg mb-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||
<span class="text-[15px] font-semibold text-apptext whitespace-nowrap">邮件通知配置</span>
|
||||
<span class="text-[13px] text-muted">下单/发货时自动给用户发送通知邮件(支持 QQ / 163 / Gmail / 自定义域名邮箱)</span>
|
||||
<span
|
||||
v-if="message"
|
||||
class="text-sm px-2 py-0.5 rounded whitespace-nowrap"
|
||||
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
|
||||
>{{ message }}</span>
|
||||
</div>
|
||||
<div class="smtp-enable-row">
|
||||
<label class="smtp-toggle">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
|
||||
<!-- Enable toggle -->
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-sm text-apptext font-medium">
|
||||
<input type="checkbox" v-model="form.enabled" class="w-4 h-4 accent-accent cursor-pointer" />
|
||||
<span>启用邮件通知</span>
|
||||
</label>
|
||||
<span class="smtp-status-tag" :class="form.enabled ? 'tag-on' : 'tag-off'">
|
||||
{{ form.enabled ? '已启用' : '已关闭' }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
:class="form.enabled ? 'bg-[rgba(74,222,128,0.15)] text-[#2d8a4e]' : 'bg-black/[0.06] text-[#888]'"
|
||||
>{{ form.enabled ? '已启用' : '已关闭' }}</span>
|
||||
</div>
|
||||
<div class="smtp-fields" :class="{ 'smtp-fields-disabled': !form.enabled }">
|
||||
<label class="smtp-field">
|
||||
<span>发件邮箱</span>
|
||||
<input v-model="form.email" type="email" placeholder="noreply@yourdomain.com" :disabled="!form.enabled" />
|
||||
|
||||
<!-- Fields -->
|
||||
<div
|
||||
class="flex gap-2.5 flex-wrap items-end max-md:flex-col"
|
||||
:class="{ 'opacity-45 pointer-events-none': !form.enabled }"
|
||||
>
|
||||
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
|
||||
<span class="text-[13px] text-muted">发件邮箱</span>
|
||||
<input v-model="form.email" type="email" placeholder="noreply@yourdomain.com" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
|
||||
</label>
|
||||
<label class="smtp-field">
|
||||
<span>SMTP 密码 / 授权码</span>
|
||||
<input v-model="form.password" type="password" placeholder="QQ/163 填授权码;其他填密码" autocomplete="new-password" :disabled="!form.enabled" />
|
||||
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
|
||||
<span class="text-[13px] text-muted">SMTP 密码 / 授权码</span>
|
||||
<input v-model="form.password" type="password" placeholder="QQ/163 填授权码;其他填密码" autocomplete="new-password" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
|
||||
</label>
|
||||
<label class="smtp-field">
|
||||
<span>发件人名称</span>
|
||||
<input v-model="form.fromName" type="text" placeholder="萌芽小店" :disabled="!form.enabled" />
|
||||
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
|
||||
<span class="text-[13px] text-muted">发件人名称</span>
|
||||
<input v-model="form.fromName" type="text" placeholder="萌芽小店" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
|
||||
</label>
|
||||
<label class="smtp-field">
|
||||
<span>SMTP 主机</span>
|
||||
<input v-model="form.host" type="text" placeholder="smtp.qq.com" :disabled="!form.enabled" />
|
||||
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
|
||||
<span class="text-[13px] text-muted">SMTP 主机</span>
|
||||
<input v-model="form.host" type="text" placeholder="smtp.qq.com" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
|
||||
</label>
|
||||
<label class="smtp-field smtp-field-port">
|
||||
<span>端口</span>
|
||||
<input v-model="form.port" type="text" placeholder="465" :disabled="!form.enabled" />
|
||||
<label class="flex flex-col gap-1 w-[90px] flex-shrink-0 max-md:w-full">
|
||||
<span class="text-[13px] text-muted">端口</span>
|
||||
<input v-model="form.port" type="text" placeholder="465" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
|
||||
</label>
|
||||
<button class="primary smtp-save-btn" type="button" :disabled="saving" @click="save">
|
||||
<button class="primary self-end px-[18px] py-2 text-sm whitespace-nowrap" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -51,17 +64,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
enabled: true,
|
||||
email: '',
|
||||
password: '',
|
||||
fromName: '',
|
||||
host: 'smtp.qq.com',
|
||||
port: '465'
|
||||
})
|
||||
const form = reactive({ enabled: true, email: '', password: '', fromName: '', host: 'smtp.qq.com', port: '465' })
|
||||
|
||||
watch(() => props.config, (cfg) => {
|
||||
if (!cfg) return
|
||||
@@ -75,165 +80,6 @@ watch(() => props.config, (cfg) => {
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await emit('save', { ...form })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
try { await emit('save', { ...form }) } finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smtp-row {
|
||||
padding: 14px 18px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.smtp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.smtp-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smtp-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.smtp-enable-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.smtp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.smtp-toggle input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smtp-status-tag {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tag-on {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #2d8a4e;
|
||||
}
|
||||
|
||||
.tag-off {
|
||||
background: rgba(0,0,0,0.06);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.smtp-fields {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smtp-fields-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.smtp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.smtp-field-port {
|
||||
max-width: 90px;
|
||||
flex: 0 0 90px;
|
||||
}
|
||||
|
||||
.smtp-field span {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.smtp-field input {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smtp-field input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.smtp-save-btn {
|
||||
align-self: flex-end;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-tag {
|
||||
font-size: 14px;
|
||||
color: var(--accent-2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: rgba(145, 168, 208, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-tag.error {
|
||||
color: #c95a6a;
|
||||
background: rgba(201, 90, 106, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.smtp-fields {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.smtp-field {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.smtp-field-port {
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
<template>
|
||||
<div v-if="show" class="token-row">
|
||||
<div class="form-field token-field">
|
||||
<div v-if="show" class="flex items-end gap-3.5 mb-4 p-4 bg-white/50 border border-white/35 rounded-lg flex-wrap max-md:flex-col max-md:items-stretch max-md:p-3">
|
||||
<div class="form-field flex-1 max-w-[460px] !mb-0 max-md:max-w-full">
|
||||
<label>管理 Token</label>
|
||||
<div class="token-input-wrap">
|
||||
<input :value="token" @input="$emit('update:token', $event.target.value)" placeholder="粘贴管理员令牌后自动加载…" />
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
class="flex-1"
|
||||
:value="token"
|
||||
@input="$emit('update:token', $event.target.value)"
|
||||
placeholder="粘贴管理员令牌后自动加载…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="message"
|
||||
class="msg-tag"
|
||||
:class="{ error: message.includes('失败') || message.includes('错误') }"
|
||||
class="text-[15px] px-2.5 py-1 rounded bg-[rgba(145,168,208,0.1)] whitespace-nowrap"
|
||||
:class="message.includes('失败') || message.includes('错误') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2'"
|
||||
>{{ message }}</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="inlineMessage"
|
||||
class="msg-inline"
|
||||
:class="{ error: inlineMessage.includes('失败') || inlineMessage.includes('错误') }"
|
||||
class="text-[15px] mb-3"
|
||||
:class="inlineMessage.includes('失败') || inlineMessage.includes('错误') ? 'text-[#c95a6a]' : 'text-accent2'"
|
||||
>{{ inlineMessage }}</p>
|
||||
</template>
|
||||
|
||||
@@ -26,77 +31,5 @@ defineProps({
|
||||
message: { type: String, default: '' },
|
||||
inlineMessage: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['update:token'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-field {
|
||||
flex: 1;
|
||||
max-width: 460px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.token-input-wrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token-input-wrap input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.msg-tag {
|
||||
font-size: 15px;
|
||||
color: var(--accent-2);
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: rgba(145, 168, 208, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-tag.error {
|
||||
color: #c95a6a;
|
||||
background: rgba(201, 90, 106, 0.08);
|
||||
}
|
||||
|
||||
.msg-inline {
|
||||
font-size: 15px;
|
||||
color: var(--accent-2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.msg-inline.error {
|
||||
color: #c95a6a;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 7px 13px;
|
||||
font-size: 13px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.token-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
.token-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="auth-callback">
|
||||
<div v-if="status === 'loading'" class="auth-status">
|
||||
<div class="flex justify-center py-12">
|
||||
<div v-if="status === 'loading'" class="text-center flex flex-col items-center gap-3">
|
||||
<h2>正在验证登录...</h2>
|
||||
<p class="tag">请稍候,正在与萌芽认证中心确认身份</p>
|
||||
</div>
|
||||
<div v-else-if="status === 'success'" class="auth-status">
|
||||
<div v-else-if="status === 'success'" class="text-center flex flex-col items-center gap-3">
|
||||
<h2>登录成功</h2>
|
||||
<p class="tag">欢迎回来,{{ displayName }}!正在跳转...</p>
|
||||
</div>
|
||||
<div v-else class="auth-status">
|
||||
<div v-else class="text-center flex flex-col items-center gap-3">
|
||||
<h2>登录失败</h2>
|
||||
<p class="tag">{{ errorMessage }}</p>
|
||||
<button class="primary" @click="goHome">返回商店</button>
|
||||
@@ -52,7 +52,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证 token 并从 SproutGate 获取最新用户信息
|
||||
const verifyData = await verifySproutGateToken(token)
|
||||
if (!verifyData.valid) {
|
||||
status.value = 'error'
|
||||
@@ -71,7 +70,6 @@ onMounted(async () => {
|
||||
status.value = 'success'
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
} catch {
|
||||
// 验证失败(如网络异常)时,回退使用 URL fragment 中的数据
|
||||
setAuth({ token, account, username, avatarUrl: fragmentAvatar })
|
||||
displayName.value = username || account
|
||||
status.value = 'success'
|
||||
@@ -79,19 +77,3 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-callback {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,84 +1,109 @@
|
||||
<template>
|
||||
<!-- Floating toggle button -->
|
||||
<button class="chat-fab" @click="toggleOpen" :title="open ? '关闭' : '联系客服'" aria-label="联系客服">
|
||||
<!-- Floating action button -->
|
||||
<button
|
||||
class="fixed right-6 bottom-7 z-[1200] w-[52px] h-[52px] rounded-full bg-[#2c2b2d] text-white border-none cursor-pointer flex items-center justify-center shadow-[0_4px_18px_rgba(0,0,0,0.28)] transition hover:scale-[1.08] hover:shadow-[0_6px_24px_rgba(0,0,0,0.38)] max-[600px]:right-4 max-[600px]:bottom-5"
|
||||
@click="toggleOpen" :title="open ? '关闭' : '联系客服'" aria-label="联系客服"
|
||||
>
|
||||
<Transition name="icon-flip" mode="out-in">
|
||||
<svg v-if="!open" key="chat" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<svg v-else key="close" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<svg v-if="!open" key="chat" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<svg v-else key="close" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</Transition>
|
||||
</button>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<Transition name="chat-slide">
|
||||
<div v-if="open" class="chat-panel">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed right-6 bottom-[94px] z-[1200] w-[340px] h-[500px] flex flex-col rounded-2xl bg-white/97 backdrop-blur-xl shadow-[0_16px_56px_rgba(0,0,0,0.16)] border border-white/60 overflow-hidden max-[600px]:right-2 max-[600px]:bottom-20 max-[600px]:w-[calc(100vw-16px)] max-[600px]:h-[440px]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="chat-header">
|
||||
<div class="chat-header-avatar">
|
||||
<div class="flex items-center gap-2.5 px-4 py-3.5 bg-white border-b border-black/[0.08] flex-shrink-0">
|
||||
<div class="w-9 h-9 rounded-full bg-[#f0f0f0] flex items-center justify-center flex-shrink-0 text-[#555]">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div class="chat-header-info">
|
||||
<span class="chat-title">在线客服</span>
|
||||
<span class="chat-subtitle">有问题随时找我们</span>
|
||||
<div class="flex-1 flex flex-col gap-0">
|
||||
<span class="text-[15px] font-bold text-[#1a1a1a] leading-[1.2]">在线客服</span>
|
||||
<span class="text-[11px] text-[#888]">有问题随时找我们</span>
|
||||
</div>
|
||||
<button class="chat-close-btn" @click="toggleOpen" title="关闭">
|
||||
<button
|
||||
class="w-7 h-7 rounded-full bg-black/10 border-none text-[#333] cursor-pointer flex items-center justify-center transition hover:bg-black/[0.18] flex-shrink-0"
|
||||
@click="toggleOpen" title="关闭"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="chat-messages" ref="messagesEl">
|
||||
<div v-if="loading" class="chat-status">
|
||||
<span class="loading-dots"><span>.</span><span>.</span><span>.</span></span>
|
||||
<!-- Messages area -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-3 py-3.5 flex flex-col gap-3 bg-[#f8f6fb] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref="messagesEl"
|
||||
>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<span class="text-[26px] text-muted tracking-[4px] loading-dots"><span>.</span><span>.</span><span>.</span></span>
|
||||
</div>
|
||||
<div v-else-if="messages.length === 0" class="chat-empty-state">
|
||||
<div class="chat-empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.4"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div v-else-if="messages.length === 0" class="flex flex-col items-center gap-1.5 py-10">
|
||||
<div class="text-muted mb-1">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</div>
|
||||
<p class="chat-empty-text">有任何疑问,欢迎留言!</p>
|
||||
<p class="chat-empty-sub">客服在线时会尽快回复您</p>
|
||||
<p class="text-sm font-semibold text-apptext m-0">有任何疑问,欢迎留言!</p>
|
||||
<p class="text-xs text-muted m-0">客服在线时会尽快回复您</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="['msg-row', msg.fromAdmin ? 'msg-row--admin' : 'msg-row--me']"
|
||||
class="flex items-end gap-1.5"
|
||||
:class="msg.fromAdmin ? '' : 'flex-row-reverse'"
|
||||
>
|
||||
<div v-if="msg.fromAdmin" class="msg-avatar msg-avatar--admin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-meta">
|
||||
<span class="msg-name">{{ msg.fromAdmin ? '客服' : '我' }}</span>
|
||||
<span class="msg-time">{{ formatTime(msg.sentAt) }}</span>
|
||||
</div>
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="w-7 h-7 rounded-full text-[11px] font-bold flex items-center justify-center flex-shrink-0 overflow-hidden"
|
||||
:class="msg.fromAdmin ? 'bg-[#2c2b2d] text-white' : 'bg-[#e8e8e8] text-[#444]'"
|
||||
>
|
||||
<template v-if="!msg.fromAdmin">
|
||||
<img v-if="props.userAvatar" :src="props.userAvatar" class="w-7 h-7 object-cover rounded-full block" />
|
||||
<span v-else>{{ (props.userName || '我').charAt(0) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!msg.fromAdmin" class="msg-avatar msg-avatar--me">
|
||||
<img v-if="props.userAvatar" :src="props.userAvatar" class="avatar-img" />
|
||||
<span v-else>{{ (props.userName || '我').charAt(0) }}</span>
|
||||
<!-- Bubble -->
|
||||
<div class="flex flex-col gap-0.5 max-w-[76%]" :class="msg.fromAdmin ? '' : 'items-end'">
|
||||
<div class="flex gap-1.5 items-baseline px-0.5">
|
||||
<span class="text-[11px] font-semibold text-muted">{{ msg.fromAdmin ? '客服' : '我' }}</span>
|
||||
<span class="text-[10px] text-muted opacity-70">{{ formatTime(msg.sentAt) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3.5 py-2.5 text-sm leading-[1.55] whitespace-pre-wrap break-words shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="msg.fromAdmin
|
||||
? 'bg-white text-[#1a1a1a] rounded-xl rounded-bl-[4px] border border-black/[0.08]'
|
||||
: 'bg-[#2c2b2d] text-white rounded-xl rounded-br-[4px]'"
|
||||
>{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<form class="chat-input-row" @submit.prevent="send">
|
||||
<!-- Input row -->
|
||||
<form class="flex items-center gap-2 px-3 py-2.5 border-t border-black/[0.06] bg-white flex-shrink-0" @submit.prevent="send">
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
class="flex-1 px-3.5 py-2.5 rounded-full border border-[rgba(180,154,203,0.3)] text-sm bg-[#f8f6fb] text-apptext outline-none transition focus:border-[#aaa] focus:bg-white"
|
||||
style="font-family: var(--font-serif);"
|
||||
placeholder="输入消息,Enter 发送…"
|
||||
maxlength="500"
|
||||
:disabled="sending"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<button class="chat-send-btn" type="submit" :disabled="sending || !inputText.trim()">
|
||||
<button
|
||||
class="w-[38px] h-[38px] rounded-full bg-[#2c2b2d] text-white border-none cursor-pointer flex items-center justify-center flex-shrink-0 transition disabled:opacity-55 disabled:cursor-default disabled:bg-[#888] hover:not-disabled:scale-[1.08] hover:not-disabled:opacity-85"
|
||||
type="submit" :disabled="sending || !inputText.trim()"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="chat-error" v-if="error">{{ error }}</p>
|
||||
<p v-if="error" class="text-xs text-[#d64848] px-3.5 pb-2 text-center">{{ error }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -105,36 +130,23 @@ let pollTimer = null
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const scrollBottom = async () => {
|
||||
await nextTick()
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
messages.value = await fetchMyChatMessages(props.userToken)
|
||||
await scrollBottom()
|
||||
} catch {
|
||||
// 静默忽略轮询错误,避免频繁弹出错误提示
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollTimer = setInterval(loadMessages, 5000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
const startPolling = () => { pollTimer = setInterval(loadMessages, 5000) }
|
||||
const stopPolling = () => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||
|
||||
const toggleOpen = async () => {
|
||||
open.value = !open.value
|
||||
@@ -171,375 +183,35 @@ onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── FAB button ── */
|
||||
.chat-fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 28px;
|
||||
z-index: 1200;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.chat-fab:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
/* ── Panel ── */
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 94px;
|
||||
z-index: 1200;
|
||||
width: 340px;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 13px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-subtitle {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.chat-close-btn {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-close-btn:hover {
|
||||
background: rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
/* ── Messages area ── */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: none;
|
||||
background: #f8f6fb;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Loading dots */
|
||||
.chat-status {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
font-size: 26px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
animation: blink 1.2s infinite;
|
||||
/* ── Animations that cannot be expressed in Tailwind utilities ── */
|
||||
@keyframes blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
.loading-dots span { animation: blink 1.2s infinite; }
|
||||
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.chat-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.chat-empty-icon {
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-empty-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Message rows */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.msg-row--me {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-avatar--admin {
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.msg-avatar--me {
|
||||
background: #e8e8e8;
|
||||
color: #444;
|
||||
overflow: hidden;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-width: 76%;
|
||||
}
|
||||
|
||||
.msg-row--me .msg-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: baseline;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.msg-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
padding: 9px 13px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.msg-row--admin .msg-content {
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.msg-row--me .msg-content {
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Input row ── */
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 9px 13px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(180, 154, 203, 0.3);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #f8f6fb;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border-color: #aaa;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: #2c2b2d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-send-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.chat-send-btn:not(:disabled):hover {
|
||||
transform: scale(1.08);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
font-size: 12px;
|
||||
color: #d64848;
|
||||
padding: 0 14px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
/* Chat panel slide */
|
||||
.chat-slide-enter-active,
|
||||
.chat-slide-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.chat-slide-enter-from,
|
||||
.chat-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
|
||||
/* Icon flip */
|
||||
.icon-flip-enter-active,
|
||||
.icon-flip-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.icon-flip-enter-from,
|
||||
.icon-flip-leave-to {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.7);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.chat-panel {
|
||||
right: 8px;
|
||||
bottom: 80px;
|
||||
width: calc(100vw - 16px);
|
||||
height: 440px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.chat-fab {
|
||||
right: 16px;
|
||||
bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,93 +1,28 @@
|
||||
<template>
|
||||
<div class="maintenance-wrap">
|
||||
<div class="maintenance-card">
|
||||
<div class="maintenance-icon">
|
||||
<div class="flex items-center justify-center min-h-[60vh] px-[5vw] py-10">
|
||||
<div class="max-w-[480px] w-full glass-strong rounded-[var(--radius-default)] px-10 py-12 text-center shadow-[var(--shadow-card)] max-md:px-6 max-md:py-8">
|
||||
<!-- Icon -->
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-[20px] bg-gradient-to-br from-accent to-accent2 text-white mb-6">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>站点维护中</h1>
|
||||
<p class="divider-line"></p>
|
||||
<p class="reason" v-if="reason">{{ reason }}</p>
|
||||
<p class="reason muted" v-else>暂无维护说明,请稍后再试。</p>
|
||||
<p class="tip">如有紧急需求,请联系管理员。</p>
|
||||
|
||||
<h1 class="text-[28px] font-bold text-apptext mb-4 tracking-[1px]">站点维护中</h1>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-12 h-[3px] rounded-full bg-gradient-to-r from-accent to-accent2 opacity-60 mx-auto mb-5"></div>
|
||||
|
||||
<p v-if="reason" class="text-[17px] text-apptext leading-[1.7] mb-4">{{ reason }}</p>
|
||||
<p v-else class="text-[17px] text-muted leading-[1.7] mb-4">暂无维护说明,请稍后再试。</p>
|
||||
|
||||
<p class="text-[15px] text-muted mt-2">如有紧急需求,请联系管理员。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
reason: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
defineProps({
|
||||
reason: { type: String, default: '' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 40px 5vw;
|
||||
}
|
||||
|
||||
.maintenance-card {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.maintenance-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: white;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.maintenance-card h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||
margin: 0 auto 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.reason {
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reason.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
<template>
|
||||
<Transition name="splash-fade" appear>
|
||||
<div v-if="visible" class="splash-screen">
|
||||
<!-- Ambient glow -->
|
||||
<div class="splash-glow splash-glow-1"></div>
|
||||
<div class="splash-glow splash-glow-2"></div>
|
||||
<div v-if="visible" class="fixed inset-0 z-[99999] flex items-center justify-center overflow-hidden bg-gradient-to-br from-[#f8f5f2] via-[#e9f0f7] to-[#f4eaf1]">
|
||||
<!-- Ambient glow blobs -->
|
||||
<div class="glow-blob absolute w-[420px] h-[420px] -top-20 -left-24 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(180,154,203,0.35) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out infinite alternate;"></div>
|
||||
<div class="glow-blob absolute w-[380px] h-[380px] -bottom-16 -right-20 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(145,168,208,0.3) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out 1.5s infinite alternate;"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="splash-center">
|
||||
<!-- Ripple rings -->
|
||||
<div class="splash-rings">
|
||||
<div class="ring ring-1"></div>
|
||||
<div class="ring ring-2"></div>
|
||||
<div class="ring ring-3"></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="splash-logo-wrap">
|
||||
<img src="/pwa-192x192.png" alt="萌芽小店" class="splash-logo" />
|
||||
<!-- Center content -->
|
||||
<div class="relative z-[1] flex flex-col items-center gap-0">
|
||||
<!-- Rings + logo -->
|
||||
<div class="relative w-40 h-40 flex items-center justify-center mb-7">
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0.8s;"></div>
|
||||
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 1.6s;"></div>
|
||||
<div class="relative z-[2]" style="animation: logo-float 2.8s ease-in-out infinite;">
|
||||
<img
|
||||
src="/pwa-192x192.png"
|
||||
alt="萌芽小店"
|
||||
class="w-[88px] h-[88px] rounded-[22px] object-cover shadow-[0_12px_40px_rgba(33,33,40,0.18),0_2px_8px_rgba(180,154,203,0.25)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="splash-title">萌芽小店</h1>
|
||||
<p class="splash-subtitle">加载中</p>
|
||||
<h1 class="text-[26px] font-extrabold text-[#2c2b2d] tracking-[2px] mb-1.5" style="font-family: var(--font-serif);">萌芽小店</h1>
|
||||
<p class="text-[13px] text-[#8a8690] tracking-[3px] mb-5">加载中</p>
|
||||
|
||||
<!-- Dots loader -->
|
||||
<div class="splash-dots">
|
||||
<span class="dot dot-1"></span>
|
||||
<span class="dot dot-2"></span>
|
||||
<span class="dot dot-3"></span>
|
||||
<!-- Dots -->
|
||||
<div class="flex gap-2 items-center mt-1">
|
||||
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0s;"></span>
|
||||
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0.2s;"></span>
|
||||
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0.4s;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,104 +36,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: true }
|
||||
})
|
||||
defineProps({ visible: { type: Boolean, default: true } })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.splash-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #f8f5f2 0%, #e9f0f7 45%, #f4eaf1 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ambient glow blobs */
|
||||
.splash-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
animation: glow-pulse 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.splash-glow-1 {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
top: -80px;
|
||||
left: -100px;
|
||||
background: radial-gradient(circle, rgba(180, 154, 203, 0.35) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.splash-glow-2 {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
bottom: -60px;
|
||||
right: -80px;
|
||||
background: radial-gradient(circle, rgba(145, 168, 208, 0.3) 0%, transparent 70%);
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
|
||||
/* Keyframe animations – cannot be replicated with Tailwind utilities */
|
||||
@keyframes glow-pulse {
|
||||
from { opacity: 0.6; transform: scale(1); }
|
||||
to { opacity: 1; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
/* Center content */
|
||||
.splash-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Rings + logo */
|
||||
.splash-rings {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(180, 154, 203, 0.4);
|
||||
animation: ring-expand 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.ring-1 { width: 100px; height: 100px; animation-delay: 0s; }
|
||||
.ring-2 { width: 100px; height: 100px; animation-delay: 0.8s; }
|
||||
.ring-3 { width: 100px; height: 100px; animation-delay: 1.6s; }
|
||||
|
||||
@keyframes ring-expand {
|
||||
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(180, 154, 203, 0.5); }
|
||||
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(180, 154, 203, 0); }
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.splash-logo-wrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: logo-float 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 22px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 12px 40px rgba(33, 33, 40, 0.18), 0 2px 8px rgba(180, 154, 203, 0.25);
|
||||
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(180,154,203,0.5); }
|
||||
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(180,154,203,0); }
|
||||
}
|
||||
|
||||
@keyframes logo-float {
|
||||
@@ -140,53 +56,25 @@ defineProps({
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.splash-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #2c2b2d;
|
||||
letter-spacing: 2px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.splash-subtitle {
|
||||
font-size: 13px;
|
||||
color: #8a8690;
|
||||
letter-spacing: 3px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* Dots */
|
||||
.splash-dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-1 { animation-delay: 0s; }
|
||||
.dot-2 { animation-delay: 0.2s; }
|
||||
.dot-3 { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 60%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||||
30% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Exit transition */
|
||||
.ring-anim {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
animation: ring-expand 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.dot-bounce {
|
||||
animation: dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Splash exit transition */
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loading && product">
|
||||
<div class="checkout-header">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2>立即下单 · {{ product.name }}</h2>
|
||||
<p class="tag">一个扫码流程即可完成订单</p>
|
||||
@@ -8,20 +9,24 @@
|
||||
<button class="ghost" @click="goBack">返回商品</button>
|
||||
</div>
|
||||
|
||||
<div class="checkout-grid">
|
||||
<div class="checkout-summary">
|
||||
<img :src="product.coverUrl" :alt="product.name" />
|
||||
<div class="summary-content">
|
||||
<!-- Grid: summary + form -->
|
||||
<div class="grid gap-6 mb-5 max-md:grid-cols-1" style="grid-template-columns: minmax(280px, 1fr) 320px;">
|
||||
<!-- Summary -->
|
||||
<div class="flex gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-[18px] max-md:flex-col max-md:items-center">
|
||||
<img
|
||||
:src="product.coverUrl"
|
||||
:alt="product.name"
|
||||
class="w-[140px] h-[140px] object-cover rounded-lg max-md:w-full max-md:h-[200px]"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<p class="tag">库存:{{ product.quantity }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="product-price">
|
||||
<span v-if="isFree" class="free-price">免费</span>
|
||||
<span
|
||||
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
|
||||
>
|
||||
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
|
||||
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
|
||||
¥ {{ product.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
@@ -29,14 +34,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-form" v-if="!orderResult && product.requireLogin && !loggedIn">
|
||||
<div class="require-login-block">
|
||||
<p class="require-login-title">该商品需要登录后才能购买</p>
|
||||
<a class="primary btn-inline" :href="loginUrl">立即登录</a>
|
||||
</div>
|
||||
<!-- Require-login gate -->
|
||||
<div v-if="!orderResult && product.requireLogin && !loggedIn" class="flex flex-col gap-4 items-center p-8 text-center border border-white/35 rounded-lg bg-white/60">
|
||||
<p class="text-[18px] font-semibold text-apptext">该商品需要登录后才能购买</p>
|
||||
<a class="primary inline-block no-underline px-7 py-2.5 rounded-lg text-base font-bold cursor-pointer" :href="loginUrl">立即登录</a>
|
||||
</div>
|
||||
|
||||
<form class="checkout-form" @submit.prevent="submitOrder" v-else-if="!orderResult">
|
||||
<!-- Order form -->
|
||||
<form v-else-if="!orderResult" class="flex flex-col gap-3" @submit.prevent="submitOrder">
|
||||
<div class="form-field">
|
||||
<label>下单数量</label>
|
||||
<input
|
||||
@@ -47,85 +52,86 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="tag">预计总价:¥ {{ totalPrice.toFixed(2) }}</p>
|
||||
<p class="tag limit-hint" v-if="product.maxPerAccount > 0">
|
||||
<p class="tag text-accent" v-if="product.maxPerAccount > 0">
|
||||
该商品每个账户最多可购买 {{ product.maxPerAccount }} 个
|
||||
</p>
|
||||
|
||||
<div class="form-field" v-if="product.showNote">
|
||||
<div class="form-field">
|
||||
<label>备注(选填)</label>
|
||||
<textarea v-model="form.note" placeholder="填写您的备注信息…" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<template v-if="product.showContact">
|
||||
<div class="form-row contact-row">
|
||||
<div class="form-field">
|
||||
<label>手机号(选填)</label>
|
||||
<input v-model="form.contactPhone" type="tel" placeholder="138xxxx0000" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>邮箱(选填)</label>
|
||||
<input v-model="form.contactEmail" type="email" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div class="form-field">
|
||||
<label>手机号(选填)</label>
|
||||
<input v-model="form.contactPhone" type="tel" placeholder="138xxxx0000" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>邮箱(选填)</label>
|
||||
<input v-model="form.contactEmail" type="email" placeholder="you@example.com" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="tag login-hint" v-if="!loggedIn">
|
||||
<a :href="loginUrl">登录萌芽账号</a> 后购买可享受:历史订单记录、专属优惠商品及更多购买权益
|
||||
</p>
|
||||
<div class="delivery-tip" v-if="product.deliveryMode === 'manual'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
该商品为手动发货,付款后管理员将核验并处理您的订单
|
||||
</div>
|
||||
|
||||
<p class="tag" v-if="!loggedIn">
|
||||
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受:历史订单记录、专属优惠商品及更多购买权益
|
||||
</p>
|
||||
|
||||
<button class="primary" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '生成中...' : '生成二维码下单' }}
|
||||
</button>
|
||||
<p class="error" v-if="error">{{ error }}</p>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="order-result" v-if="orderResult">
|
||||
<h4>订单 {{ orderResult.orderId }} 已创建</h4>
|
||||
<!-- Order result -->
|
||||
<section v-if="orderResult" class="mt-4 text-center">
|
||||
<h4 class="text-lg font-bold mb-2">订单 {{ orderResult.orderId }} 已创建</h4>
|
||||
|
||||
<template v-if="!confirmed">
|
||||
<p class="tag">请扫描下方二维码完成付款</p>
|
||||
<img :src="orderResult.qrCodeUrl" alt="下单二维码" />
|
||||
<div class="confirm-area">
|
||||
<p class="tag mb-2">请扫描下方二维码完成付款</p>
|
||||
<img :src="orderResult.qrCodeUrl" alt="下单二维码" class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3" />
|
||||
<div class="flex flex-col items-center gap-2.5 my-4">
|
||||
<button class="primary" :disabled="confirming" @click="doConfirm">
|
||||
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
|
||||
</button>
|
||||
<p class="tag">扫码完成后点击上方按钮获取购买内容</p>
|
||||
</div>
|
||||
<p class="error" v-if="confirmError">{{ confirmError }}</p>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="tag confirmed-badge">订单已完成</p>
|
||||
<div class="result-content" v-if="deliveredCodes.length">
|
||||
<span class="inline-block px-4 py-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold mb-3">订单已完成</span>
|
||||
<div v-if="deliveredCodes.length" class="my-3 flex flex-col items-center gap-2">
|
||||
<p class="tag">购买后内容</p>
|
||||
<textarea class="delivery-box" readonly :value="deliveredCodes.join('\n')"></textarea>
|
||||
<textarea
|
||||
class="w-[min(520px,100%)] min-h-[120px] px-4 py-3.5 rounded-lg border border-white/35 bg-white/90 text-apptext text-base leading-[1.7] resize-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
readonly
|
||||
:value="deliveredCodes.join('\n')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else-if="isManualDelivery" class="manual-delivery-notice">
|
||||
<div v-else-if="isManualDelivery" class="flex items-start gap-3 p-4 bg-[rgba(90,180,120,0.08)] border border-[rgba(90,180,120,0.25)] rounded-lg text-apptext max-w-lg mx-auto text-left">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<div>
|
||||
<p class="manual-delivery-title">等待发货中</p>
|
||||
<p class="text-base font-semibold text-[#3a9a68] mb-1">等待发货中</p>
|
||||
<p class="tag">管理员将尽快处理您的订单并进行发货,请关注您的邮箱或联系方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tag">感谢购买!如有问题请联系售后邮箱。</p>
|
||||
<p class="tag mt-3">感谢购买!如有问题请联系售后邮箱。</p>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else-if="!loading">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag">该商品无法下单,请返回商店页。</p>
|
||||
<div class="detail-actions">
|
||||
<p class="tag mt-2">该商品无法下单,请返回商店页。</p>
|
||||
<div class="flex gap-3 mt-[18px]">
|
||||
<button class="ghost" @click="goBack">返回商品</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="status">加载中...</div>
|
||||
<div class="py-6 text-muted">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -152,12 +158,7 @@ const deliveredCodes = ref([])
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const form = reactive({
|
||||
quantity: 1,
|
||||
note: '',
|
||||
contactPhone: '',
|
||||
contactEmail: ''
|
||||
})
|
||||
const form = reactive({ quantity: 1, note: '', contactPhone: '', contactEmail: '' })
|
||||
const isManualDelivery = ref(false)
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
@@ -171,11 +172,8 @@ const getPayPrice = (p) => {
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
if (!product.value) {
|
||||
return 0
|
||||
}
|
||||
if (!product.value) return 0
|
||||
const qty = form.quantity && form.quantity > 0 ? form.quantity : 1
|
||||
return unitPrice.value * qty
|
||||
})
|
||||
@@ -183,9 +181,7 @@ const totalPrice = computed(() => {
|
||||
const goBack = () => router.push('/')
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
if (!product.value) return
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
orderResult.value = null
|
||||
@@ -211,9 +207,7 @@ const submitOrder = async () => {
|
||||
}
|
||||
|
||||
const doConfirm = async () => {
|
||||
if (!orderResult.value?.orderId) {
|
||||
return
|
||||
}
|
||||
if (!orderResult.value?.orderId) return
|
||||
confirmError.value = ''
|
||||
confirming.value = true
|
||||
try {
|
||||
@@ -237,228 +231,3 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.checkout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 320px;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.checkout-summary {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.checkout-summary img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkout-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-hint a {
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.order-result {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-result img {
|
||||
max-width: 320px;
|
||||
margin: 12px auto;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.confirm-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.confirmed-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delivery-box {
|
||||
width: min(520px, 100%);
|
||||
min-height: 120px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d64848;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.require-login-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.require-login-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-inline {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.limit-hint {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.delivery-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(90, 120, 200, 0.08);
|
||||
border: 1px solid rgba(90, 120, 200, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #5a78c8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.manual-delivery-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(90, 180, 120, 0.08);
|
||||
border: 1px solid rgba(90, 180, 120, 0.25);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.manual-delivery-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #3a9a68;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.checkout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.checkout-summary {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkout-summary img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,82 +1,81 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loading && product">
|
||||
<div class="detail-body">
|
||||
<div class="detail-summary">
|
||||
<div class="flex flex-col gap-[18px]">
|
||||
<!-- Summary -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2>{{ product.name }}</h2>
|
||||
<p class="tag">库存:{{ product.quantity }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="product-price">
|
||||
<span v-if="isFree" class="free-price">免费</span>
|
||||
<span
|
||||
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
|
||||
>
|
||||
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
|
||||
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
|
||||
<span class="font-semibold">¥ {{ product.discountPrice.toFixed(2) }}</span>
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div>
|
||||
<!-- Description (full markdown, not clamped) -->
|
||||
<div
|
||||
class="block text-apptext text-[17px] leading-[1.8] [display:block] [overflow:visible] [-webkit-box-orient:unset] [-webkit-line-clamp:unset] markdown"
|
||||
v-html="renderMarkdown(product.description)"
|
||||
></div>
|
||||
|
||||
<div class="detail-gallery" v-if="galleryImages.length">
|
||||
<div class="detail-gallery-main">
|
||||
<!-- Gallery -->
|
||||
<div v-if="galleryImages.length" class="flex flex-col gap-3.5">
|
||||
<div class="relative overflow-hidden rounded-[10px] border border-white/35 bg-white/45">
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
class="gallery-nav prev"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
|
||||
type="button"
|
||||
aria-label="上一张"
|
||||
@click="prevImage"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
>‹</button>
|
||||
<img
|
||||
class="detail-image"
|
||||
class="w-full h-[360px] object-cover block max-md:h-60"
|
||||
:src="galleryImages[currentImageIndex].url"
|
||||
:alt="galleryImages[currentImageIndex].label"
|
||||
/>
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
class="gallery-nav next"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
|
||||
type="button"
|
||||
aria-label="下一张"
|
||||
@click="nextImage"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
>›</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-gallery-meta">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="badge">{{ galleryImages[currentImageIndex].label }}</span>
|
||||
<span class="tag">{{ currentImageIndex + 1 }} / {{ galleryImages.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-thumbs" v-if="galleryImages.length > 1">
|
||||
<div v-if="galleryImages.length > 1" class="grid gap-2.5" style="grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));">
|
||||
<button
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="`${image.url}-${index}`"
|
||||
class="detail-thumb"
|
||||
:class="{ active: index === currentImageIndex }"
|
||||
class="flex flex-col gap-2 p-2 text-left bg-white/58 border transition max-md:p-1.5"
|
||||
:class="index === currentImageIndex ? 'border-accent2 shadow-[0_10px_24px_rgba(145,168,208,0.16)]' : 'border-transparent'"
|
||||
type="button"
|
||||
@click="currentImageIndex = index"
|
||||
>
|
||||
<img :src="image.url" :alt="image.label" />
|
||||
<span>{{ image.label }}</span>
|
||||
<img :src="image.url" :alt="image.label" class="w-full h-[76px] object-cover rounded-md" />
|
||||
<span class="text-sm text-muted">{{ image.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 mt-[18px] flex-wrap">
|
||||
<button class="buy-button" @click="goCheckout">立即下单</button>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="ghost wishlist-action"
|
||||
:class="{ 'wishlist-active': inWishlist }"
|
||||
class="ghost"
|
||||
:class="inWishlist ? 'text-[#e8826a] border-[rgba(232,130,106,0.35)] bg-[rgba(255,240,235,0.6)]' : 'text-muted'"
|
||||
type="button"
|
||||
@click="onToggleWishlist"
|
||||
>
|
||||
{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}
|
||||
</button>
|
||||
>{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}</button>
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,14 +83,14 @@
|
||||
|
||||
<section class="page-card" v-else-if="!loading && !product">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag">请返回商店查看其他商品。</p>
|
||||
<div class="detail-actions">
|
||||
<p class="tag mt-2">请返回商店查看其他商品。</p>
|
||||
<div class="flex gap-3 mt-[18px]">
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="status">加载中...</div>
|
||||
<div class="py-6 text-muted">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -119,79 +118,43 @@ const getPayPrice = (p) => {
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!product.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!product.value) return []
|
||||
const images = []
|
||||
const seen = new Set()
|
||||
const appendImage = (url, label) => {
|
||||
const trimmed = (url || '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
return
|
||||
}
|
||||
if (!trimmed || seen.has(trimmed)) return
|
||||
seen.add(trimmed)
|
||||
images.push({ url: trimmed, label })
|
||||
}
|
||||
|
||||
appendImage(product.value.coverUrl, '商品封面')
|
||||
|
||||
const screenshots = Array.isArray(product.value.screenshotUrls)
|
||||
? product.value.screenshotUrls
|
||||
: []
|
||||
|
||||
screenshots.forEach((url, index) => {
|
||||
appendImage(url, `商品截图 ${index + 1}`)
|
||||
})
|
||||
|
||||
const screenshots = Array.isArray(product.value.screenshotUrls) ? product.value.screenshotUrls : []
|
||||
screenshots.forEach((url, index) => { appendImage(url, `商品截图 ${index + 1}`) })
|
||||
return images
|
||||
})
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false)
|
||||
const onToggleWishlist = () => {
|
||||
if (product.value) toggleWishlist(product.value.id)
|
||||
}
|
||||
const onToggleWishlist = () => { if (product.value) toggleWishlist(product.value.id) }
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goCheckout = () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
router.push(`/checkout/${product.value.id}`)
|
||||
}
|
||||
const goBack = () => { router.push('/') }
|
||||
const goCheckout = () => { if (!product.value) return; router.push(`/checkout/${product.value.id}`) }
|
||||
|
||||
const prevImage = () => {
|
||||
if (!galleryImages.value.length) {
|
||||
return
|
||||
}
|
||||
currentImageIndex.value =
|
||||
(currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
|
||||
if (!galleryImages.value.length) return
|
||||
currentImageIndex.value = (currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
if (!galleryImages.value.length) {
|
||||
return
|
||||
}
|
||||
if (!galleryImages.value.length) return
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % galleryImages.value.length
|
||||
}
|
||||
|
||||
watch(
|
||||
galleryImages,
|
||||
(images) => {
|
||||
if (!images.length || currentImageIndex.value >= images.length) {
|
||||
currentImageIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(galleryImages, (images) => {
|
||||
if (!images.length || currentImageIndex.value >= images.length) currentImageIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -201,10 +164,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const result = await recordProductView(product.value.id)
|
||||
if (typeof result.viewCount === 'number') {
|
||||
product.value = {
|
||||
...product.value,
|
||||
viewCount: result.viewCount
|
||||
}
|
||||
product.value = { ...product.value, viewCount: result.viewCount }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('record product view failed', error)
|
||||
@@ -215,131 +175,3 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
-webkit-line-clamp: initial;
|
||||
-webkit-box-orient: initial;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-gallery-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 10px 20px rgba(33, 33, 40, 0.12);
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.detail-gallery-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-thumb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.detail-thumb.active {
|
||||
border-color: var(--accent-2);
|
||||
box-shadow: 0 10px 24px rgba(145, 168, 208, 0.16);
|
||||
}
|
||||
|
||||
.detail-thumb img {
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-thumb span {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.wishlist-action {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wishlist-action.wishlist-active {
|
||||
color: #e8826a;
|
||||
border-color: rgba(232, 130, 106, 0.35);
|
||||
background: rgba(255, 240, 235, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-thumb {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="hero">
|
||||
<h2>所有商品</h2>
|
||||
<div class="filters">
|
||||
<!-- Hero / filters row -->
|
||||
<div class="flex items-center gap-4 flex-wrap mb-5 max-md:flex-col max-md:items-start max-md:gap-2.5 max-md:mb-3">
|
||||
<h2 class="text-[26px] max-md:text-xl">所有商品</h2>
|
||||
<div class="flex gap-3 flex-wrap max-md:flex-nowrap max-md:overflow-x-auto max-md:gap-2 max-md:w-full max-md:pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<button
|
||||
v-for="opt in VIEW_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="filter-btn"
|
||||
:class="{ active: viewMode === opt.value }"
|
||||
class="px-3.5 py-2 rounded-full border border-white/35 bg-white/60 text-apptext text-[15px] font-bold transition hover:-translate-y-px max-md:flex-shrink-0 max-md:px-2.5 max-md:py-1.5 max-md:text-[13px]"
|
||||
:class="opt.value === viewMode ? 'bg-gradient-to-br from-accent to-accent2 !border-transparent !text-white shadow-[0_10px_30px_rgba(145,168,208,0.35)]' : ''"
|
||||
type="button"
|
||||
@click="setViewMode(opt.value)"
|
||||
>
|
||||
@@ -16,15 +17,16 @@
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
class="w-[320px] max-w-full rounded-full border border-white/35 bg-white/70 px-3.5 py-2.5 text-[16px] outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)] transition max-md:w-full max-md:text-sm max-md:py-2"
|
||||
style="font-family: var(--font-serif);"
|
||||
type="text"
|
||||
placeholder="搜索商品/标签"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中...</div>
|
||||
<div v-if="loading" class="py-6 text-muted">加载中...</div>
|
||||
<div v-else>
|
||||
<div class="grid">
|
||||
<div class="product-grid">
|
||||
<ProductCard
|
||||
v-for="item in pagedProducts"
|
||||
:key="item.id"
|
||||
@@ -33,12 +35,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button class="ghost pg-btn" :disabled="page === 1" @click="page = 1">首页</button>
|
||||
<button class="ghost pg-btn" :disabled="page === 1" @click="page--">上一页</button>
|
||||
<span class="pg-info">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page++">下一页</button>
|
||||
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page = totalPages">末页</button>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-[22px] flex-wrap">
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page = 1">首页</button>
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page--">上一页</button>
|
||||
<span class="text-sm text-muted px-1.5">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === totalPages" @click="page++">下一页</button>
|
||||
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === totalPages" @click="page = totalPages">末页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -76,7 +78,6 @@ const updatePerPage = () => {
|
||||
|
||||
const getPayPrice = (item) => {
|
||||
if (!item) return 0
|
||||
// 折扣规则:discountPrice > 0 且小于原价时启用,price = 0 时显示"免费"
|
||||
if (item.price === 0) return 0
|
||||
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
|
||||
return item.price
|
||||
@@ -95,38 +96,20 @@ const matchesSearch = (item) => {
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
let list = [...products.value]
|
||||
|
||||
if (viewMode.value === 'free') list = list.filter((p) => isFree(p))
|
||||
|
||||
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
|
||||
|
||||
switch (viewMode.value) {
|
||||
case 'newest':
|
||||
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
break
|
||||
case 'most-sold':
|
||||
list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0))
|
||||
break
|
||||
case 'most-viewed':
|
||||
list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0))
|
||||
break
|
||||
case 'price-high':
|
||||
list.sort((a, b) => getPayPrice(b) - getPayPrice(a))
|
||||
break
|
||||
case 'price-low':
|
||||
list.sort((a, b) => getPayPrice(a) - getPayPrice(b))
|
||||
break
|
||||
default:
|
||||
break
|
||||
case 'newest': list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); break
|
||||
case 'most-sold': list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0)); break
|
||||
case 'most-viewed': list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)); break
|
||||
case 'price-high': list.sort((a, b) => getPayPrice(b) - getPayPrice(a)); break
|
||||
case 'price-low': list.sort((a, b) => getPayPrice(a) - getPayPrice(b)); break
|
||||
default: break
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
|
||||
)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value)))
|
||||
const pagedProducts = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return filteredProducts.value.slice(start, start + perPage.value)
|
||||
@@ -159,134 +142,3 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', updatePerPage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px 14px;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pg-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 14px;
|
||||
border-radius: 999px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.pg-btn:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pg-info {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Filter buttons: scrollable horizontal row, no wrap */
|
||||
.filters {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filters::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['product-link', { 'is-disabled': isSoldOut }]"
|
||||
class="block no-underline text-inherit h-full"
|
||||
:class="isSoldOut ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<article class="product-card">
|
||||
<div class="cover-wrap">
|
||||
<img :src="item.coverUrl" :alt="item.name" />
|
||||
<div v-if="isSoldOut" class="soldout-badge">已售空</div>
|
||||
<div v-else-if="item.requireLogin" class="require-login-badge">需登录</div>
|
||||
<article class="flex flex-col flex-1 gap-3 h-full bg-white/72 border border-white/35 rounded-lg p-[18px] shadow-[0_12px_30px_rgba(33,33,40,0.1)] max-md:p-2.5">
|
||||
<!-- Cover image -->
|
||||
<div class="relative rounded-md overflow-hidden">
|
||||
<img
|
||||
:src="item.coverUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-[140px] object-cover block max-md:h-[100px]"
|
||||
/>
|
||||
<div
|
||||
v-if="isSoldOut"
|
||||
class="absolute inset-0 flex items-center justify-center bg-[rgba(20,18,22,0.52)] backdrop-blur-sm text-white text-[22px] font-semibold tracking-[4px] pointer-events-none"
|
||||
style="font-family: var(--font-serif);"
|
||||
>已售空</div>
|
||||
<div
|
||||
v-else-if="item.requireLogin"
|
||||
class="absolute top-2 right-2 px-2.5 py-0.5 rounded-full bg-accent/85 text-white text-[13px] font-bold pointer-events-none backdrop-blur-sm tracking-[0.5px]"
|
||||
>需登录</div>
|
||||
<button
|
||||
v-if="loggedIn"
|
||||
class="wishlist-btn"
|
||||
:class="{ 'in-wishlist': inWishlist }"
|
||||
class="absolute bottom-2 right-2 p-0 w-auto h-auto border-none bg-transparent shadow-none text-xl leading-none text-white/90 [text-shadow:0_1px_4px_rgba(0,0,0,0.5)] transition-transform hover:scale-125"
|
||||
:class="inWishlist ? 'text-[#ffb347] [text-shadow:0_1px_6px_rgba(255,140,0,0.6)]' : ''"
|
||||
type="button"
|
||||
@click="onWishlistClick"
|
||||
:title="inWishlist ? '取消收藏' : '加入收藏'"
|
||||
>
|
||||
{{ inWishlist ? '★' : '☆' }}
|
||||
</button>
|
||||
>{{ inWishlist ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
<div class="card-top">
|
||||
<h3 class="card-name">{{ item.name }}</h3>
|
||||
<div class="card-price">
|
||||
<span v-if="isFree" class="product-price free-price">免费</span>
|
||||
<span
|
||||
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
|
||||
class="product-price"
|
||||
>
|
||||
<span class="price-original">¥ {{ item.price.toFixed(2) }}</span>
|
||||
<span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span>
|
||||
|
||||
<!-- Name + price -->
|
||||
<div class="flex items-baseline justify-between gap-3 max-md:flex-row max-md:items-start max-md:gap-1">
|
||||
<h3 class="text-[18px] font-bold text-apptext leading-[1.2] max-md:text-sm max-md:leading-[1.3] max-md:flex-1 max-md:min-w-0 max-md:break-all">{{ item.name }}</h3>
|
||||
<div class="flex items-center justify-end max-md:flex-shrink-0 max-md:text-[13px]">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black max-md:text-[13px]">免费</span>
|
||||
<span v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="text-accent2 font-semibold">
|
||||
<span class="line-through text-muted mr-1.5 text-[15px] max-md:text-[11px] max-md:mr-0.5">¥ {{ item.price.toFixed(2) }}</span>
|
||||
¥ {{ item.discountPrice.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
|
||||
<span v-else class="text-accent2 font-semibold">¥ {{ item.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-mid">
|
||||
<div class="markdown" v-html="renderedDescription"></div>
|
||||
<!-- Description -->
|
||||
<div class="flex-1 flex items-center justify-center text-center">
|
||||
<div
|
||||
class="markdown text-center text-[15px] text-muted leading-relaxed line-clamp-3 overflow-hidden max-md:text-xs max-md:line-clamp-2"
|
||||
v-html="renderedDescription"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<div v-if="item.tags && item.tags.length" class="tag-row">
|
||||
<span v-for="tag in item.tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||
<!-- Tags + meta -->
|
||||
<div class="flex flex-col gap-1.5 items-start max-md:gap-1 max-md:text-xs">
|
||||
<div v-if="item.tags && item.tags.length" class="flex flex-wrap gap-2 mt-2 max-md:gap-1 max-md:mt-1">
|
||||
<span
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
class="px-2.5 py-1 rounded-full text-sm font-bold text-accent2 bg-[rgba(145,168,208,0.08)] border border-[rgba(145,168,208,0.18)] max-md:text-[11px] max-md:px-1.5 max-md:py-0.5"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<div class="tag">库存:{{ item.quantity }}</div>
|
||||
<div class="tag">浏览量:{{ item.viewCount || 0 }}</div>
|
||||
<div class="flex gap-2.5 items-center flex-wrap">
|
||||
<div class="text-sm text-muted max-md:text-[11px]">库存:{{ item.quantity }}</div>
|
||||
<div class="text-sm text-muted max-md:text-[11px]">浏览量:{{ item.viewCount || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -86,257 +105,3 @@ const isFree = computed(() => payPrice.value === 0)
|
||||
const isSoldOut = computed(() => props.item && props.item.quantity === 0)
|
||||
const renderedDescription = computed(() => md.render(props.item.description || ''))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-link.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-mid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-mid .markdown {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-wrap {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-wrap img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.soldout-badge {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(20, 18, 22, 0.52);
|
||||
backdrop-filter: blur(3px);
|
||||
color: #fff;
|
||||
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.require-login-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(180, 154, 203, 0.85);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wishlist-btn {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
transition: color 0.2s ease, transform 0.15s ease;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wishlist-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.wishlist-btn.in-wishlist {
|
||||
color: #ffb347;
|
||||
text-shadow: 0 1px 6px rgba(255, 140, 0, 0.6);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-2);
|
||||
background: rgba(145, 168, 208, 0.08);
|
||||
border: 1px solid rgba(145, 168, 208, 0.18);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
margin-right: 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.price-discount {
|
||||
color: var(--accent-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cover-wrap img {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
/* Name + price on one line, compressed */
|
||||
.card-top {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
font-size: 11px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.free-price {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-mid {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-mid .markdown {
|
||||
font-size: 12px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,104 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loggedIn">
|
||||
<div class="auth-prompt">
|
||||
<div class="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<h2>请先登录</h2>
|
||||
<p class="tag">登录萌芽账号后即可查看你的历史订单记录。</p>
|
||||
<a class="login-btn" :href="loginUrl">使用萌芽账号登录</a>
|
||||
<a
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] hover:-translate-y-px transition-transform"
|
||||
:href="loginUrl"
|
||||
>使用萌芽账号登录</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="orders-header">
|
||||
<div class="header-info">
|
||||
<h2>我的订单</h2>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-3.5 mb-[22px] flex-wrap max-md:gap-2.5">
|
||||
<div>
|
||||
<h2 class="text-[22px] mb-1 max-md:text-lg">我的订单</h2>
|
||||
<p class="tag">
|
||||
{{ authState.username || authState.account }}
|
||||
<span v-if="orders.length" class="order-count">共 {{ orders.length }} 笔</span>
|
||||
<span
|
||||
v-if="orders.length"
|
||||
class="ml-1.5 text-[13px] text-muted bg-[rgba(180,154,203,0.12)] px-2 py-0.5 rounded-full"
|
||||
>共 {{ orders.length }} 笔</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="orders-actions">
|
||||
<button class="ghost small-act" @click="refresh">
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="ghost inline-flex items-center gap-1.5 text-[13px] px-3 py-1.5"
|
||||
@click="refresh"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
<button class="ghost small-act" @click="goHome">← 返回商店</button>
|
||||
<button class="ghost inline-flex items-center gap-1.5 text-[13px] px-3 py-1.5" @click="goHome">← 返回商店</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中…</div>
|
||||
<div v-if="loading" class="py-6 text-muted text-sm">加载中…</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="empty-state">
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<div v-else-if="orders.length === 0" class="flex flex-col items-center gap-2.5 py-12 text-muted text-sm">
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-30"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<p>暂无订单记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="orders-list">
|
||||
<div v-for="order in orders" :key="order.id" class="order-card">
|
||||
<div v-else class="flex flex-col gap-3.5">
|
||||
<div
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="rounded-[10px] bg-white/72 border border-white/35 overflow-hidden transition-shadow hover:shadow-[0_4px_16px_rgba(0,0,0,0.08)]"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="order-card-header">
|
||||
<div class="order-product-name">{{ order.productName }}</div>
|
||||
<div class="order-status-badge" :class="order.deliveryMode === 'manual' && !order.deliveredCodes?.length ? 'badge-pending' : 'badge-done'">
|
||||
<div class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
|
||||
<div class="text-base font-bold text-apptext flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap max-md:text-[15px]">
|
||||
{{ order.productName }}
|
||||
</div>
|
||||
<div
|
||||
class="px-2.5 py-0.5 rounded-full text-xs font-semibold flex-shrink-0"
|
||||
:class="order.deliveryMode === 'manual' && !order.deliveredCodes?.length
|
||||
? 'bg-[rgba(145,168,208,0.15)] text-[#5a7db0]'
|
||||
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
|
||||
>
|
||||
{{ order.deliveryMode === 'manual' && !order.deliveredCodes?.length ? '等待发货' : '已完成' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="order-meta-row">
|
||||
<span class="meta-item">
|
||||
<div class="flex items-center gap-3.5 px-4 pb-3 flex-wrap border-b border-white/35 max-md:gap-2.5">
|
||||
<span class="inline-flex items-center gap-1 text-[13px] text-muted">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ formatTime(order.createdAt) }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="inline-flex items-center gap-1 text-[13px] text-muted">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V22H4V12"/><path d="M22 7H2v5h20V7z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
|
||||
× {{ order.quantity }} 件
|
||||
</span>
|
||||
<span class="order-id-tag">{{ order.id.slice(0, 8) }}…</span>
|
||||
<span class="text-[11px] text-muted font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">{{ order.id.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
|
||||
<!-- Delivered codes -->
|
||||
<div class="order-codes" v-if="order.deliveredCodes?.length">
|
||||
<div class="codes-label">
|
||||
<div v-if="order.deliveredCodes?.length" class="px-4 py-3">
|
||||
<div class="flex items-center gap-1.5 text-xs font-semibold text-muted uppercase tracking-[0.5px] mb-2">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
发货内容
|
||||
</div>
|
||||
<div class="codes-list">
|
||||
<div v-for="(code, i) in order.deliveredCodes" :key="i" class="code-item">
|
||||
<span class="code-index">{{ i + 1 }}</span>
|
||||
<span class="code-text">{{ code }}</span>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(code, i) in order.deliveredCodes"
|
||||
:key="i"
|
||||
class="flex items-baseline gap-2.5 px-3 py-2 bg-white/90 border border-white/35 rounded-md"
|
||||
>
|
||||
<span class="text-[11px] text-muted font-bold min-w-[16px] flex-shrink-0">{{ i + 1 }}</span>
|
||||
<span class="text-sm text-apptext break-all font-mono leading-relaxed">{{ code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual delivery pending -->
|
||||
<div class="pending-notice" v-else-if="order.deliveryMode === 'manual'">
|
||||
<div
|
||||
v-else-if="order.deliveryMode === 'manual'"
|
||||
class="flex items-center gap-2 mx-4 my-3 px-3.5 py-2.5 bg-[rgba(145,168,208,0.1)] rounded-md border-l-[3px] border-accent2 text-[13px] text-[#5a7db0]"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
订单已提交,等待人工发货,请耐心等候
|
||||
</div>
|
||||
@@ -98,16 +126,8 @@ const formatTime = (isoStr) => {
|
||||
if (!isoStr) return ''
|
||||
try {
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return isoStr
|
||||
}
|
||||
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return isoStr }
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -123,243 +143,6 @@ const refresh = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoggedIn()) {
|
||||
refresh()
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
if (isLoggedIn()) { refresh() } else { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Auth prompt ── */
|
||||
.auth-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.orders-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.order-count {
|
||||
margin-left: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
background: rgba(180, 154, 203, 0.12);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.orders-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.small-act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* ── Empty & loading ── */
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 48px 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Order list ── */
|
||||
.orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Order card ── */
|
||||
.order-card {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.order-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.order-product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-status-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-done {
|
||||
background: rgba(100, 185, 140, 0.15);
|
||||
color: #3a9a68;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: rgba(145, 168, 208, 0.15);
|
||||
color: #5a7db0;
|
||||
}
|
||||
|
||||
/* Meta row */
|
||||
.order-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 16px 12px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.order-id-tag {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
background: rgba(0,0,0,0.04);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Codes */
|
||||
.order-codes {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.codes-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.codes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.code-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.code-index {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Pending notice */
|
||||
.pending-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 16px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(145, 168, 208, 0.1);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--accent-2);
|
||||
font-size: 13px;
|
||||
color: #5a7db0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.orders-header {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.order-product-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.order-meta-row {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="hero">
|
||||
<div class="flex items-baseline gap-3 mb-5">
|
||||
<h2>我的收藏夹</h2>
|
||||
<p class="tag">共 {{ wishlistItems.length }} 件商品</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中...</div>
|
||||
<div v-if="loading" class="py-6 text-muted">加载中...</div>
|
||||
|
||||
<div v-else-if="wishlistItems.length === 0" class="empty">
|
||||
<div class="empty-icon">☆</div>
|
||||
<p class="empty-title">收藏夹是空的</p>
|
||||
<div v-else-if="wishlistItems.length === 0" class="flex flex-col items-center gap-3 py-[60px] text-center">
|
||||
<div class="text-[52px] text-muted opacity-40 leading-none">☆</div>
|
||||
<p class="text-xl font-semibold text-apptext">收藏夹是空的</p>
|
||||
<p class="tag">在商品卡片上点击 ☆ 即可加入收藏</p>
|
||||
<button class="ghost" @click="$router.push('/')">去逛逛</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<div v-else class="product-grid">
|
||||
<ProductCard
|
||||
v-for="item in wishlistItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:class="{ 'card-dimmed': !item.active || item.quantity === 0 }"
|
||||
:class="{ 'opacity-50': !item.active || item.quantity === 0 }"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -61,48 +61,3 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 52px;
|
||||
color: var(--muted);
|
||||
opacity: 0.4;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-dimmed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
# 萌芽账户认证中心 API 文档
|
||||
|
||||
接入地址:
|
||||
- 统一登录前端:`https://auth.shumengya.top`
|
||||
- 后端 API:`https://auth.api.shumengya.top`
|
||||
- 本地开发 API:`http://<host>:8080`
|
||||
|
||||
对外接入建议:
|
||||
1. 第三方应用按钮跳转到统一登录前端。
|
||||
2. 登录成功后回跳到业务站点。
|
||||
3. 业务站点使用回跳带回的 `token` 调用后端 API。
|
||||
|
||||
示例按钮:
|
||||
```html
|
||||
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
|
||||
使用萌芽统一账户认证登录
|
||||
</a>
|
||||
```
|
||||
|
||||
回跳说明:
|
||||
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
|
||||
- 登录成功后会回跳到 `redirect_uri`,并在 URL `#fragment` 中带上 `token`、`account`、`username`、`state`。
|
||||
- 第三方应用建议先调用 `GET /api/auth/verify` 或 `GET /api/auth/me` 校验令牌。
|
||||
|
||||
## 认证与统一登录
|
||||
|
||||
### 登录获取统一令牌
|
||||
`POST /api/auth/login`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token",
|
||||
"expiresAt": "2026-03-14T12:00:00Z",
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00Z",
|
||||
"updatedAt": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 校验令牌
|
||||
`POST /api/auth/verify`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 获取当前用户信息
|
||||
`GET /api/auth/me`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." },
|
||||
"checkIn": {
|
||||
"rewardCoins": 1,
|
||||
"checkedInToday": false,
|
||||
"lastCheckInDate": "",
|
||||
"lastCheckInAt": "",
|
||||
"today": "2026-03-14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
### 每日签到
|
||||
`POST /api/auth/check-in`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"checkedIn": true,
|
||||
"alreadyCheckedIn": false,
|
||||
"rewardCoins": 1,
|
||||
"awardedCoins": 1,
|
||||
"message": "签到成功",
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 更新当前用户资料
|
||||
`PUT /api/auth/profile`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"bio": "### 新简介"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 用户广场
|
||||
|
||||
### 获取用户公开主页
|
||||
`GET /api/public/users/{account}`
|
||||
|
||||
说明:
|
||||
- 仅支持账户名 `account`,不支持昵称查询。
|
||||
- 适合第三方应用展示用户公开资料。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 3,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册账号(发送邮箱验证码)
|
||||
`POST /api/auth/register`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证邮箱并完成注册
|
||||
`POST /api/auth/verify-email`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 忘记密码(发送重置验证码)
|
||||
`POST /api/auth/forgot-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"email": "demo@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
`POST /api/auth/reset-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456",
|
||||
"newPassword": "newpass"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "reset": true }
|
||||
```
|
||||
|
||||
### 申请添加辅助邮箱(发送验证码)
|
||||
`POST /api/auth/secondary-email/request`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证辅助邮箱
|
||||
`POST /api/auth/secondary-email/verify`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 管理端接口(需要管理员 Token)
|
||||
|
||||
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
|
||||
请求时可使用以下任一方式携带:
|
||||
- Query:`?token=<admin-token>`
|
||||
- Header:`X-Admin-Token: <admin-token>`
|
||||
|
||||
### 签到奖励设置
|
||||
`GET /api/admin/check-in/config`
|
||||
|
||||
`PUT /api/admin/check-in/config`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"rewardCoins": 1
|
||||
}
|
||||
```
|
||||
- Header:`Authorization: Bearer <admin-token>`
|
||||
|
||||
### 获取用户列表
|
||||
`GET /api/admin/users`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"users": [{ "account": "demo", "...": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
### 新建用户
|
||||
`POST /api/admin/users`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新用户
|
||||
`PUT /api/admin/users/{account}`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"level": 1,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"sproutCoins": 99
|
||||
}
|
||||
```
|
||||
|
||||
### 删除用户
|
||||
`DELETE /api/admin/users/{account}`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "deleted": true }
|
||||
```
|
||||
|
||||
## 数据存储说明
|
||||
|
||||
- 用户数据:`data/users/*.json`
|
||||
- 注册待验证:`data/pending/*.json`
|
||||
- 密码重置记录:`data/reset/*.json`
|
||||
- 辅助邮箱验证:`data/secondary/*.json`
|
||||
- 管理员 Token:`data/config/admin.json`
|
||||
- JWT 配置:`data/config/auth.json`
|
||||
- 邮件配置:`data/config/email.json`
|
||||
|
||||
## 快速联调用示例
|
||||
|
||||
```bash
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"demo","password":"demo123"}'
|
||||
|
||||
# 使用令牌获取用户信息
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H 'Authorization: Bearer <jwt-token>'
|
||||
```
|
||||
18
萌芽小店项目经历.md
18
萌芽小店项目经历.md
@@ -1,12 +1,12 @@
|
||||
项目名称:萌芽小店项目-支持网关登录商品售卖平台
|
||||
时间:2026.1 - 2026.03
|
||||
技术栈:Golang,Gin,MySQL,Redis,Vue
|
||||
描述:前后端分离的轻量级商城平台,支持登录、商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服聊天、维护模式与 PWA 离线缓存。
|
||||
商品与订单管理、发货与订单状态联动、SMTP 邮件通知、后台运维(商品/订单/对话/站点设置)、用户聊天客服与维护模式能力。
|
||||
通过限购与订单状态约束降低并发超卖风险;通过公开/用户/管理员分层实现权限边界隔离;通过分层架构与可运维配置提升可维护性。
|
||||
后端采用分层解耦与 ORM 自动建表,支持多环境配置与容器化部署;前端路由守卫与接口封装增强可用性与一致性。
|
||||
上线地址:https://store.shumengya.top
|
||||
开源地址:https://github.com/shumengya/mengyastore
|
||||
**项目名称:** 萌芽小店-网关登录商品售卖平台
|
||||
**时间:** 2026.1 - 2026.03
|
||||
**技术栈:** Golang,Gin,MySQL,Redis,Docker,Nginx,Vue,Vite,Tailwind CSS
|
||||
**描述:** 前后端分离的轻量级商城,支持商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服消息、维护模式与 PWA 离线缓存;后台覆盖商品/订单/对话与 SMTP、站点运维。后端分层(Handler + Storage + GORM),对接第三方网关登录与多环境配置、容器化部署;公开接口、用户态与管理员令牌分层鉴权,限购与订单状态约束降低并发超卖风险。引入 Redis:商品列表与站点统计走 Cache-Aside、管理端写后失效,浏览去重用 SET NX 适配多实例,缓存不可用时降级直查数据库;管理后台提供 API/MySQL/Redis 连接与基础信息面板,便于排查。前端路由守卫与接口封装统一调用方式。
|
||||
**项目地址:** https://store.smyhub.com | **开源地址:** https://github.com/shumengya/mengyastore
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
655
萌芽账户认证中心-第三方应用API接入文档.md
Normal file
655
萌芽账户认证中心-第三方应用API接入文档.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# 萌芽账户认证中心 API 文档
|
||||
|
||||
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health`、`routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 **`GET /api/docs`**;完整 HTTP 清单以 **`sproutgate-backend/main.go`** 与本文件、**`sproutgate-backend/后端文档.md`** 为准。
|
||||
|
||||
接入地址:
|
||||
- 统一登录前端:`https://auth.shumengya.top`
|
||||
- 后端 API:`https://auth.api.shumengya.top`
|
||||
- 本地开发 API:`http://<host>:8080`
|
||||
|
||||
对外接入建议:
|
||||
1. 第三方「登录」按钮跳转到统一登录前端的 **授权入口**(推荐 `.../authorize?...`,见下节)。
|
||||
2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。
|
||||
3. 业务站点使用回跳 URL **`#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify` 或 `GET /api/auth/me` 校验并建立会话。
|
||||
|
||||
**推荐入口 URL(生产):** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`。
|
||||
|
||||
```html
|
||||
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123&client_id=my-app&client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
|
||||
使用萌芽统一账户认证登录
|
||||
</a>
|
||||
```
|
||||
|
||||
说明:
|
||||
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向** 到 **`/authorize`**(保留同一套 query),以使用独立授权确认页。
|
||||
- `client_id` / `client_name` 会由浏览器写入 session,并在用户登录时提交给后端,用于累计 **应用接入记录**(详见下文「应用接入记录」)。
|
||||
|
||||
回跳说明:
|
||||
- 在 **`/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied` 等。
|
||||
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
|
||||
- 第三方拿到 `token` 后,建议服务端调用 **`POST /api/auth/verify`**(不更新「最后访问」等侧写,适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。
|
||||
|
||||
### 统一登录前端:查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
|
||||
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
|
||||
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
|
||||
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
|
||||
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |
|
||||
| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 |
|
||||
|
||||
### 回跳 URL:`#` 哈希参数
|
||||
|
||||
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
|
||||
| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 |
|
||||
| `account` | 账户名(与 JWT `sub` 一致)。 |
|
||||
| `username` | 展示用昵称,可能为空。 |
|
||||
| `state` | 若登录请求携带了 `state`,则原样返回。 |
|
||||
|
||||
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
|
||||
|
||||
### 第三方后端接入建议
|
||||
|
||||
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`(JSON body:`{"token":"..."}`),根据 `valid` 与 `user.account` 建立会话。
|
||||
2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
|
||||
3. **令牌无效或封禁**:`verify` / `me` 返回 **401**、`verify` 因封禁返回 **403**(`valid: false`)时,应作废本地会话并引导用户重新走统一登录。
|
||||
|
||||
## 应用接入记录(authClients)
|
||||
|
||||
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 **`client_id`**,并展示展示名(若有)、**首次接入时间**、**最近接入时间**(均为 RFC3339)。持久化在 **MySQL `users` 表**中对应用户的 **`auth_clients` JSON 列**;对外 API 字段名仍为 **`authClients`**(与历史 JSON 文件存储时的形状一致)。
|
||||
|
||||
示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie` 为 `client_id`。
|
||||
|
||||
### `client_id` 与名称规则
|
||||
|
||||
- **`client_id`(必填才能记一条)**:长度 1–64;必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
|
||||
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`。
|
||||
|
||||
### 在哪些接口上累计记录
|
||||
|
||||
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 **`lastSeenAt`**;已存在同一 `client_id` 时更新 **`lastSeenAt`**,必要时更新 **`displayName`**,**不改动** **`firstSeenAt`**。
|
||||
|
||||
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
|
||||
|------|----------------|--------------------------------|
|
||||
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 **`client_id`**、**`client_name`**(见上表)。前端写入 session,登录请求 `POST /api/auth/login` 的 JSON 中会带 **`clientId`** / **`clientName`**。 |
|
||||
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON **`clientId`**、**`clientName`**(可选,规则同上)。 |
|
||||
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 **`X-Auth-Client`**(必须为合法 id)、**`X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
|
||||
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:**`X-Auth-Client`**、**`X-Auth-Client-Name`**,与 **`Authorization: Bearer`** 同时使用。 |
|
||||
|
||||
说明:
|
||||
|
||||
- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`,**含** `authClients`(若本次写入了新记录)。
|
||||
- **C**:`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user` 为 **`Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
|
||||
- **D**:`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user` 为 **`OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
|
||||
|
||||
**封禁用户**:`POST /api/auth/verify` 与 `GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。
|
||||
|
||||
**浏览器直连 API**:若前端或网关需带上 `X-Auth-Client`,请确认 CORS 允许该自定义头(本服务 CORS 已包含 `X-Auth-Client`、`X-Auth-Client-Name`)。
|
||||
|
||||
### 存储结构(`authClients` 数组元素)
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "infogenie",
|
||||
"displayName": "万象口袋",
|
||||
"firstSeenAt": "2026-03-22T16:33:24+08:00",
|
||||
"lastSeenAt": "2026-03-30T19:22:45+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 认证与统一登录
|
||||
|
||||
### 登录获取统一令牌
|
||||
`POST /api/auth/login`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"clientId": "my-app",
|
||||
"clientName": "我的应用"
|
||||
}
|
||||
```
|
||||
|
||||
`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见上文 **「应用接入记录(authClients)」**)。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token",
|
||||
"expiresAt": "2026-03-14T12:00:00Z",
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00Z",
|
||||
"updatedAt": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
`banReason` 可能为空字符串或省略。
|
||||
|
||||
**常见 HTTP 状态码(登录)**
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 200 | 成功,返回 `token`、`expiresAt`、`user`。 |
|
||||
| 400 | 请求体非法或缺少 `account` / `password`。 |
|
||||
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
|
||||
| 403 | 账户已封禁(见上文 JSON)。 |
|
||||
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
|
||||
|
||||
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
|
||||
|
||||
### 校验令牌
|
||||
`POST /api/auth/verify`
|
||||
|
||||
请求头:**`Content-Type: application/json`**。可选 **`X-Auth-Client`**、**`X-Auth-Client-Name`**(累计应用接入记录,见专章)。
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
若账户已封禁,返回 **403**,正文示例(不返回 `user`):
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
令牌过期、签名错误、issuer 不匹配、`token` 被撤销(`token_epoch`)等失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}` 或 `{"valid": false, "error": "token revoked"}`(以服务端实际字段为准)。
|
||||
|
||||
`verify` 与 `me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。
|
||||
|
||||
**应用接入记录**:在 **`verify` / `me`** 上使用 **`X-Auth-Client`**、**`X-Auth-Client-Name`** 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录(authClients)」**。
|
||||
|
||||
### 获取当前用户信息
|
||||
`GET /api/auth/me`
|
||||
|
||||
请求头:
|
||||
- **`Authorization: Bearer <jwt-token>`**(必填)
|
||||
|
||||
可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录(authClients)」**):
|
||||
- **`X-Auth-Client`**、**`X-Auth-Client-Name`**
|
||||
|
||||
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」):
|
||||
- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可)
|
||||
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`)
|
||||
|
||||
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 **`GEO_LOOKUP_URL`** 指定的基址(默认与 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 **`?ip=`**)解析展示位置并写入用户记录。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." },
|
||||
"checkIn": {
|
||||
"rewardCoins": 1,
|
||||
"checkedInToday": false,
|
||||
"lastCheckInDate": "",
|
||||
"lastCheckInAt": "",
|
||||
"today": "2026-03-14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
|
||||
|
||||
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**`POST /api/auth/verify` 返回的 `user`(`Public()`)中不包含这两项**(避免第三方校验响应携带访问隐私)。**`GET /api/public/users/:account` 的 `user`(`PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
|
||||
|
||||
### 每日签到
|
||||
`POST /api/auth/check-in`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"checkedIn": true,
|
||||
"alreadyCheckedIn": false,
|
||||
"rewardCoins": 1,
|
||||
"awardedCoins": 1,
|
||||
"message": "签到成功",
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 更新当前用户资料
|
||||
`PUT /api/auth/profile`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 新简介"
|
||||
}
|
||||
```
|
||||
|
||||
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 用户广场
|
||||
|
||||
### 获取公开用户目录
|
||||
`GET /api/public/users`
|
||||
|
||||
无需鉴权。返回未封禁用户的简要列表,默认按 **`createdAt`**(注册时间)升序。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 2,
|
||||
"users": [
|
||||
{
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 获取用户公开主页
|
||||
`GET /api/public/users/{account}`
|
||||
|
||||
说明:
|
||||
- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。
|
||||
- 适合第三方应用展示用户公开资料。
|
||||
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
|
||||
- 响应体在 `user` 之外包含累计赞数 **`profileLikeCount`**。
|
||||
- **`Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday`、`viewerLikesRemainingToday`、`profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`。
|
||||
- `user` 中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation`);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
|
||||
|
||||
响应示例(未带 Bearer):
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 3,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"lastVisitIp": "203.0.113.1",
|
||||
"lastVisitDisplayLocation": "中国 广东省 深圳市",
|
||||
"bio": "### 简介"
|
||||
},
|
||||
"profileLikeCount": 12
|
||||
}
|
||||
```
|
||||
|
||||
### 公开主页点赞
|
||||
`POST /api/public/users/{account}/like`
|
||||
|
||||
请求头:**`Authorization: Bearer <jwt-token>`**(必填)。
|
||||
|
||||
为路径中 **`account`** 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
|
||||
|
||||
- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**。
|
||||
- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday`、`profileLikeDailyMax`)。
|
||||
- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。
|
||||
|
||||
成功响应示例:
|
||||
```json
|
||||
{
|
||||
"profileLikeCount": 13,
|
||||
"viewerHasLikedToday": true,
|
||||
"viewerLikesRemainingToday": 4,
|
||||
"profileLikeDailyMax": 5
|
||||
}
|
||||
```
|
||||
|
||||
### 公开注册策略
|
||||
`GET /api/public/registration-policy`
|
||||
|
||||
无需鉴权。用于前端判断是否展示「邀请码」输入框。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"requireInviteCode": false
|
||||
}
|
||||
```
|
||||
|
||||
当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
|
||||
|
||||
### 注册账号(发送邮箱验证码)
|
||||
`POST /api/auth/register`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"inviteCode": "ABCD1234"
|
||||
}
|
||||
```
|
||||
|
||||
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证邮箱并完成注册
|
||||
`POST /api/auth/verify-email`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 忘记密码(发送重置验证码)
|
||||
`POST /api/auth/forgot-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"email": "demo@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
`POST /api/auth/reset-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456",
|
||||
"newPassword": "newpass"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "reset": true }
|
||||
```
|
||||
|
||||
### 申请添加辅助邮箱(发送验证码)
|
||||
`POST /api/auth/secondary-email/request`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证辅助邮箱
|
||||
`POST /api/auth/secondary-email/verify`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 管理端接口(需要管理员 Token)
|
||||
|
||||
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
|
||||
请求时可使用以下任一方式携带:
|
||||
- Query:`?token=<admin-token>`
|
||||
- Header:`X-Admin-Token: <admin-token>`
|
||||
|
||||
### 签到奖励设置
|
||||
`GET /api/admin/check-in/config`
|
||||
|
||||
`PUT /api/admin/check-in/config`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"rewardCoins": 1
|
||||
}
|
||||
```
|
||||
- Header:`Authorization: Bearer <admin-token>`
|
||||
|
||||
### 注册策略与邀请码
|
||||
|
||||
`GET /api/admin/registration`
|
||||
|
||||
响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。
|
||||
|
||||
`PUT /api/admin/registration`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "requireInviteCode": true }
|
||||
```
|
||||
|
||||
`POST /api/admin/registration/invites`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"note": "内测批次",
|
||||
"maxUses": 10,
|
||||
"expiresAt": "2026-12-31T15:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。
|
||||
|
||||
`DELETE /api/admin/registration/invites/{code}`
|
||||
|
||||
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
|
||||
|
||||
### 获取用户列表
|
||||
`GET /api/admin/users`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"users": [{ "account": "demo", "...": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
### 新建用户
|
||||
`POST /api/admin/users`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新用户
|
||||
`PUT /api/admin/users/{account}`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"level": 1,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"sproutCoins": 99,
|
||||
"websiteUrl": "https://example.com",
|
||||
"banned": true,
|
||||
"banReason": "违规说明(最多 500 字)"
|
||||
}
|
||||
```
|
||||
|
||||
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
|
||||
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于数据库用户记录)。
|
||||
|
||||
管理员列表 `GET /api/admin/users` 中每条 `user` 为 `OwnerPublic()`,可含 `banned`、`banReason`、`bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准。
|
||||
|
||||
### 删除用户
|
||||
`DELETE /api/admin/users/{account}`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "deleted": true }
|
||||
```
|
||||
|
||||
## 数据存储说明
|
||||
|
||||
当前发行版**以 MySQL 为唯一运行时数据源**(GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:
|
||||
|
||||
| 表名 | 用途 |
|
||||
|------|------|
|
||||
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
|
||||
| `pending_users` | 注册邮箱待验证 |
|
||||
| `password_resets` | 找回密码验证码 |
|
||||
| `secondary_email_verifications` | 辅助邮箱验证 |
|
||||
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
|
||||
| `invite_codes` | 邀请码 |
|
||||
| `profile_likes` | 公开主页点赞记录 |
|
||||
| `profile_like_daily_quota` | 点赞每日额度辅助 |
|
||||
|
||||
从旧版 **`sproutgate-backend/data/`** 目录迁移时,使用 **`go run ./cmd/migrate --data-dir ./data`**(详见 **`sproutgate-backend/后端文档.md`**)。
|
||||
|
||||
## 快速联调用示例
|
||||
|
||||
```bash
|
||||
# 服务根路径 JSON 说明
|
||||
curl -s http://localhost:8080/ | jq .
|
||||
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"demo","password":"demo123"}'
|
||||
|
||||
# 校验令牌(推荐第三方网关先调此接口)
|
||||
curl -X POST http://localhost:8080/api/auth/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Auth-Client: my-app' \
|
||||
-d '{"token":"<jwt-token>"}'
|
||||
|
||||
# 同上,并携带应用展示名(累计应用接入记录)
|
||||
curl -X POST http://localhost:8080/api/auth/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Auth-Client: infogenie' \
|
||||
-H 'X-Auth-Client-Name: 万象口袋' \
|
||||
-d '{"token":"<jwt-token>"}'
|
||||
|
||||
# 使用令牌获取用户信息(会更新访问记录);可一并携带 X-Auth-Client 刷新「应用接入」最近时间
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H 'Authorization: Bearer <jwt-token>' \
|
||||
-H 'X-Auth-Client: infogenie' \
|
||||
-H 'X-Auth-Client-Name: 万象口袋'
|
||||
```
|
||||
Reference in New Issue
Block a user