Compare commits
43
Commits
f29d8e053a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3eb870da | ||
|
|
f1e639e0ba | ||
|
|
7e302c51a6 | ||
|
|
e6e4bdc71c | ||
|
|
baf14897aa | ||
|
|
8ff9878e91 | ||
|
|
1a43199ca0 | ||
|
|
84f88cb473 | ||
|
|
c6eb0bbe5b | ||
|
|
165f87d086 | ||
|
|
2674bea223 | ||
|
|
ce07f4a347 | ||
|
|
c2d5e9db89 | ||
|
|
3eb0aab788 | ||
|
|
f7669badf2 | ||
|
|
6e6196d68b | ||
|
|
23771f0661 | ||
|
|
44a24736aa | ||
|
|
55d898cd42 | ||
|
|
ab96426489 | ||
|
|
c84a383e5c | ||
|
|
f7a2e83727 | ||
|
|
b93fe9847c | ||
|
|
a74299d716 | ||
|
|
da0333e09c | ||
|
|
1db1698174 | ||
|
|
a234afe448 | ||
|
|
661d157b6c | ||
|
|
232abf2019 | ||
|
|
6108aca34c | ||
|
|
b6342a622d | ||
|
|
baf14ec6ca | ||
|
|
9149f672e7 | ||
|
|
1a2db5ddac | ||
|
|
0929f880e3 | ||
|
|
8e9d98e261 | ||
|
|
cfd6948987 | ||
|
|
740d47bbf6 | ||
|
|
08250b58ef | ||
|
|
34f0faedcc | ||
|
|
bc1b0ccce3 | ||
|
|
5dfa1550fb | ||
|
|
75737034d9 |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
AccessKey=
|
||||
SecretAccessKey=
|
||||
S3Api="https://<account_id>.r2.cloudflarestorage.com"
|
||||
PublicURL=
|
||||
Bucket=
|
||||
|
||||
# GitHub OAuth(评论登录):https://github.com/settings/developers
|
||||
# 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名)
|
||||
ONE_GITHUB_CLIENT_ID=
|
||||
ONE_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Telegram 登录(Login Widget):@BotFather 建 bot 拿 token,
|
||||
# 再用 /setdomain 把域名登记给 bot(本地开发填 localhost)
|
||||
ONE_TELEGRAM_BOT=
|
||||
ONE_TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Google 登录:https://console.cloud.google.com/apis/credentials 建 OAuth 客户端,
|
||||
# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名)
|
||||
ONE_GOOGLE_CLIENT_ID=
|
||||
ONE_GOOGLE_CLIENT_SECRET=
|
||||
|
||||
ONE_SECRET=# 任意字符串,用于加密
|
||||
@@ -12,6 +12,10 @@ data/
|
||||
# 开发进程日志 / pid
|
||||
.run/
|
||||
|
||||
# 其他 agent 工具的本地草稿(计划、忽略清单),不进仓库
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
|
||||
# 二进制
|
||||
one-server
|
||||
backend/one-server
|
||||
|
||||
+29
-2
@@ -1,21 +1,48 @@
|
||||
module oneblog
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
golang.org/x/text v0.30.0
|
||||
modernc.org/sqlite v1.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+72
-8
@@ -1,5 +1,55 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 h1:n6kO3OlBvnDEksQpvBLbAldjHwGlu8kErvhHJkhlaRY=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -10,21 +60,35 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 账户页的后端:站主资料、身份绑定列表、passkey 管理。
|
||||
//
|
||||
// 和「站点设置」的分工:站点设置管站点(标题、皮肤、评论开关),账户页管
|
||||
// 「你是谁 + 你能用什么方式登录」。昵称/简介仍是 author_name/author_bio
|
||||
// 两个 settings 键(单一来源,前端各处照旧读),头像另用 owner_avatar_key
|
||||
// 单键写入,避开 UpdateSettings 的全量替换。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
const maxAvatarKeyLen = 160
|
||||
|
||||
// accountView 是账户页一次拉取的全部数据。
|
||||
type accountView struct {
|
||||
Name string `json:"name"`
|
||||
Bio string `json:"bio"`
|
||||
AvatarKey string `json:"avatar_key"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Handle string `json:"handle"`
|
||||
Password passwordInfo `json:"password"`
|
||||
Identities []model.UserIdentity `json:"identities"`
|
||||
Passkeys []model.Passkey `json:"passkeys"`
|
||||
// Providers 告诉前端哪些平台可以绑(未配凭据的平台不出现)。
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
|
||||
type passwordInfo struct {
|
||||
// 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
|
||||
// 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
|
||||
ManagedBy string `json:"managed_by"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
v, err := a.buildAccount()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, v)
|
||||
}
|
||||
|
||||
func (a *API) buildAccount() (accountView, error) {
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
ids, err := a.Store.ListIdentities(owner.ID)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
pks, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
v := accountView{
|
||||
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", Username: a.Cfg.AdminUser},
|
||||
Identities: ids,
|
||||
Passkeys: pks,
|
||||
}
|
||||
// 可绑定的平台:只有跳转式 OAuth 能在后台发起。Telegram 是评论区里的
|
||||
// 登录 widget,后台没有它的入口,所以不进这个列表(已绑的记录仍会显示)。
|
||||
for _, p := range []string{"github", "google"} {
|
||||
if a.providerEnabled(p) {
|
||||
v.Providers = append(v.Providers, p)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
|
||||
// 否则点了必然报错。
|
||||
func (a *API) providerEnabled(name string) bool {
|
||||
switch name {
|
||||
case "github":
|
||||
return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
|
||||
case "google":
|
||||
return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
|
||||
case "telegram":
|
||||
return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// avatarURL 把 files key 解析成可访问 URL。key 指向的文件已删除时返回空串
|
||||
// (前端会自动回落到站标),不留一个打不开的链接。
|
||||
func (a *API) avatarURL(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
}
|
||||
|
||||
type patchAccountRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarKey *string `json:"avatar_key"`
|
||||
}
|
||||
|
||||
// patchAccount 改资料。只动传了的字段;头像 key 必须是 files 表里真实存在的
|
||||
// 图片,免得存一个指向任意字符串的死链。
|
||||
func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var in patchAccountRequest
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if len([]rune(name)) > 40 {
|
||||
httpx.BadRequest(w, "昵称最多 40 字")
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
httpx.BadRequest(w, "昵称不能为空")
|
||||
return
|
||||
}
|
||||
// 站主行与站点设置两处都要写:前者是身份来源,后者是既有前端读的地方。
|
||||
if _, err := a.Store.UpdateProfile(owner.ID, name); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
st.AuthorName = name
|
||||
changed = true
|
||||
}
|
||||
if in.Bio != nil {
|
||||
bio := strings.TrimSpace(*in.Bio)
|
||||
if len([]rune(bio)) > 200 {
|
||||
httpx.BadRequest(w, "简介最多 200 字")
|
||||
return
|
||||
}
|
||||
st.AuthorBio = bio
|
||||
changed = true
|
||||
}
|
||||
if in.AvatarKey != nil {
|
||||
key := strings.TrimSpace(*in.AvatarKey)
|
||||
if len(key) > maxAvatarKeyLen {
|
||||
httpx.BadRequest(w, "头像 key 过长")
|
||||
return
|
||||
}
|
||||
if key != "" {
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.BadRequest(w, "头像文件不存在,请重新上传")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(f.Mime, "image/") {
|
||||
httpx.BadRequest(w, "头像必须是图片")
|
||||
return
|
||||
}
|
||||
}
|
||||
st.AuthorAvatarKey = key
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
httpx.BadRequest(w, "没有要更新的字段")
|
||||
return
|
||||
}
|
||||
// AuthorAvatarURL 是算出来的,不入库;写库前清掉免得误读。
|
||||
st.AuthorAvatarURL = ""
|
||||
if err := a.Store.UpdateSettings(st); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 头像键单独写:UpdateSettings 是全量替换,不含这个键。
|
||||
if in.AvatarKey != nil {
|
||||
if err := a.Store.SetSetting("owner_avatar_key", st.AuthorAvatarKey); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := a.buildAccount()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, v)
|
||||
}
|
||||
|
||||
// unbindIdentity 解绑一个第三方登录方式。
|
||||
// 站主始终有环境变量密码兜底,所以这里不需要「不能解绑唯一登录方式」的护栏。
|
||||
func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/identities/"), "/")
|
||||
if provider == "" || strings.Contains(provider, "/") {
|
||||
httpx.BadRequest(w, "bad provider")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.UnbindIdentity(owner.ID, provider); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// doAs 带着有效后台会话发一个请求。
|
||||
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
|
||||
sess := NewSessions("test-secret", time.Hour)
|
||||
tok, _ := sess.Issue("admin")
|
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAccountGET(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
Handle string `json:"handle"`
|
||||
Password struct{} `json:"password"`
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Handle != "admin" {
|
||||
t.Fatalf("handle=%q", v.Handle)
|
||||
}
|
||||
// 测试配置里没有 OAuth 凭据,可绑平台应为空
|
||||
if len(v.Providers) != 0 {
|
||||
t.Fatalf("providers=%v, want empty", v.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountPATCHProfile(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name)
|
||||
owner, err := a.Store.GetOwner()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner.Name != "麻衣" {
|
||||
t.Fatalf("owner.name=%q", owner.Name)
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" {
|
||||
t.Fatalf("settings 未同步: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
|
||||
}
|
||||
// 真实存在但不是图片的也要拒
|
||||
f, err := a.Store.CreateFile(model.File{
|
||||
Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain",
|
||||
Size: 4, SHA256: strings.Repeat("a", 64), Store: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非图片: got %d, want 400", rec.Code)
|
||||
}
|
||||
// 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换)
|
||||
img, err := a.Store.CreateFile(model.File{
|
||||
Key: "2026/09/me.png", Name: "me.png", Mime: "image/png",
|
||||
Size: 4, SHA256: strings.Repeat("b", 64), Store: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
st, _ := a.Store.GetSettings()
|
||||
if st.AuthorAvatarKey != img.Key {
|
||||
t.Fatalf("avatar key=%q", st.AuthorAvatarKey)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) {
|
||||
t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String())
|
||||
}
|
||||
// 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开)
|
||||
if err := a.Store.UpdateSettings(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := a.Store.GetSettings()
|
||||
if after.AuthorAvatarKey != img.Key {
|
||||
t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountRejectsAnonymous(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s: got %d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountUnbindUnknown(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// passkey 未配置时必须明确不可用,而不是假装成功
|
||||
func TestPasskeysUnavailableWhenNil(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if a.Passkeys != nil {
|
||||
t.Skip("测试构造里不该有 Passkeys")
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,50 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/hub"
|
||||
"oneblog/internal/linkmeta"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Sessions *Sessions
|
||||
// Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
|
||||
// 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
|
||||
Hub *hub.Hub
|
||||
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
|
||||
Blobs storage.BlobStore
|
||||
PublicBase string
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
|
||||
// 缩略图产物,否则已删图片会一直占着缓存。
|
||||
Thumbs *thumbs.Store
|
||||
// Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503)
|
||||
Passkeys *auth.Passkeys
|
||||
|
||||
loginOnce sync.Once
|
||||
logins *loginLimiter
|
||||
@@ -56,7 +84,44 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID))
|
||||
mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
|
||||
mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
|
||||
mux.HandleFunc("/api/admin/files", a.guard(a.files))
|
||||
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
|
||||
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
|
||||
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
|
||||
// 账户页:资料 + 身份绑定 + passkey
|
||||
mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.account(w, r)
|
||||
case http.MethodPatch, http.MethodPut:
|
||||
a.patchAccount(w, r)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PATCH required")
|
||||
}
|
||||
}))
|
||||
mux.HandleFunc("/api/admin/account/identities/", a.guard(a.unbindIdentity))
|
||||
mux.HandleFunc("/api/admin/account/passkeys", a.guard(a.listPasskeys))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/begin", a.guard(a.beginPasskey))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/finish", a.guard(a.finishPasskey))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/", a.guard(a.deletePasskey))
|
||||
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
|
||||
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
|
||||
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
|
||||
mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 路径形如 /api/admin/readers/{id}/ban
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[1] != "ban" {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(w, "bad reader id")
|
||||
return
|
||||
}
|
||||
a.adminReaderBan(w, r, id)
|
||||
}))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -104,7 +169,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
key := sourceKey(r)
|
||||
if a.limiter().blocked(key) {
|
||||
if a.limiter().Blocked(key) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
|
||||
return
|
||||
}
|
||||
@@ -116,11 +181,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
a.limiter().fail(key)
|
||||
a.limiter().Add(key)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
a.limiter().reset(key)
|
||||
a.limiter().Reset(key)
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
@@ -184,6 +249,8 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
in.Status = store.NormalizeStatus(in.Status)
|
||||
attachLinkCard(r.Context(), &in, nil)
|
||||
autoMeta(&in, nil)
|
||||
p, err := a.Store.Create(in)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
@@ -223,6 +290,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
|
||||
if in.Status != "" {
|
||||
in.Status = store.NormalizeStatus(in.Status)
|
||||
}
|
||||
// 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动
|
||||
// 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住
|
||||
var curCard *model.LinkCard
|
||||
var curPost *model.Post
|
||||
if cur, err := a.Store.Get(id); err == nil {
|
||||
curCard = cur.LinkCard
|
||||
curPost = &cur
|
||||
}
|
||||
attachLinkCard(r.Context(), &in, curCard)
|
||||
autoMeta(&in, curPost)
|
||||
p, err := a.Store.Update(id, in)
|
||||
writeOne(w, p, err)
|
||||
case http.MethodDelete:
|
||||
@@ -545,3 +622,496 @@ func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "action must be one of publish|draft|delete")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- files(上传与文件管理) ----------
|
||||
|
||||
// 单文件上限与类型白名单。SVG 拒绝:同源内联可执行脚本。
|
||||
const (
|
||||
maxFileUpload = 50 << 20
|
||||
)
|
||||
|
||||
var allowFileExt = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
".pdf": "application/pdf",
|
||||
".zip": "application/zip",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
func (a *API) files(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
fp, err := a.Store.ListFiles(httpx.QueryInt(r, "page", 1), httpx.QueryInt(r, "size", 20), httpx.QueryString(r, "q"))
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range fp.Items {
|
||||
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase)
|
||||
}
|
||||
httpx.OK(w, fp)
|
||||
case http.MethodPost:
|
||||
a.uploadFiles(w, r)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
|
||||
// 64MB = 50MB 文件 + multipart 编码开销
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFileUpload+(8<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
httpx.BadRequest(w, "上传失败:请求体超过上限(单文件 50MB)")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
fhs := r.MultipartForm.File["file"]
|
||||
if len(fhs) == 0 {
|
||||
httpx.BadRequest(w, "没有收到文件")
|
||||
return
|
||||
}
|
||||
out := make([]model.File, 0, len(fhs))
|
||||
for _, fh := range fhs {
|
||||
f, err := a.storeOne(r.Context(), fh)
|
||||
if err != nil {
|
||||
if bu, ok := err.(badUpload); ok {
|
||||
httpx.BadRequest(w, string(bu))
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
|
||||
for i := range out {
|
||||
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase)
|
||||
}
|
||||
httpx.Created(w, out)
|
||||
}
|
||||
|
||||
// badUpload 区分「文件本身的问题」(类型 / 大小 → 400 直接告诉站主)与
|
||||
// 服务器故障(500,已入库的部分保留)。
|
||||
type badUpload string
|
||||
|
||||
func (e badUpload) Error() string { return string(e) }
|
||||
|
||||
// storeOne 校验、哈希、落盘单个文件。内容哈希做 key(同年月分目录),
|
||||
// 同内容重复上传直接复用已有行,不产生孤儿对象。
|
||||
func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.File, error) {
|
||||
if fh.Size > maxFileUpload {
|
||||
return model.File{}, badUpload(fmt.Sprintf("%s:超过单文件 50MB 上限", fh.Filename))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(fh.Filename))
|
||||
mime, ok := allowFileExt[ext]
|
||||
if !ok {
|
||||
return model.File{}, badUpload(fmt.Sprintf("%s:不支持的类型 %q", fh.Filename, ext))
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 头 512 字节给 http.DetectContentType 嗅探真实类型,全文流过 sha256,
|
||||
// 同时落到临时文件(S3 PutObject 需要确定的 ContentLength)。
|
||||
tmp, err := os.CreateTemp("", "one-upload-*")
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
hasher := sha256.New()
|
||||
head := make([]byte, 512)
|
||||
hn, _ := io.ReadFull(src, head)
|
||||
head = head[:hn]
|
||||
for _, w := range []io.Writer{tmp, hasher} {
|
||||
if _, err := w.Write(head); err != nil {
|
||||
tmp.Close()
|
||||
return model.File{}, err
|
||||
}
|
||||
}
|
||||
copied, err := io.Copy(io.MultiWriter(tmp, hasher), src)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return model.File{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
size := int64(hn) + copied
|
||||
|
||||
// 嗅探结果必须与扩展名声明的类型完全一致(防改后缀绕过白名单)。
|
||||
// 不给 octet-stream 留口子:一张「image/png」嗅探出 octet-stream
|
||||
// 就绝不是 PNG——随机字节改名上传就是这么溜进来的。
|
||||
detected := strings.SplitN(http.DetectContentType(head), ";", 2)[0]
|
||||
if detected != mime {
|
||||
return model.File{}, badUpload(fmt.Sprintf("%s:文件内容与扩展名不符", fh.Filename))
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(hasher.Sum(nil))
|
||||
return a.persistFile(ctx, fh.Filename, ext, mime, tmp.Name(), size, sum)
|
||||
}
|
||||
|
||||
// persistFile 落库共享段:内容哈希做 key(同年月分目录)、去重复用、
|
||||
// Put 对象存储、建行。storeOne(本地上传)与 importOne(外链转存)共用。
|
||||
func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string, size int64, sum string) (model.File, error) {
|
||||
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
|
||||
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
|
||||
// 数据库必须记录同样的完整 key,直链才不会 404
|
||||
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" {
|
||||
key = p + "/" + key
|
||||
}
|
||||
|
||||
// 内容去重:同一份内容只存一份,复用已有行。
|
||||
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
|
||||
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
|
||||
if exist, err := a.Store.GetFileByKey(key); err == nil {
|
||||
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
|
||||
return exist, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
|
||||
created, err := a.Store.CreateFile(model.File{
|
||||
Key: key,
|
||||
Name: name,
|
||||
Mime: mime,
|
||||
Size: size,
|
||||
SHA256: sum,
|
||||
Store: a.Cfg.StorageDriver,
|
||||
})
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// mimeToExt 是 allowFileExt 的反向映射:外链转存时内容嗅探出 mime,
|
||||
// 反推扩展名(URL 本身可能不带后缀或后缀不可信)。
|
||||
var mimeToExt = func() map[string]string {
|
||||
m := make(map[string]string, len(allowFileExt))
|
||||
for ext, mime := range allowFileExt {
|
||||
m[mime] = ext
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// importFiles 外链转存:POST /api/admin/files/import {"urls": [...]}。
|
||||
// 站主把别处的图片贴进正文后一键搬进自己的存储——与手动上传同一套
|
||||
// 白名单、内容嗅探与内容去重;抓取走 linkmeta 的 SSRF 防护拨号。
|
||||
func (a *API) importFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if len(in.URLs) == 0 || len(in.URLs) > 20 {
|
||||
httpx.BadRequest(w, "urls 需要1-20个")
|
||||
return
|
||||
}
|
||||
// files 带上 source(原址),前端按它做正文替换
|
||||
type imported struct {
|
||||
Source string `json:"source"`
|
||||
File model.File `json:"file"`
|
||||
}
|
||||
files := make([]imported, 0, len(in.URLs))
|
||||
errs := map[string]string{}
|
||||
for _, u := range in.URLs {
|
||||
f, err := a.importOne(r.Context(), u)
|
||||
if err != nil {
|
||||
if bu, ok := err.(badUpload); ok {
|
||||
errs[u] = string(bu)
|
||||
} else {
|
||||
errs[u] = err.Error()
|
||||
}
|
||||
continue
|
||||
}
|
||||
files = append(files, imported{Source: u, File: f})
|
||||
}
|
||||
httpx.OK(w, map[string]any{"files": files, "errors": errs})
|
||||
}
|
||||
|
||||
func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
data, ct, err := linkmeta.FetchBytes(cctx, rawURL, maxFileUpload)
|
||||
if err != nil {
|
||||
return model.File{}, fmt.Errorf("抓取失败:%w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return model.File{}, badUpload("空内容")
|
||||
}
|
||||
// 类型必须落在本站白名单里:嗅探优先(不信响应头,更不信 URL 后缀)
|
||||
detected := strings.SplitN(http.DetectContentType(data[:512]), ";", 2)[0]
|
||||
ext, ok := mimeToExt[detected]
|
||||
if !ok {
|
||||
return model.File{}, badUpload("不支持的类型 " + detected)
|
||||
}
|
||||
_ = ct
|
||||
// 落临时文件:persistFile / S3 PutObject 都要确定的文件与长度
|
||||
tmp, err := os.CreateTemp("", "one-import-*")
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
size, err := tmp.Write(data)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return model.File{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
// 名字取 URL 路径末段(仅用于文件管理页展示,不参与存储路径)
|
||||
name := rawURL
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
|
||||
if base := path.Base(u.Path); base != "" && base != "/" && base != "." {
|
||||
name = base
|
||||
}
|
||||
}
|
||||
return a.persistFile(ctx, name, ext, detected, tmp.Name(), int64(size), hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")
|
||||
idPart, sub := rest, ""
|
||||
if i := strings.Index(rest, "/"); i >= 0 {
|
||||
idPart, sub = rest[:i], rest[i+1:]
|
||||
}
|
||||
// /files/{id} 与 /files/{id}/refs 两种形态,别的一律不收
|
||||
if sub != "" && (sub != "refs" || r.Method != http.MethodGet) {
|
||||
httpx.BadRequest(w, "bad file path")
|
||||
return
|
||||
}
|
||||
id, err := parseInt(idPart)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad file id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if sub == "refs" {
|
||||
a.fileRefs(w, r, id)
|
||||
return
|
||||
}
|
||||
f, err := a.Store.GetFile(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
httpx.OK(w, f)
|
||||
case http.MethodDelete:
|
||||
f, err := a.Store.GetFile(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 正文/封面/项目/站主头像里还在用就先挡住,除非明确带 force=1。
|
||||
// 检查放在删对象之前:一旦 blob 删了就没法回头。
|
||||
if refs, err := a.Store.FileReferences(f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
} else if len(refs) > 0 && httpx.QueryString(r, "force") != "1" {
|
||||
httpx.Error(w, http.StatusConflict, fileInUseMessage(refs))
|
||||
return
|
||||
}
|
||||
// 先删对象存储再删行:存储端失败时行保留,可以重试
|
||||
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.DeleteFile(id); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if a.Thumbs != nil {
|
||||
a.Thumbs.Purge(f.SHA256)
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
// fileRefs 报告这个文件被谁引用,供前端在删除弹层里列出来。
|
||||
func (a *API) fileRefs(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
f, err := a.Store.GetFile(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
refs, err := a.Store.FileReferences(f.Key)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"key": f.Key, "count": len(refs), "items": refs})
|
||||
}
|
||||
|
||||
// fileInUseMessage 是挡住删除时回给调用方的一句话,只列前三个引用。
|
||||
func fileInUseMessage(refs []model.FileRef) string {
|
||||
names := make([]string, 0, 3)
|
||||
for _, x := range refs {
|
||||
if x.Kind == "avatar" {
|
||||
names = append(names, "站主头像")
|
||||
continue
|
||||
}
|
||||
t := x.Title
|
||||
if t == "" {
|
||||
t = x.Slug
|
||||
}
|
||||
names = append(names, "《"+t+"》")
|
||||
if len(names) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
label := strings.Join(names, "、")
|
||||
if len(refs) > len(names) {
|
||||
label += " 等"
|
||||
}
|
||||
return fmt.Sprintf("该文件正被 %d 处引用:%s,删除后这些位置会失效(确认要删带 force=1)", len(refs), label)
|
||||
}
|
||||
|
||||
// ---------- comments(评论审核与管理) ----------
|
||||
|
||||
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
|
||||
status := httpx.QueryString(r, "status")
|
||||
page := httpx.QueryInt(r, "page", 1)
|
||||
size := httpx.QueryInt(r, "size", 20)
|
||||
fp, err := a.Store.ListCommentsAdmin(status, page, size)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, fp)
|
||||
}
|
||||
|
||||
func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/")
|
||||
id, err := parseInt(rest)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad comment id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
// 审核动作:{"status": "visible" | "pending"}
|
||||
var in struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.Status != "visible" && in.Status != "pending" {
|
||||
httpx.BadRequest(w, "status 只支持 visible / pending")
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetCommentStatus(id, in.Status); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if in.Status == "visible" && a.Hub != nil {
|
||||
if c, err := a.Store.GetComment(id); err == nil {
|
||||
a.Hub.Broadcast(c.PostID)
|
||||
}
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
case http.MethodDelete:
|
||||
// 后台删除同样走软删(墓碑保楼层)
|
||||
if err := a.Store.DeleteComment(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if a.Hub != nil {
|
||||
if c, err := a.Store.GetComment(id); err == nil {
|
||||
a.Hub.Broadcast(c.PostID)
|
||||
}
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- readers(评论用户与禁言) ----------
|
||||
|
||||
func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
readers, err := a.Store.ListReaders()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"readers": readers})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
var in struct {
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
// 站主身份(管理员会话映射出来的读者)不允许禁言
|
||||
if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" {
|
||||
httpx.BadRequest(w, "不能禁言站主身份")
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。
|
||||
func seed(t *testing.T, a *API, key, body string) model.File {
|
||||
t.Helper()
|
||||
if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := a.Store.CreateFile(model.File{
|
||||
Key: key, Name: filepath.Base(key), Mime: "image/png",
|
||||
Size: 8, SHA256: strings.Repeat("f", 64), Store: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "" {
|
||||
if _, err := a.Store.Create(model.PostInput{
|
||||
Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestFileRefsEndpoint(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/aaa.png", `看图 `)
|
||||
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
|
||||
if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
Key string `json:"key"`
|
||||
Count int `json:"count"`
|
||||
Items []model.FileRef `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 {
|
||||
t.Fatalf("refs 不对: %+v", v)
|
||||
}
|
||||
var kinds = map[string]bool{}
|
||||
for _, x := range v.Items {
|
||||
kinds[x.Kind] = true
|
||||
}
|
||||
if !kinds["post"] || !kinds["avatar"] {
|
||||
t.Fatalf("缺引用类型: %+v", v.Items)
|
||||
}
|
||||
|
||||
// 不存在的文件:404 而不是空列表
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
// 乱七八糟的子路径不收
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
|
||||
func TestFileDeleteBlockedByRefs(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/bbb.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(e.Error, "1 处引用") {
|
||||
t.Fatalf("错误消息没报引用数: %q", e.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil {
|
||||
t.Fatalf("被挡住时不该动存储: %v", err)
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); err != nil {
|
||||
t.Fatalf("被挡住时不该删行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 明确带 force=1 才真删:行、blob 一起走。
|
||||
func TestFileDeleteForceProceeds(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/ccc.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("行应已删除, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) {
|
||||
t.Fatalf("blob 应已删除, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
|
||||
func TestFileDeleteUnreferenced(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/ddd.png", "")
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("行应已删除, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/ratelimit"
|
||||
)
|
||||
|
||||
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
|
||||
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
// 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留
|
||||
// 原有调用面与常量。
|
||||
type loginLimiter = ratelimit.Window
|
||||
|
||||
const (
|
||||
maxLoginFails = 10
|
||||
@@ -20,55 +19,10 @@ const (
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{hits: make(map[string][]time.Time)}
|
||||
return ratelimit.New(maxLoginFails, loginWindow)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= maxLoginFails
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.recent(key, now), now)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
|
||||
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
|
||||
hs := l.hits[key]
|
||||
cut := now.Add(-loginWindow)
|
||||
i := 0
|
||||
for ; i < len(hs); i++ {
|
||||
if hs[i].After(cut) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > 0 {
|
||||
hs = hs[i:]
|
||||
l.hits[key] = hs
|
||||
}
|
||||
if len(hs) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
func sourceKey(r *http.Request) string {
|
||||
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) }
|
||||
|
||||
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
|
||||
func isTLS(r *http.Request) bool {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/linkmeta"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// linkFetchBudget 是给外链抓取的时间预算:发布不该被一个慢站点拖住,
|
||||
// 超时就当作「这篇没有卡片」。
|
||||
const linkFetchBudget = 2500 * time.Millisecond
|
||||
|
||||
// linkFetch 是要不要抓远端的注入点:默认走带 SSRF 防护的真实抓取,
|
||||
// 测试里换成假实现(真实抓取在测试环境会被自己的防护拦掉)。
|
||||
var linkFetch = linkmeta.Fetch
|
||||
|
||||
// attachLinkCard 按正文重算短文链接卡片,结果写进 in.LinkCard。
|
||||
//
|
||||
// 只处理短文(长文在时间线已有封面媒体位,再加卡片会抢位置);
|
||||
// 链接没变就沿用旧卡片,不重复抓远端;正文里删掉链接、或抓取失败,
|
||||
// 一律传空卡片把旧的清掉——宁可没有卡片,也不要挂着别的地址抓来的旧数据。
|
||||
func attachLinkCard(ctx context.Context, in *model.PostInput, cur *model.LinkCard) {
|
||||
if in.Kind != model.KindShort || in.ContentMd == "" {
|
||||
return
|
||||
}
|
||||
u := linkmeta.FirstURL(in.ContentMd)
|
||||
if u == "" {
|
||||
in.LinkCard = &model.LinkCard{}
|
||||
return
|
||||
}
|
||||
if cur != nil && cur.URL == u {
|
||||
in.LinkCard = cur
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, linkFetchBudget)
|
||||
defer cancel()
|
||||
card, err := linkFetch(cctx, u)
|
||||
if err != nil {
|
||||
in.LinkCard = &model.LinkCard{}
|
||||
return
|
||||
}
|
||||
in.LinkCard = card
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func TestAttachLinkCard(t *testing.T) {
|
||||
old := linkFetch
|
||||
t.Cleanup(func() { linkFetch = old })
|
||||
|
||||
t.Run("长文不动", func(t *testing.T) {
|
||||
called := false
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindLong, ContentMd: "看 https://a.cn"}
|
||||
attachLinkCard(context.Background(), in, nil)
|
||||
if in.LinkCard != nil || called {
|
||||
t.Fatalf("长文不该抓取,card=%+v called=%v", in.LinkCard, called)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没链接则清空", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
t.Fatal("没链接不该抓")
|
||||
return nil, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "纯文本一句"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://old.cn", Title: "旧"})
|
||||
if in.LinkCard == nil || !in.LinkCard.Empty() {
|
||||
t.Fatalf("应传空卡片清掉旧的,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("链接未变则沿用不重抓", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
t.Fatal("同一个链接不该重复抓")
|
||||
return nil, nil
|
||||
}
|
||||
cur := &model.LinkCard{URL: "https://a.cn", Title: "已抓过"}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "看 https://a.cn"}
|
||||
attachLinkCard(context.Background(), in, cur)
|
||||
if in.LinkCard != cur {
|
||||
t.Fatalf("应沿用旧卡片,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("新链接则抓取", func(t *testing.T) {
|
||||
var gotURL string
|
||||
linkFetch = func(_ context.Context, u string) (*model.LinkCard, error) {
|
||||
gotURL = u
|
||||
return &model.LinkCard{URL: u, Title: "新卡片"}, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "换成 https://b.cn/x。"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
|
||||
if gotURL != "https://b.cn/x" {
|
||||
t.Fatalf("结尾标点没剪掉: %q", gotURL)
|
||||
}
|
||||
if in.LinkCard == nil || in.LinkCard.Title != "新卡片" {
|
||||
t.Fatalf("card=%+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("抓取失败则清空", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
return nil, errors.New("timeout")
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "https://slow.cn"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
|
||||
if in.LinkCard == nil || !in.LinkCard.Empty() {
|
||||
t.Fatalf("失败应清空,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkCardRoundTrip(t *testing.T) {
|
||||
a, _ := newTestAPI(t)
|
||||
card := &model.LinkCard{URL: "https://example.cn/x", Title: "示例标题", Desc: "描述", Site: "example", Image: "https://cdn/i.png"}
|
||||
p, err := a.Store.Create(model.PostInput{Kind: model.KindShort, ContentMd: "看 https://example.cn/x",
|
||||
Status: model.StatusPublished, LinkCard: card})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.LinkCard == nil || p.LinkCard.Title != "示例标题" || p.LinkCard.Image != "https://cdn/i.png" {
|
||||
t.Fatalf("创建后没读回卡片: %+v", p.LinkCard)
|
||||
}
|
||||
// 列表接口也要带上(时间线靠它渲染)
|
||||
page, err := a.Store.List(store.ListOptions{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].LinkCard == nil {
|
||||
t.Fatalf("列表未带卡片: %+v", page.Items)
|
||||
}
|
||||
// 空卡片 = 清空
|
||||
if _, err := a.Store.Update(p.ID, model.PostInput{LinkCard: &model.LinkCard{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := a.Store.Get(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LinkCard != nil {
|
||||
t.Fatalf("应已清空,got %+v", got.LinkCard)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 长文的自动摘要与自动封面:站主没填这两项时从正文派生——
|
||||
// 摘要取正文开头的一段纯文本,封面取正文第一张图。
|
||||
// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
const autoSummaryLen = 110
|
||||
|
||||
var (
|
||||
mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`)
|
||||
mdCodeFence = regexp.MustCompile("(?s)```.*?```")
|
||||
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复)
|
||||
mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`)
|
||||
mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "")
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// autoMeta 按需填充 in.Summary / in.CoverURL。
|
||||
// cur 为更新前的旧文章(新建时传 nil)。
|
||||
func autoMeta(in *model.PostInput, cur *model.Post) {
|
||||
if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") {
|
||||
in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen)
|
||||
}
|
||||
if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") {
|
||||
in.CoverURL = firstImage(in.ContentMd)
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本
|
||||
func summarizeMarkdown(md string, maxRunes int) string {
|
||||
s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要
|
||||
s = mdHeadingRe.ReplaceAllString(s, " ")
|
||||
s = mdImageRe.ReplaceAllString(s, " ")
|
||||
s = mdLinkRe.ReplaceAllString(s, "$1")
|
||||
s = mdNoiseRe.ReplaceAllString(s, " ")
|
||||
s = mdEmphasis.Replace(s)
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
s = wsRe.ReplaceAllString(s, " ")
|
||||
s = strings.TrimSpace(s)
|
||||
runes := []rune(s)
|
||||
if len(runes) > maxRunes {
|
||||
return string(runes[:maxRunes]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可)
|
||||
func firstImage(md string) string {
|
||||
m := mdImageRe.FindStringSubmatch(md)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
u := m[1]
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func TestSummarizeMarkdown(t *testing.T) {
|
||||
md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n\n\n```go\ncode ignored\n```\n\n后续内容"
|
||||
got := summarizeMarkdown(md, 110)
|
||||
for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("summary 含语法残留 %q: %q", bad, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") {
|
||||
t.Errorf("summary 应以正文开头并保留链接文字: %q", got)
|
||||
}
|
||||
long := strings.Repeat("长", 200)
|
||||
if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstImage(t *testing.T) {
|
||||
md := "前言\n\n\n\n"
|
||||
if got := firstImage(md); got != "https://a.com/1.png" {
|
||||
t.Errorf("firstImage = %q", got)
|
||||
}
|
||||
if got := firstImage("没有图片"); got != "" {
|
||||
t.Errorf("无图应返回空, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMeta(t *testing.T) {
|
||||
in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 "}
|
||||
autoMeta(&in, nil)
|
||||
if in.Summary == "" || in.CoverURL != "https://a.com/x.png" {
|
||||
t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL)
|
||||
}
|
||||
// 站主已填的不能覆盖
|
||||
in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""}
|
||||
autoMeta(&in2, nil)
|
||||
if in2.Summary != "手写的" {
|
||||
t.Errorf("手写摘要不应被覆盖: %q", in2.Summary)
|
||||
}
|
||||
// 更新路径:新值与旧值都空才补——主动清空的保得住
|
||||
cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""}
|
||||
cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"}
|
||||
autoMeta(&cleared, &cur)
|
||||
if cleared.Summary != "" || cleared.CoverURL != "" {
|
||||
t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL)
|
||||
}
|
||||
// 新旧都空 → 生成
|
||||
never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 "}
|
||||
autoMeta(&never, &model.Post{})
|
||||
if never.CoverURL != "/uploads/a.png" {
|
||||
t.Errorf("新旧都空应取首图: %q", never.CoverURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Passkey 的管理端点:列出、注册(两步)、删除。
|
||||
//
|
||||
// 全部在 guard 之后 —— 注册凭据等于发放永久登录方式,必须已是管理员。
|
||||
// 删光 passkey 也不会把自已锁死:站主密码走环境变量,不在这张表里。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
// passkeyName 是站主给这把凭据起的名字(「MacBook 指纹」「iPhone」)。
|
||||
type passkeyNameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
list, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"passkeys": list})
|
||||
}
|
||||
|
||||
func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
existing, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
creation, token, err := a.Passkeys.BeginRegistration(owner.ID, owner.Handle, owner.Name, existing)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"options": creation, "token": token})
|
||||
}
|
||||
|
||||
func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
|
||||
httpx.BadRequest(w, "token 与 credential 都要传")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if len([]rune(name)) > 40 {
|
||||
httpx.BadRequest(w, "名称最多 40 字")
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "未命名设备"
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
existing, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
pk, err := a.Passkeys.FinishRegistration(in.Token, owner.ID, owner.Handle, owner.Name, existing, in.Credential)
|
||||
if errors.Is(err, auth.ErrSessionExpired) {
|
||||
httpx.Error(w, http.StatusGone, "注册已过期,请重新开始")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "passkey 校验失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
pk.Name = name
|
||||
created, err := a.Store.AddPasskey(pk)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 公钥不回传:前端不需要,少一处能误用的字段
|
||||
created.PublicKey = ""
|
||||
httpx.Created(w, created)
|
||||
}
|
||||
|
||||
func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "DELETE required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/passkeys/"), "/"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(w, "bad passkey id")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeletePasskey(id, owner.ID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 账户相关的公开端点:第三方身份绑定、passkey 登录。
|
||||
//
|
||||
// 绑定复用登录的 OAuth 跳转,只在发起时多打一个一次性 cookie 表明意图;
|
||||
// 回调拿到身份后统一走 afterIdentity 分流,免得三个 provider 各写一遍判断。
|
||||
//
|
||||
// 分流规则(顺序即优先级):
|
||||
// 1. 带绑定意图 + 当前是有效管理员会话 → 把该外部身份绑到站主账号,回账户页
|
||||
// 2. 该外部身份已绑到某账号 → 站主发后台会话,读者发读者会话
|
||||
// 3. 谁都不认识 → 按老路建/更新读者档案,发读者会话
|
||||
//
|
||||
// 第 1 步必须校验管理员会话:否则任何人都能跑一遍自己的 OAuth 流程,
|
||||
// 把身份塞进别人的账号上。
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
const oauthBindCook = "one_oauth_bind"
|
||||
|
||||
// startOAuth 备好 state(防 CSRF)与回跳地址,然后跳到 provider 授权页。
|
||||
// authorize 拿到 state 拼出最终授权 URL —— state 必须在这里生成,
|
||||
// 又要出现在 URL 里,所以用回调而不是先算好传进来。
|
||||
func (a *API) startOAuth(w http.ResponseWriter, r *http.Request, authorize func(state string) string) {
|
||||
state := randHex(16)
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
|
||||
HttpOnly: true, MaxAge: 600})
|
||||
// 记下发起登录的前台 origin:开发时前端 :3000、后端 :8080 分离,
|
||||
// 回调只有靠它才知道该跳回哪儿。
|
||||
if ref := r.Referer(); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
|
||||
Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, authorize(state), http.StatusFound)
|
||||
}
|
||||
|
||||
// beginBind 发起绑定。只有已登录的后台管理员能发起,且平台必须已配置。
|
||||
func (a *API) beginBind(w http.ResponseWriter, r *http.Request) {
|
||||
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/auth/"), "/")
|
||||
provider = strings.TrimSuffix(provider, "/bind")
|
||||
if !a.adminSessionValid(r) {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
setBindCookie(w)
|
||||
switch provider {
|
||||
case "github":
|
||||
if !a.GH.Enabled() {
|
||||
clearBindCookie(w)
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
a.startOAuth(w, r, func(state string) string {
|
||||
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
|
||||
})
|
||||
case "google":
|
||||
if !a.GG.Enabled() {
|
||||
clearBindCookie(w)
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
a.startOAuth(w, r, func(state string) string {
|
||||
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
|
||||
})
|
||||
default:
|
||||
clearBindCookie(w)
|
||||
httpx.NotFound(w)
|
||||
}
|
||||
}
|
||||
|
||||
func setBindCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "1", Path: "/",
|
||||
HttpOnly: true, MaxAge: 600, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
|
||||
func clearBindCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
func bindRequested(r *http.Request) bool {
|
||||
ck, err := r.Cookie(oauthBindCook)
|
||||
return err == nil && ck.Value == "1"
|
||||
}
|
||||
|
||||
// adminSessionValid 判断当前请求带的是不是有效后台会话。
|
||||
// 公开 API 只依赖注入的接口(不 import admin 包,免得两个 API 互相引用成环)。
|
||||
func (a *API) adminSessionValid(r *http.Request) bool {
|
||||
if a.AdminSessions == nil {
|
||||
return false
|
||||
}
|
||||
ck, err := r.Cookie(adminCookieName)
|
||||
if err != nil || ck.Value == "" {
|
||||
return false
|
||||
}
|
||||
_, verr := a.AdminSessions.Verify(ck.Value)
|
||||
return verr == nil
|
||||
}
|
||||
|
||||
const adminCookieName = "one_session"
|
||||
|
||||
// afterIdentity 见文件头的分流规则。返回空串表示响应已写好,调用方直接 return。
|
||||
func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, externUID, display string, persona model.Reader) string {
|
||||
defer clearBindCookie(w) // 意图用完即清,免得下次普通登录误判成绑定
|
||||
if externUID == "" {
|
||||
// provider 没给稳定 id:宁可退回老流程按 handle 认人,也不建一条
|
||||
// 空 extern_uid 的绑定 —— 空值会和别人的空值撞唯一键。
|
||||
return a.issueReaderLogin(w, r, persona)
|
||||
}
|
||||
|
||||
if bindRequested(r) {
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return ""
|
||||
}
|
||||
if err := a.Store.BindIdentity(owner.ID, provider, externUID, display); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
|
||||
return ""
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return ""
|
||||
}
|
||||
http.Redirect(w, r, strings.TrimRight(a.Cfg.SiteURL, "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 已绑定的身份优先于新建档案:站主用绑定的 GitHub 登录要拿到后台会话
|
||||
u, err := a.Store.GetUserByIdentity(provider, externUID)
|
||||
switch {
|
||||
case err == nil && u.Role == model.RoleOwner:
|
||||
a.issueAdminSession(w, r)
|
||||
return ""
|
||||
case err == nil:
|
||||
return a.issueReaderSession(w, r, u.ID)
|
||||
case !errors.Is(err, store.ErrNotFound):
|
||||
httpx.ServerError(w, err)
|
||||
return ""
|
||||
}
|
||||
return a.issueReaderLogin(w, r, persona)
|
||||
}
|
||||
|
||||
func (a *API) issueReaderLogin(w http.ResponseWriter, r *http.Request, persona model.Reader) string {
|
||||
reader, err := a.Store.UpsertReader(persona)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return ""
|
||||
}
|
||||
return a.issueReaderSession(w, r, reader.ID)
|
||||
}
|
||||
|
||||
func (a *API) issueReaderSession(w http.ResponseWriter, r *http.Request, readerID int64) string {
|
||||
token, _ := a.ReaderSessions.Issue(readerID)
|
||||
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: a.ReaderSessions.TTL()})
|
||||
return a.loginBack(w, r)
|
||||
}
|
||||
|
||||
// loginBack 决定登录完跳回哪儿:优先回发起登录的前台 origin
|
||||
// (开发时前端 :3000、后端 :8080 分离,只有它才不会跳错站),
|
||||
// 没有记录(直接敲 URL 进来的)就回站点根。
|
||||
func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string {
|
||||
back := a.Cfg.SiteURL
|
||||
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
|
||||
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
|
||||
back = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
|
||||
return back
|
||||
}
|
||||
|
||||
// issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」
|
||||
// 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下
|
||||
// 浏览器会把它当不安全 cookie 丢掉。
|
||||
func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) {
|
||||
token, exp := a.AdminSessions.Issue(a.Cfg.AdminUser)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName, Value: token, Path: "/", HttpOnly: true,
|
||||
Secure: isTLS(r), SameSite: http.SameSiteLaxMode,
|
||||
Expires: exp, MaxAge: a.AdminSessions.TTL(),
|
||||
})
|
||||
}
|
||||
|
||||
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
|
||||
// admin 包有一份同名私有函数:两边各自独立,不为三行代码造共享包。
|
||||
func isTLS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
|
||||
// ---------- passkey 登录(公开) ----------
|
||||
|
||||
// passkeyBegin 发起一次发现式登录:不预先要用户名,凭据自己带出身份。
|
||||
func (a *API) passkeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if a.passkeyFails.Blocked(ratelimit.SourceKey(r)) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
options, token, err := a.Passkeys.BeginLogin()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"options": options, "token": token})
|
||||
}
|
||||
|
||||
// passkeyFinish 校验断言。命中站主发后台会话,命中读者发读者会话。
|
||||
func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
|
||||
httpx.BadRequest(w, "token 与 credential 都要传")
|
||||
return
|
||||
}
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.passkeyFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
res, err := a.Passkeys.FinishLogin(in.Token, in.Credential, func(credID string) (model.Passkey, error) {
|
||||
return a.Store.GetPasskeyByCredentialID(credID)
|
||||
})
|
||||
if err != nil {
|
||||
a.passkeyFails.Add(ip)
|
||||
httpx.Error(w, http.StatusUnauthorized, "passkey 校验失败")
|
||||
return
|
||||
}
|
||||
pk, err := a.Store.GetPasskeyByCredentialID(res.CredentialID)
|
||||
if err != nil {
|
||||
a.passkeyFails.Add(ip)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
if err := a.Store.TouchPasskey(pk.ID, res.SignCount); err != nil {
|
||||
// 计数回写失败不该挡住已验签成功的登录,但要留痕:
|
||||
// 丢了计数就等于丢了克隆检测能力。
|
||||
log.Printf("passkey: 回写签名计数失败 (id=%d): %v", pk.ID, err)
|
||||
}
|
||||
if res.CloneWarning {
|
||||
log.Printf("passkey: 凭据 %q 签名计数回退,可能被克隆或多设备同步", pk.Name)
|
||||
}
|
||||
u, err := a.Store.GetReader(pk.UserID)
|
||||
if err != nil {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
if u.Role == model.RoleOwner {
|
||||
a.issueAdminSession(w, r)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
return
|
||||
}
|
||||
a.issueReaderSession(w, r, u.ID)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// fakeAdmin 满足 API.AdminSessions 接口。不 import admin 包 ——
|
||||
// 两个 API 之间不该为了测试互相依赖。
|
||||
type fakeAdmin struct{ valid map[string]bool }
|
||||
|
||||
func (f fakeAdmin) Verify(token string) (string, error) {
|
||||
if f.valid[token] {
|
||||
return "admin", nil
|
||||
}
|
||||
return "", errors.New("bad session")
|
||||
}
|
||||
func (f fakeAdmin) Issue(string) (string, time.Time) {
|
||||
return "issued-admin-token", time.Now().Add(time.Hour)
|
||||
}
|
||||
func (f fakeAdmin) TTL() int { return 3600 }
|
||||
|
||||
func newAccountAPI(t *testing.T) (*API, http.Handler) {
|
||||
t.Helper()
|
||||
a, h := newTestAPI(t)
|
||||
a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin"}
|
||||
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
|
||||
a.GH = auth.GitHub{ClientID: "id", ClientSecret: "sec"}
|
||||
return a, h
|
||||
}
|
||||
|
||||
// 绑定动作必须已登录后台 —— 否则任何人都能往别人账号上塞身份
|
||||
func TestBindRequiresAdminSession(t *testing.T) {
|
||||
_, h := newAccountAPI(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("无会话绑定: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) {
|
||||
a, h := newAccountAPI(t)
|
||||
owner, err := a.Store.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back := a.Cfg.SiteURL + "/admin/account"
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"})
|
||||
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("got %d %s, want 302 跳 GitHub", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://github.com/login/oauth/authorize") {
|
||||
t.Fatalf("没跳授权页: %s", loc)
|
||||
}
|
||||
// 绑定意图必须落到一次性 cookie 上
|
||||
var sawBind bool
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
if ck.Name == oauthBindCook && ck.Value == "1" {
|
||||
sawBind = true
|
||||
}
|
||||
}
|
||||
if !sawBind {
|
||||
t.Fatal("绑定 cookie 没设上,回调时无从判断意图")
|
||||
}
|
||||
_ = owner
|
||||
}
|
||||
|
||||
// 回调命中「已绑定给站主」的身份 → 必须发后台会话,而不是读者会话
|
||||
func TestBoundOwnerIdentityGrantsAdminSession(t *testing.T) {
|
||||
a, _ := newAccountAPI(t)
|
||||
owner, err := a.Store.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Store.BindIdentity(owner.ID, "github", "4242", "littleckin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/callback/github", strings.NewReader("code=x&state=y"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
// 直接测分流函数:绕开真 GitHub
|
||||
back := a.afterIdentity(rec, req, "github", "4242", "littleckin",
|
||||
model.Reader{Provider: "github", Handle: "littleckin", Name: "n"})
|
||||
if back != "" {
|
||||
t.Fatalf("站主命中绑定应自己收尾(不发跳),got back=%q", back)
|
||||
}
|
||||
var admin, reader bool
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
switch ck.Name {
|
||||
case "one_session":
|
||||
admin = ck.Value == "issued-admin-token"
|
||||
case auth.ReaderCookie:
|
||||
reader = true
|
||||
}
|
||||
}
|
||||
if !admin {
|
||||
t.Fatal("没发后台会话")
|
||||
}
|
||||
if reader {
|
||||
t.Fatal("站主登录不该只拿到读者会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 陌生身份(没绑定)走老路:建读者档案 + 发读者会话 + 回跳
|
||||
func TestUnknownIdentityFallsBackToReader(t *testing.T) {
|
||||
a, _ := newAccountAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: "http://localhost:3000"})
|
||||
rec := httptest.NewRecorder()
|
||||
back := a.afterIdentity(rec, req, "github", "999", "stranger",
|
||||
model.Reader{Provider: "github", Handle: "stranger", Name: "Stranger"})
|
||||
if back != "http://localhost:3000" {
|
||||
t.Fatalf("回跳地址不对: %q", back)
|
||||
}
|
||||
var reader bool
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
if ck.Name == auth.ReaderCookie && ck.Value != "" {
|
||||
reader = true
|
||||
}
|
||||
if ck.Name == "one_session" {
|
||||
t.Fatal("陌生身份拿到了后台会话")
|
||||
}
|
||||
}
|
||||
if !reader {
|
||||
t.Fatal("没发读者会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 已被别人绑走的外部账号,不能再绑给站主
|
||||
func TestBindConflictReturns409(t *testing.T) {
|
||||
a, _ := newAccountAPI(t)
|
||||
owner, _ := a.Store.EnsureOwner("admin")
|
||||
other, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "real-owner", Name: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Store.BindIdentity(other.ID, "github", "4242", "real-owner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.AddCookie(&http.Cookie{Name: oauthBindCook, Value: "1"})
|
||||
rec := httptest.NewRecorder()
|
||||
a.afterIdentity(rec, req, "github", "4242", "hijack",
|
||||
model.Reader{Provider: "github", Handle: "hijack", Name: "h"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("got %d, want 409", rec.Code)
|
||||
}
|
||||
// 确认没被抢走
|
||||
got, err := a.Store.GetUserByIdentity("github", "4242")
|
||||
if err != nil || got.ID != other.ID {
|
||||
t.Fatalf("身份归属被改动: %+v err=%v", got, err)
|
||||
}
|
||||
_ = owner
|
||||
}
|
||||
|
||||
// passkey 未启用时公开端点要 404,不能 500
|
||||
func TestPasskeyEndpointsAbsentWhenDisabled(t *testing.T) {
|
||||
a, h := newAccountAPI(t)
|
||||
if a.Passkeys != nil {
|
||||
t.Skip("Passkeys 应未装配")
|
||||
}
|
||||
for _, p := range []string{"/api/auth/passkey/begin", "/api/auth/passkey/finish"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s: got %d, want 404", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-2
@@ -6,22 +6,76 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/hub"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/render"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。
|
||||
// 为 nil 时 /uploads/thumb/ 路由直接回原图。
|
||||
Thumbs *thumbs.Store
|
||||
// 评论区读者会话与 GitHub OAuth(main.go 装配)
|
||||
ReaderSessions *auth.ReaderSessions
|
||||
GH auth.GitHub
|
||||
// AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
|
||||
// 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话,
|
||||
// 所以除了 Verify 还要 Issue/TTL。
|
||||
AdminSessions interface {
|
||||
Verify(token string) (string, error)
|
||||
Issue(user string) (string, time.Time)
|
||||
TTL() int
|
||||
}
|
||||
// 其余登录方式(main.go 装配,未配置的自动不开放)
|
||||
GG auth.Google
|
||||
TG auth.Telegram
|
||||
// Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放)
|
||||
Passkeys *auth.Passkeys
|
||||
|
||||
// passkeyFails 按 IP 限失败次数:passkey 登录是公开端点,
|
||||
// 虽然伪造断言过不了验签,但不该让人无限次试。
|
||||
passkeyFails *ratelimit.Window
|
||||
// Hub 是评论变更的进程内广播(SSE 用;与后台 admin 共享同一实例)
|
||||
Hub *hub.Hub
|
||||
|
||||
// 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。
|
||||
// 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、
|
||||
// Telegram 伪造签名这类恶意请求需要一个兜底。
|
||||
authFails *ratelimit.Window
|
||||
commentNew *ratelimit.Window
|
||||
}
|
||||
|
||||
const (
|
||||
maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调)
|
||||
authFailWindow = 10 * time.Minute
|
||||
maxComments = 5 // 每个读者每窗口最多发几条
|
||||
commentWindow = time.Minute
|
||||
)
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
if a.authFails == nil {
|
||||
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
|
||||
a.commentNew = ratelimit.New(maxComments, commentWindow)
|
||||
}
|
||||
if a.passkeyFails == nil {
|
||||
a.passkeyFails = ratelimit.New(maxAuthFails, authFailWindow)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.Store.Ping(r.Context()); err != nil {
|
||||
@@ -30,6 +84,23 @@ func (a *API) Routes() http.Handler {
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
|
||||
})
|
||||
// 读者登录与评论
|
||||
mux.HandleFunc("/api/auth/providers", a.authProviders)
|
||||
mux.HandleFunc("/api/auth/me", a.authMe)
|
||||
mux.HandleFunc("/api/auth/logout", a.authLogout)
|
||||
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
|
||||
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
|
||||
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
|
||||
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
|
||||
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
|
||||
// 第三方身份绑定(需已登录后台)与 passkey 登录
|
||||
mux.HandleFunc("/api/auth/github/bind", a.beginBind)
|
||||
mux.HandleFunc("/api/auth/google/bind", a.beginBind)
|
||||
mux.HandleFunc("/api/auth/passkey/begin", a.passkeyBegin)
|
||||
mux.HandleFunc("/api/auth/passkey/finish", a.passkeyFinish)
|
||||
mux.HandleFunc("/api/comments", a.comments)
|
||||
mux.HandleFunc("/api/comments/stream", a.commentsStream)
|
||||
mux.HandleFunc("/api/comments/", a.commentSub)
|
||||
mux.HandleFunc("/api/site", a.site)
|
||||
mux.HandleFunc("/api/posts", a.listPosts)
|
||||
mux.HandleFunc("/api/posts/", a.getPost)
|
||||
@@ -54,7 +125,23 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
st.AuthorAvatarURL = a.avatarURL(st.AuthorAvatarKey)
|
||||
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
|
||||
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
|
||||
}
|
||||
|
||||
// avatarURL 把 settings 里的头像 key 解析成可访问 URL。
|
||||
// key 指向的文件已被删除时返回空串 —— 前台据此回落到站标,
|
||||
// 而不是留一个打不开的裂图。
|
||||
func (a *API) avatarURL(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
}
|
||||
|
||||
func listOptions(r *http.Request, defSize int) store.ListOptions {
|
||||
@@ -74,6 +161,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 时间线上短文直接铺正文:出口处做盘古之白(库里存的是原始渲染结果)
|
||||
for i := range page.Items {
|
||||
if page.Items[i].Kind == model.KindShort {
|
||||
page.Items[i].ContentHTML = render.PanguHTML(page.Items[i].ContentHTML)
|
||||
}
|
||||
}
|
||||
httpx.OK(w, page)
|
||||
}
|
||||
|
||||
@@ -102,6 +195,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
nb = nil
|
||||
}
|
||||
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
|
||||
p.ContentHTML = render.PanguHTML(p.ContentHTML)
|
||||
httpx.OK(w, struct {
|
||||
model.Post
|
||||
Neighbors []model.Post `json:"neighbors"`
|
||||
@@ -201,7 +296,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
desc := p.Summary
|
||||
if desc == "" {
|
||||
desc = trimRunes(stripTags(p.ContentHTML), 160)
|
||||
desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
|
||||
}
|
||||
item := rssItem{
|
||||
Title: title,
|
||||
@@ -275,3 +370,59 @@ func orDefault(s, def string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- uploads(上传文件的公开访问) ----------
|
||||
|
||||
// UploadsHandler 供 main.go 挂在根 mux 的 /uploads/ 前缀上。按 key 查索引行,
|
||||
// 经存储层流式返回本体;R2 + 公开域名时 302 到直链(后端不出流量)。
|
||||
// key 必须在 files 表里有行——防止拿任意对象名探测存储端。
|
||||
func (a *API) UploadsHandler() http.Handler {
|
||||
return http.HandlerFunc(a.uploads)
|
||||
}
|
||||
|
||||
func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
||||
if key == "" || strings.Contains(key, "..") {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// R2 且配了公开域名:302 到直链,后端不出流量
|
||||
if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" {
|
||||
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
rc, size, err := a.Blobs.Open(r.Context(), f.Key)
|
||||
if err != nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
|
||||
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
|
||||
w.Header().Set("Content-Type", f.Mime)
|
||||
w.Header().Set("ETag", `"`+f.SHA256+`"`)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
if rs, ok := rc.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, f.Name, time.Time{}, rs)
|
||||
return
|
||||
}
|
||||
// 非本地存储拿不到 Seeker 时退回流式拷贝
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
io.Copy(w, rc)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func newTestAPI(t *testing.T) (*API, http.Handler) {
|
||||
t.Helper()
|
||||
d, err := db.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
st, err := store.New(d)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
a := &API{
|
||||
Store: st,
|
||||
Cfg: &config.Config{SiteURL: "http://localhost:8080"},
|
||||
ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour),
|
||||
TG: auth.Telegram{Bot: "testbot", Token: "123:abc"},
|
||||
}
|
||||
settings, err := st.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
settings.CommentsEnabled = true
|
||||
if err := st.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("enable comments: %v", err)
|
||||
}
|
||||
return a, a.Routes()
|
||||
}
|
||||
|
||||
// Telegram 伪造签名反复重试应触发 IP 限速
|
||||
func TestTelegramAuthRateLimited(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
body := `{"id":1,"first_name":"x","hash":"deadbeef"}`
|
||||
var rec *httptest.ResponseRecorder
|
||||
for i := 0; i < maxAuthFails; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("attempt %d: got %d, want 403", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("after %d failures: got %d, want 429", maxAuthFails, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 评论写入按读者限速:第 maxComments+1 条被拒
|
||||
func TestCommentRateLimited(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
p, err := a.Store.Create(model.PostInput{Kind: model.KindLong, Title: "t", Slug: "t",
|
||||
ContentMd: "x", Status: model.StatusPublished})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "u1", Name: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, _ := a.ReaderSessions.Issue(rd.ID)
|
||||
post := func(i int) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"post_id": p.ID, "body_md": "好"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/comments", strings.NewReader(string(body)))
|
||||
req.AddCookie(&http.Cookie{Name: auth.ReaderCookie, Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for i := 0; i < maxComments; i++ {
|
||||
if rec := post(i); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("comment %d: got %d %s", i+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := post(maxComments); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("over limit: got %d, want 429", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转;
|
||||
// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。
|
||||
//
|
||||
// 审核:设置里开了「先审后显」时,新评论 status=pending——
|
||||
// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/render"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
readerCookie = "one_reader"
|
||||
oauthStateCook = "one_oauth_state"
|
||||
oauthBackCook = "one_oauth_back"
|
||||
maxCommentLen = 500
|
||||
editWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
// readerID 从会话 cookie 解出读者 ID;匿名返回 false。
|
||||
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
|
||||
// 站主发评论不必再走一遍 GitHub 登录。
|
||||
func (a *API) readerID(r *http.Request) (int64, bool) {
|
||||
rd, ok, err := a.resolveReader(r)
|
||||
if err != nil || !ok {
|
||||
return 0, false
|
||||
}
|
||||
return rd.ID, true
|
||||
}
|
||||
|
||||
// resolveReader 解出当前访客的读者身份:读者会话优先,
|
||||
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
|
||||
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
|
||||
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
|
||||
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
|
||||
rd, gerr := a.Store.GetReader(id)
|
||||
if gerr == nil {
|
||||
return rd, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.AdminSessions != nil {
|
||||
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
|
||||
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
|
||||
rd, oerr := a.ownerReader()
|
||||
if oerr == nil {
|
||||
return rd, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.Reader{}, false, nil
|
||||
}
|
||||
|
||||
// ownerReader 取(或创建)站主评论身份:provider=admin,名字用站点作者名。
|
||||
func (a *API) ownerReader() (model.Reader, error) {
|
||||
name := "站主"
|
||||
if st, err := a.Store.GetSettings(); err == nil && st.AuthorName != "" {
|
||||
name = st.AuthorName
|
||||
}
|
||||
return a.Store.UpsertReader(model.Reader{
|
||||
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
|
||||
var user any // 匿名时 {user: null},前端判空即「未登录」
|
||||
if reader, ok, err := a.resolveReader(r); err == nil && ok {
|
||||
user = map[string]any{
|
||||
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
|
||||
"avatar_url": reader.AvatarURL, "url": reader.URL,
|
||||
"provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
|
||||
}
|
||||
}
|
||||
httpx.OK(w, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
|
||||
// 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
|
||||
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GH.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
a.startOAuth(w, r, func(state string) string {
|
||||
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
|
||||
})
|
||||
}
|
||||
|
||||
// githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
|
||||
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GH.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
ck, err := r.Cookie(oauthStateCook)
|
||||
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "state 不匹配,请重新登录")
|
||||
return
|
||||
}
|
||||
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
u, err := a.GH.FetchUser(r.Context(), gh)
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if u.Login == "" {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, errors.New("github 未返回用户名"))
|
||||
return
|
||||
}
|
||||
name := u.Name
|
||||
if name == "" {
|
||||
name = u.Login
|
||||
}
|
||||
persona := model.Reader{
|
||||
Provider: "github", Handle: u.Login, Name: name,
|
||||
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
|
||||
}
|
||||
// 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
|
||||
extern := strconv.FormatInt(u.ID, 10)
|
||||
if u.ID == 0 {
|
||||
extern = ""
|
||||
}
|
||||
if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
|
||||
http.Redirect(w, r, back, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ---------- comments(评论的读取与发表) ----------
|
||||
|
||||
func (a *API) comments(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.listComments(w, r)
|
||||
case http.MethodPost:
|
||||
a.createComment(w, r)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
|
||||
postID := httpx.QueryInt(r, "post_id", 0)
|
||||
if postID <= 0 {
|
||||
httpx.BadRequest(w, "post_id required")
|
||||
return
|
||||
}
|
||||
viewer, _ := a.readerID(r)
|
||||
newest := httpx.QueryString(r, "sort") == "newest"
|
||||
roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{
|
||||
"items": roots, "total": len(roots),
|
||||
"page": 1, "size": len(roots),
|
||||
})
|
||||
}
|
||||
|
||||
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
|
||||
// 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
|
||||
func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
|
||||
reader, ok, err := a.resolveReader(r)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
|
||||
return
|
||||
}
|
||||
isOwner := reader.Provider == "admin"
|
||||
if reader.Banned && !isOwner {
|
||||
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
|
||||
return
|
||||
}
|
||||
// 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡)
|
||||
var rateKey string
|
||||
if !isOwner {
|
||||
rateKey = strconv.FormatInt(reader.ID, 10)
|
||||
if a.commentNew.Blocked(rateKey) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试")
|
||||
return
|
||||
}
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !st.CommentsEnabled {
|
||||
httpx.Error(w, http.StatusForbidden, "评论未开放")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
PostID int64 `json:"post_id"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
BodyMd string `json:"body_md"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(in.BodyMd)
|
||||
if body == "" {
|
||||
httpx.BadRequest(w, "评论内容不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(body)) > maxCommentLen {
|
||||
httpx.BadRequest(w, "评论最多 500 字")
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.Get(in.PostID); err != nil {
|
||||
httpx.BadRequest(w, "文章不存在")
|
||||
return
|
||||
}
|
||||
var parent model.Comment
|
||||
root := int64(0)
|
||||
if in.ParentID > 0 {
|
||||
p, err := a.Store.GetComment(in.ParentID)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "回复的评论不存在")
|
||||
return
|
||||
}
|
||||
if p.PostID != in.PostID {
|
||||
httpx.BadRequest(w, "回复的评论不属于这篇文章")
|
||||
return
|
||||
}
|
||||
parent = p
|
||||
root = parent.RootID
|
||||
if root == 0 {
|
||||
root = parent.ID
|
||||
}
|
||||
}
|
||||
status := "visible"
|
||||
if st.CommentsReview && !isOwner {
|
||||
status = "pending"
|
||||
}
|
||||
c, err := a.Store.CreateComment(model.Comment{
|
||||
PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root,
|
||||
BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
a.Hub.Broadcast(in.PostID)
|
||||
if rateKey != "" {
|
||||
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
|
||||
}
|
||||
httpx.Created(w, c)
|
||||
}
|
||||
|
||||
// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发
|
||||
func (a *API) commentSub(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/")
|
||||
if rest == "" {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
// {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余)
|
||||
if strings.HasSuffix(rest, "/thread") {
|
||||
rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad root id")
|
||||
return
|
||||
}
|
||||
a.commentThread(w, r, rootID)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(rest, 10, 64)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad comment id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
a.editComment(w, r, id)
|
||||
case http.MethodDelete:
|
||||
a.deleteComment(w, r, id)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) {
|
||||
root, err := a.Store.GetComment(rootID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
cursor := httpx.QueryInt(r, "cursor", 0)
|
||||
items := []model.Comment{}
|
||||
if cursor >= 0 && cursor < len(root.Replies) {
|
||||
items = root.Replies[cursor:]
|
||||
}
|
||||
httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount})
|
||||
}
|
||||
|
||||
// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除
|
||||
func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
readerID, ok := a.readerID(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
|
||||
return
|
||||
}
|
||||
c, err := a.Store.GetComment(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if c.UserID != readerID {
|
||||
httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论")
|
||||
return
|
||||
}
|
||||
if c.IsDeleted {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if time.Since(mustParse(c.CreatedAt)) > editWindow {
|
||||
httpx.Error(w, http.StatusForbidden, "超过可编辑时间")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
BodyMd string `json:"body_md"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(in.BodyMd)
|
||||
if body == "" {
|
||||
httpx.BadRequest(w, "评论内容不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(body)) > maxCommentLen {
|
||||
httpx.BadRequest(w, "评论最多 500 字")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
updated, err := a.Store.GetComment(id)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
a.Hub.Broadcast(updated.PostID)
|
||||
httpx.OK(w, updated)
|
||||
}
|
||||
|
||||
// deleteComment 作者软删自己的评论(留壳保楼层)
|
||||
func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
readerID, ok := a.readerID(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
|
||||
return
|
||||
}
|
||||
c, err := a.Store.GetComment(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if c.UserID != readerID {
|
||||
httpx.Error(w, http.StatusForbidden, "只能删除自己的评论")
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeleteComment(id); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
a.Hub.Broadcast(c.PostID)
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func mustParse(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go——
|
||||
// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同:
|
||||
// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
// authProviders 列出已配置的登录方式。
|
||||
// redirect 型前端直接跳 /api/auth/{id}/login;widget 型(Telegram)
|
||||
// 前端内联官方脚本,需要 bot 用户名。
|
||||
func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
|
||||
providers := []map[string]any{}
|
||||
if a.GH.Enabled() {
|
||||
providers = append(providers, map[string]any{
|
||||
"id": "github", "label": "GitHub", "kind": "redirect",
|
||||
})
|
||||
}
|
||||
if a.GG.Enabled() {
|
||||
providers = append(providers, map[string]any{
|
||||
"id": "google", "label": "Google", "kind": "redirect",
|
||||
})
|
||||
}
|
||||
if a.TG.Enabled() {
|
||||
providers = append(providers, map[string]any{
|
||||
"id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot,
|
||||
})
|
||||
}
|
||||
httpx.OK(w, map[string]any{"providers": providers})
|
||||
}
|
||||
|
||||
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
|
||||
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GG.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
a.startOAuth(w, r, func(state string) string {
|
||||
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
|
||||
})
|
||||
}
|
||||
|
||||
// googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
|
||||
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GG.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
ck, err := r.Cookie(oauthStateCook)
|
||||
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "state 不匹配,请重新登录")
|
||||
return
|
||||
}
|
||||
accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google")
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
u, err := a.GG.FetchUser(r.Context(), accessToken)
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id)
|
||||
handle := u.Sub
|
||||
if u.EmailVerified && u.Email != "" {
|
||||
handle = u.Email
|
||||
}
|
||||
name := u.Name
|
||||
if name == "" {
|
||||
name = handle
|
||||
}
|
||||
persona := model.Reader{
|
||||
Provider: "google", Handle: handle, Name: name, AvatarURL: u.Picture,
|
||||
}
|
||||
if back := a.afterIdentity(w, r, "google", u.Sub, handle, persona); back != "" {
|
||||
http.Redirect(w, r, back, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
|
||||
// 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。
|
||||
func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.TG.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
// widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
// 原样读 body:验签必须用收到的全部字段(官方规则),
|
||||
// 身份字段再单独解一次
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.TG.VerifyMap(fields); err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试")
|
||||
return
|
||||
}
|
||||
var in auth.TelegramUser
|
||||
if err := json.Unmarshal(body, &in); err != nil || in.IDInt() == 0 {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
handle := in.Username
|
||||
if handle == "" {
|
||||
// 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一
|
||||
handle = strconv.FormatInt(in.IDInt(), 10)
|
||||
}
|
||||
externUID := strconv.FormatInt(in.IDInt(), 10)
|
||||
|
||||
// Telegram 是 XHR + JSON 响应(不是整页跳转),所以这里走与 afterIdentity
|
||||
// 同语义、但自己写响应的一份分流。
|
||||
if bindRequested(r) {
|
||||
if !a.adminSessionValid(r) {
|
||||
clearBindCookie(w)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
clearBindCookie(w)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
err = a.Store.BindIdentity(owner.ID, "telegram", externUID, handle)
|
||||
clearBindCookie(w)
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "bound": "telegram"})
|
||||
return
|
||||
}
|
||||
clearBindCookie(w)
|
||||
|
||||
// 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话
|
||||
if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil {
|
||||
if u.Role == model.RoleOwner {
|
||||
a.issueAdminSession(w, r)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
return
|
||||
}
|
||||
a.issueReaderSession(w, r, u.ID)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
return
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := a.Store.UpsertReader(model.Reader{
|
||||
Provider: "telegram", Handle: handle, Name: in.DisplayName(),
|
||||
AvatarURL: in.PhotoURL,
|
||||
URL: tgProfileURL(in.Username),
|
||||
})
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
|
||||
a.issueReaderSession(w, r, reader.ID)
|
||||
httpx.OK(w, map[string]any{"user": map[string]any{
|
||||
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
|
||||
"avatar_url": reader.AvatarURL, "url": reader.URL,
|
||||
"provider": reader.Provider, "is_owner": reader.Role == model.RoleOwner, "banned": reader.Banned,
|
||||
}})
|
||||
}
|
||||
|
||||
func tgProfileURL(username string) string {
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://t.me/" + username
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// 评论变更的 SSE 端点:GET /api/comments/stream?post_id=N。
|
||||
// 事件里只有「这篇文章的评论变了」——不带任何内容,前端收到后
|
||||
// 自己 refetch。这样未审核的评论内容不会经 SSE 泄给围观者。
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
)
|
||||
|
||||
// commentsStream 长连推送。EventSource 断线会自动重连,所以这里
|
||||
// 只需要:连上即发注释行、25s 心跳防代理掐线、事件触发刷新。
|
||||
func (a *API) commentsStream(w http.ResponseWriter, r *http.Request) {
|
||||
postID, err := strconv.ParseInt(r.URL.Query().Get("post_id"), 10, 64)
|
||||
if err != nil || postID <= 0 {
|
||||
httpx.BadRequest(w, "post_id required")
|
||||
return
|
||||
}
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
// 反向代理(nginx 等)默认缓冲会吞掉流式响应
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
fmt.Fprint(w, ": connected\n\n")
|
||||
fl.Flush()
|
||||
|
||||
ch, off := a.Hub.Subscribe(postID)
|
||||
defer off()
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
fl.Flush()
|
||||
case <-ch:
|
||||
fmt.Fprintf(w, "event: comments\ndata: {\"post_id\":%d}\n\n", postID)
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
|
||||
//
|
||||
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
|
||||
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
|
||||
// 一律 302 回原图:前端 <img> 拿 302 是无感的。
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbDefaultW = 960
|
||||
thumbMinW = 64
|
||||
thumbMaxW = 1600
|
||||
)
|
||||
|
||||
func (a *API) ThumbHandler() http.Handler {
|
||||
return http.HandlerFunc(a.thumb)
|
||||
}
|
||||
|
||||
func (a *API) thumb(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Thumbs == nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/")
|
||||
if key == "" || strings.Contains(key, "..") {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
width := thumbDefaultW
|
||||
if s := r.URL.Query().Get("w"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW {
|
||||
width = n
|
||||
}
|
||||
}
|
||||
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !thumbs.Supported(f.Mime) || f.SHA256 == "" {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
|
||||
// 快路径:缓存命中直接供(锁外)
|
||||
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
return
|
||||
}
|
||||
// 冷却期:近期失败过,不再尝试
|
||||
if a.Thumbs.FailedRecently(f.SHA256) {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
|
||||
// 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存
|
||||
lk := a.Thumbs.Lock(f.SHA256, width)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
return
|
||||
}
|
||||
|
||||
rc, _, err := a.Blobs.Open(r.Context(), f.Key)
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
out, outMime, _, err := thumbs.Generate(src, f.Mime, width)
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
p, err := a.Thumbs.Put(f.SHA256, width, out, outMime)
|
||||
if err != nil {
|
||||
// 写缓存失败不拖累本次响应:产物就在内存里
|
||||
a.serveThumbBytes(w, outMime, out, f)
|
||||
return
|
||||
}
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
}
|
||||
|
||||
// thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。
|
||||
func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) {
|
||||
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
|
||||
}
|
||||
|
||||
// serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。
|
||||
func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) {
|
||||
fp, err := os.Open(path)
|
||||
if err != nil {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
defer fp.Close()
|
||||
fi, err := fp.Stat()
|
||||
if err != nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, path, fi.ModTime(), fp)
|
||||
}
|
||||
|
||||
func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) {
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("ETag", `"`+f.SHA256+`"`)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
|
||||
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
|
||||
const ReaderCookie = "one_reader"
|
||||
|
||||
// ReaderSessions 签发 / 校验读者会话令牌:
|
||||
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
|
||||
type ReaderSessions struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
var ErrBadSession = errors.New("invalid reader session")
|
||||
|
||||
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
|
||||
exp := time.Now().Add(s.ttl)
|
||||
payload := readerPayload(readerID, exp)
|
||||
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return enc + "." + s.sign(payload), exp
|
||||
}
|
||||
|
||||
// TTL 是会话秒数,供 cookie 的 MaxAge 用。
|
||||
// 之前两处硬编码 30*24h,改 ttl 时容易和 Issue 不同步。
|
||||
func (s *ReaderSessions) TTL() int { return int(s.ttl.Seconds()) }
|
||||
|
||||
func (s *ReaderSessions) Verify(token string) (int64, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
|
||||
if len(raw) < 10 || string(raw)[:7] != "reader:" {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 余下是 "<id>:<exp>"
|
||||
rest := string(raw)[7:]
|
||||
i := strings.IndexByte(rest, ':')
|
||||
if i <= 0 {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
id, err := strconv.ParseInt(rest[:i], 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
if time.Now().After(time.Unix(expUnix, 0)) {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
|
||||
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func readerPayload(id int64, exp time.Time) string {
|
||||
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
|
||||
}
|
||||
|
||||
func (s *ReaderSessions) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ---------- GitHub OAuth ----------
|
||||
|
||||
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
|
||||
// 前台登录卡自动不显示该入口。
|
||||
type GitHub struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||
|
||||
const (
|
||||
githubAuthURL = "https://github.com/login/oauth/authorize"
|
||||
githubTokenURL = "https://github.com/login/oauth/access_token"
|
||||
githubUserURL = "https://api.github.com/user"
|
||||
)
|
||||
|
||||
// LoginURL 生成 GitHub 授权页跳转地址
|
||||
func (g GitHub) LoginURL(redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("scope", "read:user")
|
||||
v.Set("state", state)
|
||||
return githubAuthURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GitHubUser 是 GitHub 用户接口里我们关心的字段
|
||||
type GitHubUser struct {
|
||||
// ID 是 GitHub 的数字主键:永不复用、改名不变。身份绑定必须用它,
|
||||
// 用 login 的话对方一改用户名,绑定就指向了另一个人。
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Exchange 用授权码换 access token
|
||||
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("client_secret", g.ClientSecret)
|
||||
v.Set("code", code)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
|
||||
return "", fmt.Errorf("github: token exchange failed")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// FetchUser 拉取 GitHub 用户资料
|
||||
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
|
||||
if err != nil {
|
||||
return GitHubUser{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return GitHubUser{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var u GitHubUser
|
||||
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
|
||||
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Google OAuth2(OIDC 简化用法:openid email profile 三个 scope,
|
||||
// userinfo 接口拿资料)。配置来源 .env 的 ONE_GOOGLE_CLIENT_ID / SECRET。
|
||||
const (
|
||||
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
googleTokenURL = "https://oauth2.googleapis.com/token"
|
||||
googleUserURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
)
|
||||
|
||||
type Google struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func (g Google) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||
|
||||
func (g Google) LoginURL(redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", "openid email profile")
|
||||
v.Set("state", state)
|
||||
return googleAuthURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GoogleUser 是 userinfo 接口里我们关心的字段。
|
||||
// sub 是 Google 账号的稳定唯一 id;邮箱需要已验证才当 handle 用。
|
||||
type GoogleUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func (g Google) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("client_secret", g.ClientSecret)
|
||||
v.Set("code", code)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("grant_type", "authorization_code")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(v.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
|
||||
return "", fmt.Errorf("google: token exchange failed")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
func (g Google) FetchUser(ctx context.Context, accessToken string) (GoogleUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil)
|
||||
if err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return GoogleUser{}, fmt.Errorf("google: userinfo %d", res.StatusCode)
|
||||
}
|
||||
var u GoogleUser
|
||||
if err := json.NewDecoder(res.Body).Decode(&u); err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
if u.Sub == "" {
|
||||
return GoogleUser{}, fmt.Errorf("google: userinfo missing sub")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Passkey(WebAuthn)封装。
|
||||
//
|
||||
// 两件事分开:注册必须在管理员会话之下发起(否则任何人都能往站主账号上塞凭据),
|
||||
// 登录是公开的、发现式的(不需要先输用户名,浏览器直接问系统要凭据)。
|
||||
//
|
||||
// challenge 与会话放进程内存而不是签名 cookie:单进程博客够用,重启只会让
|
||||
// 正在进行中的注册/登录作废(下次重点即可),不会留下可复用的状态。
|
||||
// 用完即删 —— challenge 是一次性的,留着就有重放面。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
const sessionTTL = 5 * time.Minute
|
||||
|
||||
var ErrSessionExpired = errors.New("passkey session expired or already used")
|
||||
|
||||
type Passkeys struct {
|
||||
w *webauthn.WebAuthn
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*pkSession
|
||||
}
|
||||
|
||||
type pkSession struct {
|
||||
data webauthn.SessionData
|
||||
userID int64 // 注册时=目标账号;登录时发现式则为 0
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// userAdapter 把我们的账号 + 凭据行喂给库的 webauthn.User 接口。
|
||||
type userAdapter struct {
|
||||
id int64
|
||||
name string
|
||||
disp string
|
||||
creds []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u userAdapter) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) }
|
||||
func (u userAdapter) WebAuthnName() string { return u.name }
|
||||
func (u userAdapter) WebAuthnDisplayName() string {
|
||||
if u.disp != "" {
|
||||
return u.disp
|
||||
}
|
||||
return u.name
|
||||
}
|
||||
func (u userAdapter) WebAuthnCredentials() []webauthn.Credential { return u.creds }
|
||||
|
||||
// NewPasskeys 构造一个 passkey 服务。rpID 是域名(不含 scheme/port),
|
||||
// origin 是完整来源(开发时是 http://localhost:3000,两者可以不同)。
|
||||
func NewPasskeys(displayName, rpID string, origins []string) (*Passkeys, error) {
|
||||
w, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: displayName,
|
||||
RPID: rpID,
|
||||
RPOrigins: origins,
|
||||
// 站内凭据不需要证明来源可信:attestation 一律 none,
|
||||
// 既避免解析各家认证报告的复杂度,也不把它当授权依据。
|
||||
AttestationPreference: protocol.PreferNoAttestation,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationRequired,
|
||||
// 可发现凭据:登录时不用先报用户名。
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Passkeys{w: w, sessions: make(map[string]*pkSession)}, nil
|
||||
}
|
||||
|
||||
func toCredentials(pks []model.Passkey) ([]webauthn.Credential, error) {
|
||||
out := make([]webauthn.Credential, 0, len(pks))
|
||||
for _, p := range pks {
|
||||
key, err := base64.StdEncoding.DecodeString(p.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("passkey %d public key: %w", p.ID, err)
|
||||
}
|
||||
id, err := base64.StdEncoding.DecodeString(p.CredentialID)
|
||||
if err != nil {
|
||||
// 兼容:credential_id 也可能是原始字符串(非 base64)
|
||||
id = []byte(p.CredentialID)
|
||||
}
|
||||
out = append(out, webauthn.Credential{
|
||||
ID: id,
|
||||
PublicKey: key,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: p.SignCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BeginRegistration 发起注册。返回给前端的创建参数与一次性 token。
|
||||
func (p *Passkeys) BeginRegistration(userID int64, name, display string, existing []model.Passkey) (creationJSON json.RawMessage, token string, err error) {
|
||||
creds, err := toCredentials(existing)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
|
||||
// 已存在的凭据要排除,免得同一台设备被重复登记
|
||||
opts := []webauthn.RegistrationOption{}
|
||||
if len(creds) > 0 {
|
||||
exclude := make([]protocol.CredentialDescriptor, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
exclude = append(exclude, protocol.CredentialDescriptor{
|
||||
Type: protocol.PublicKeyCredentialType,
|
||||
CredentialID: c.ID,
|
||||
})
|
||||
}
|
||||
opts = append(opts, webauthn.WithExclusions(exclude))
|
||||
}
|
||||
creation, session, err := p.w.BeginRegistration(u, opts...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
tok, err := randomToken()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
p.put(tok, &pkSession{data: *session, userID: userID, exp: time.Now().Add(sessionTTL)})
|
||||
b, err := json.Marshal(creation)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return b, tok, nil
|
||||
}
|
||||
|
||||
// FinishRegistration 校验浏览器返回的凭据并交回要落库的字段。
|
||||
// credJSON 是 PublicKeyCredential 的 JSON —— challenge/token 走外层字段,
|
||||
// 凭据对象原样交给库解析(库自己校验 clientDataJSON.challenge 是否对得上)。
|
||||
func (p *Passkeys) FinishRegistration(token string, userID int64, name, display string, existing []model.Passkey, credJSON []byte) (model.Passkey, error) {
|
||||
sess, err := p.take(token, userID)
|
||||
if err != nil {
|
||||
return model.Passkey{}, err
|
||||
}
|
||||
creds, err := toCredentials(existing)
|
||||
if err != nil {
|
||||
return model.Passkey{}, err
|
||||
}
|
||||
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credJSON))
|
||||
if err != nil {
|
||||
return model.Passkey{}, err
|
||||
}
|
||||
cred, err := p.w.CreateCredential(u, *sess, parsed)
|
||||
if err != nil {
|
||||
return model.Passkey{}, err
|
||||
}
|
||||
return model.Passkey{
|
||||
UserID: userID,
|
||||
CredentialID: base64.StdEncoding.EncodeToString(cred.ID),
|
||||
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginLogin 发起发现式登录(不预先指定账号,由凭据自己带出身份)。
|
||||
func (p *Passkeys) BeginLogin() (assertionJSON json.RawMessage, token string, err error) {
|
||||
assertion, session, err := p.w.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
tok, err := randomToken()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
p.put(tok, &pkSession{data: *session, exp: time.Now().Add(sessionTTL)})
|
||||
b, err := json.Marshal(assertion)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return b, tok, nil
|
||||
}
|
||||
|
||||
// LoginResult 是一次 passkey 登录的结果。
|
||||
// CloneWarning 透传库的判定:新计数 ≤ 已存计数时库认为凭据可能被克隆。
|
||||
// 这里只上报不拦 —— 云同步的 passkey(iCloud Keychain 等)计数本就不单调,
|
||||
// 硬拦会把合法用户挡在门外;但它是必须让站主看得见的信号。
|
||||
type LoginResult struct {
|
||||
CredentialID string // base64,交给 resolver 时用的同一个键
|
||||
UserID int64
|
||||
SignCount uint32
|
||||
CloneWarning bool
|
||||
}
|
||||
|
||||
// FinishLogin 完成登录:resolver 按 credential id 反查凭据(含所属账号),
|
||||
// 验签通过后返回该凭据与新计数,调用方据此决定发哪种会话。
|
||||
// credJSON 同 FinishRegistration,是客户端原样回传的 PublicKeyCredential JSON。
|
||||
func (p *Passkeys) FinishLogin(token string, credJSON []byte, resolve func(credentialIDB64 string) (model.Passkey, error)) (LoginResult, error) {
|
||||
var out LoginResult
|
||||
sess, err := p.take(token, 0)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
pk, err := resolve(base64.StdEncoding.EncodeToString(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds, err := toCredentials([]model.Passkey{pk})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.CredentialID = pk.CredentialID
|
||||
out.UserID = pk.UserID
|
||||
// 名字只用于日志/报错,登录路径不展示
|
||||
return userAdapter{id: pk.UserID, name: strconv.FormatInt(pk.UserID, 10), creds: creds}, nil
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBytes(credJSON)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
cred, err := p.w.ValidateDiscoverableLogin(handler, *sess, parsed)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.SignCount = cred.Authenticator.SignCount
|
||||
out.CloneWarning = cred.Authenticator.CloneWarning
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// put 存会话并顺手清掉过期的:map 只在这几个流程里活几分钟,
|
||||
// 清扫是防泄漏而不是防攻击。
|
||||
func (p *Passkeys) put(token string, s *pkSession) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range p.sessions {
|
||||
if v.exp.Before(now) {
|
||||
delete(p.sessions, k)
|
||||
}
|
||||
}
|
||||
p.sessions[token] = s
|
||||
}
|
||||
|
||||
// take 取出并删除会话(一次性),并核对发起时的账号。
|
||||
func (p *Passkeys) take(token string, userID int64) (*webauthn.SessionData, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.sessions[token]
|
||||
if !ok {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
delete(p.sessions, token)
|
||||
if time.Now().After(s.exp) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
// 注册会话绑账号:不能拿自己发起的 challenge 去给别人的账号注册
|
||||
if userID != 0 && s.userID != userID {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
return &s.data, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Telegram Login Widget:没有授权码流程,官方脚本把用户资料连同
|
||||
// hash 一起交给前端,前端 POST 到 /api/auth/telegram,后端用 bot
|
||||
// token 验签。算法见官方文档:secret = SHA256(bot_token),
|
||||
// data-check-string 是除 hash 外所有收到的字段按 key 排序的 "k=v" 行。
|
||||
type Telegram struct {
|
||||
Bot string // bot 用户名(不含 @),下发给 widget
|
||||
Token string // bot token,只用于验签,不出后端
|
||||
}
|
||||
|
||||
func (t Telegram) Enabled() bool { return t.Bot != "" && t.Token != "" }
|
||||
|
||||
// telegramAuthTTL 是验签窗口:widget 回传的 auth_date 超过它视为过期
|
||||
const telegramAuthTTL = 24 * time.Hour
|
||||
|
||||
// TelegramUser 是 widget 回传的身份字段(验签通过后从中取)。
|
||||
// widget 把所有值都当字符串发(id 也不例外),但前端测试或手工
|
||||
// 调用可能发数字 —— FlexStr 两种都收。
|
||||
type TelegramUser struct {
|
||||
ID FlexStr `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
}
|
||||
|
||||
// FlexStr 兼容 JSON 里的字符串和数字
|
||||
type FlexStr string
|
||||
|
||||
func (f *FlexStr) UnmarshalJSON(b []byte) error {
|
||||
if len(b) > 0 && b[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*f = FlexStr(s)
|
||||
return nil
|
||||
}
|
||||
*f = FlexStr(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FlexStr) Int64() (int64, error) {
|
||||
return strconv.ParseInt(strings.Trim(string(f), `"`), 10, 64)
|
||||
}
|
||||
|
||||
func (u TelegramUser) IDInt() int64 { id, _ := u.ID.Int64(); return id }
|
||||
|
||||
func (u TelegramUser) DisplayName() string {
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if name == "" {
|
||||
name = u.Username
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// normalize 把 JSON 值规整成 widget 发送时的字符串形态
|
||||
// (widget 的数值字段也是字符串,但调用方可能发真数字)
|
||||
func normalize(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
default:
|
||||
return fmt.Sprint(x)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyMap 校验 hash 与时效。fields 是前端原样 POST 的 JSON 对象;
|
||||
// exclude 里的 key(我们附加的非 Telegram 字段)不参与验签。
|
||||
func (t Telegram) VerifyMap(fields map[string]any, exclude ...string) error {
|
||||
hash := normalize(fields["hash"])
|
||||
if hash == "" {
|
||||
return fmt.Errorf("telegram: missing hash")
|
||||
}
|
||||
authDate, err := strconv.ParseInt(normalize(fields["auth_date"]), 10, 64)
|
||||
if err != nil || authDate == 0 || time.Since(time.Unix(authDate, 0)) > telegramAuthTTL {
|
||||
return fmt.Errorf("telegram: auth_date expired")
|
||||
}
|
||||
skip := make(map[string]bool, len(exclude)+1)
|
||||
skip["hash"] = true
|
||||
for _, k := range exclude {
|
||||
skip[k] = true
|
||||
}
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
if !skip[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+normalize(fields[k]))
|
||||
}
|
||||
secret := sha256.Sum256([]byte(t.Token))
|
||||
mac := hmac.New(sha256.New, secret[:])
|
||||
mac.Write([]byte(strings.Join(lines, "\n")))
|
||||
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(hash))) {
|
||||
return fmt.Errorf("telegram: hash mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,6 +21,41 @@ type Config struct {
|
||||
DataDir string
|
||||
SiteURL string
|
||||
InsecureDev bool
|
||||
|
||||
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
|
||||
// 评论区 Google 登录(OAuth 客户端凭据,Google Cloud Console 创建)
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
|
||||
// 评论区 Telegram 登录(Login Widget):Bot 是用户名(不含 @,下发给
|
||||
// 前端 widget),Token 用于验签。两者都要,缺一该入口不开放。
|
||||
TelegramBot string
|
||||
TelegramToken string
|
||||
|
||||
// Passkey(WebAuthn)。RPID 是域名(不含端口),Origins 是允许的完整来源。
|
||||
// 必须显式配 ONE_WEBAUTHN_ORIGINS 才启用(它是发放永久登录凭据的功能);
|
||||
// 启用后 RPID 默认取 SiteURL 的主机名,Origin 列表会自动并入 SiteURL 本身。
|
||||
// 开发时前端在 :3000、后端在 :8080,所以要把两个来源都写上:
|
||||
// ONE_WEBAUTHN_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||
WebauthnRPID string // env: ONE_WEBAUTHN_RP_ID
|
||||
WebauthnOrigins []string // env: ONE_WEBAUTHN_ORIGINS(空 = 不启用)
|
||||
|
||||
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
|
||||
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
|
||||
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
|
||||
// PublicURL = 公开访问域名(r2.dev / 绑定的自定义域名),文件直链走它
|
||||
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
|
||||
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
|
||||
// (DataDir/uploads),并在启动日志提示一句。
|
||||
StorageDriver string // r2 | local
|
||||
S3Endpoint string // env: S3Api
|
||||
R2Bucket string
|
||||
R2AccessKey string
|
||||
R2SecretKey string
|
||||
UploadsPublicBase string // env: PublicURL
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
@@ -77,9 +114,88 @@ func Load() (*Config, error) {
|
||||
|
||||
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
|
||||
|
||||
// 对象存储:变量名按站主 .env 里整理好的来(无 ONE_ 前缀)。
|
||||
c.UploadsPublicBase = strings.TrimRight(getenv("PublicURL", ""), "/")
|
||||
c.S3Endpoint = getenv("S3Api", "")
|
||||
c.R2AccessKey = getenv("AccessKey", "")
|
||||
c.R2SecretKey = getenv("SecretAccessKey", "")
|
||||
c.R2Bucket = getenv("Bucket", "")
|
||||
if c.S3Endpoint != "" && c.R2Bucket != "" && c.R2AccessKey != "" && c.R2SecretKey != "" {
|
||||
c.StorageDriver = "r2"
|
||||
} else {
|
||||
c.StorageDriver = "local"
|
||||
// 配了一半(有凭据没端点之类)时给一句启动日志,别让站主猜。
|
||||
// 只报字段名,不报值。
|
||||
var missing []string
|
||||
if c.S3Endpoint == "" {
|
||||
missing = append(missing, "S3Api")
|
||||
}
|
||||
if c.R2Bucket == "" {
|
||||
missing = append(missing, "Bucket")
|
||||
}
|
||||
if c.R2AccessKey == "" {
|
||||
missing = append(missing, "AccessKey")
|
||||
}
|
||||
if c.R2SecretKey == "" {
|
||||
missing = append(missing, "SecretAccessKey")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
log.Printf("storage: R2 配置缺 %s,上传回落本地磁盘", strings.Join(missing, "、"))
|
||||
}
|
||||
}
|
||||
|
||||
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
|
||||
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
|
||||
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
|
||||
c.GoogleClientID = getenv("ONE_GOOGLE_CLIENT_ID", "")
|
||||
c.GoogleClientSecret = getenv("ONE_GOOGLE_CLIENT_SECRET", "")
|
||||
c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "")
|
||||
c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
// Passkey:默认跟着 SiteURL,显式配了 ONE_WEBAUTHN_ORIGINS 才算启用
|
||||
// (没配就不注册相关路由,也不给前端暴露入口)。
|
||||
c.WebauthnRPID, c.WebauthnOrigins = webauthnFromSite(c.SiteURL, getenv("ONE_WEBAUTHN_RP_ID", ""), getenv("ONE_WEBAUTHN_ORIGINS", ""))
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// webauthnFromSite 推导 RPID 与允许的 Origin 列表。
|
||||
// 只有显式配了 ONE_WEBAUTHN_ORIGINS 才返回非空列表 —— 没配就视为不启用
|
||||
// (返回 nil,调用方跳过构造)。这是个发放永久登录凭据的功能,
|
||||
// 默认关闭比默认开启安全;SiteURL 推导出的 origin 只在启用后作为额外来源。
|
||||
func webauthnFromSite(siteURL, rpID, origins string) (string, []string) {
|
||||
list := []string{}
|
||||
for _, o := range strings.Split(origins, ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
list = append(list, o)
|
||||
}
|
||||
}
|
||||
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
|
||||
if rpID == "" {
|
||||
rpID = u.Hostname() // Hostname() 会去掉端口
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return "", nil // 未启用:RPID 一并清空,免得调用方误判成可用
|
||||
}
|
||||
// 启用后把站点自身来源也加上(生产环境页面就来自这里)
|
||||
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
|
||||
if origin := u.Scheme + "://" + u.Host; !containsStr(list, origin) {
|
||||
list = append(list, origin)
|
||||
}
|
||||
}
|
||||
return rpID, list
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if strings.EqualFold(v, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type badDriver struct{ d string }
|
||||
|
||||
func (e *badDriver) Error() string {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package hub 是一个极简的进程内发布/订阅:按主题(文章 ID)广播
|
||||
// 「有变更」信号。订阅者只拿到一个空 struct 信号——不携带内容,
|
||||
// 消费方(SSE 前端)收到后自己 refetch,未审核的评论内容不会经由
|
||||
// 这条通道外泄。
|
||||
package hub
|
||||
|
||||
import "sync"
|
||||
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[int64]map[chan struct{}]struct{}
|
||||
}
|
||||
|
||||
func New() *Hub {
|
||||
return &Hub{subs: make(map[int64]map[chan struct{}]struct{})}
|
||||
}
|
||||
|
||||
// Subscribe 订阅某主题;返回信号 channel 和退订函数。
|
||||
// channel 容量 1:订阅者处理不过来时新信号直接丢弃(合并刷新)。
|
||||
func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
|
||||
// nil hub(测试/未装配场景)静默降级:永远收不到信号的通道
|
||||
if h == nil {
|
||||
ch := make(chan struct{})
|
||||
return ch, func() {}
|
||||
}
|
||||
ch := make(chan struct{}, 1)
|
||||
h.mu.Lock()
|
||||
if h.subs[topic] == nil {
|
||||
h.subs[topic] = make(map[chan struct{}]struct{})
|
||||
}
|
||||
h.subs[topic][ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
off := func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs[topic], ch)
|
||||
if len(h.subs[topic]) == 0 {
|
||||
delete(h.subs, topic)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
return ch, off
|
||||
}
|
||||
|
||||
// Broadcast 唤醒某主题的全部订阅者;积压的订阅者不阻塞。
|
||||
func (h *Hub) Broadcast(topic int64) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.subs[topic] {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package linkmeta
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// charset.go 负责把响应字节解成 UTF-8 字符串。
|
||||
//
|
||||
// 为什么必须做:老派中文站点(以及不少论坛/院校站)仍在用 GBK/GB2312/Big5,
|
||||
// 直接当 UTF-8 读会得到一串替换字符,卡片标题就成了乱码。与其显示乱码,
|
||||
// 不如抓对——这是纯展示层的事,不该让站主去改对方的编码。
|
||||
|
||||
// decode 按「HTTP 头声明 → 文档里的 <meta charset> → UTF-8」的优先级解码。
|
||||
// 已经是 UTF-8/ASCII 时原样返回,不绕转换管线。
|
||||
func decode(raw []byte, contentType string) string {
|
||||
raw = bytes.TrimPrefix(raw, []byte("\ufeff")) // UTF-8 BOM
|
||||
name := charsetFromHeader(contentType)
|
||||
if name == "" {
|
||||
// meta 标签本身是 ASCII(GBK/Big5 都是 ASCII 超集),
|
||||
// 所以从未解码的原始字节里嗅探是安全的,只看文档开头。
|
||||
name = charsetFromMeta(raw)
|
||||
}
|
||||
if name == "" || isUTF8(name) {
|
||||
return string(raw)
|
||||
}
|
||||
enc, err := htmlindex.Get(name)
|
||||
if err != nil {
|
||||
return string(raw) // 没见过的字符名:按 UTF-8 尽力而为
|
||||
}
|
||||
out, _, err := transform.Bytes(enc.NewDecoder(), raw)
|
||||
if err != nil && len(out) == 0 {
|
||||
return string(raw)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func charsetFromHeader(ct string) string {
|
||||
for _, part := range strings.Split(ct, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) == 2 && strings.EqualFold(kv[0], "charset") {
|
||||
return normalizeCharset(strings.Trim(kv[1], `"'`))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
metaCharset = []byte("charset=")
|
||||
)
|
||||
|
||||
// charsetFromMeta 在文档开头找 <meta charset="x"> 或
|
||||
// <meta http-equiv="Content-Type" content="...; charset=x">。
|
||||
func charsetFromMeta(raw []byte) string {
|
||||
head := raw
|
||||
if len(head) > 2048 {
|
||||
head = head[:2048] // 声明一定在前,不必扫全文
|
||||
}
|
||||
lower := bytes.ToLower(head)
|
||||
i := bytes.Index(lower, metaCharset)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := head[i+len(metaCharset):]
|
||||
if len(rest) == 0 {
|
||||
return ""
|
||||
}
|
||||
// 值可能被引号包住;带引号时以配对引号收尾,不带时到引号/空格/;/ > 为止
|
||||
// (不闭合的引号也当结束——http-equiv 写法里值是 content="...; charset=gbk",
|
||||
// 未加引号的 charset 值正好以那个收尾引号终止)
|
||||
if q := rest[0]; q == '"' || q == '\'' {
|
||||
rest = rest[1:]
|
||||
if end := bytes.IndexByte(rest, q); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
} else if end := bytes.IndexAny(rest, `"' ;>`); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
return normalizeCharset(string(rest))
|
||||
}
|
||||
|
||||
func normalizeCharset(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
// HTML 标准与 IANA 的常见别名统一成 htmlindex 认得的名字
|
||||
switch s {
|
||||
case "gb2312", "gb_2312", "gb-2312":
|
||||
return "gbk" // GBK 是 GB2312 的超集,按声明的 GB2312 解会漏字
|
||||
case "x-sjis":
|
||||
return "shift_jis"
|
||||
case "euckr", "kr":
|
||||
return "euc-kr"
|
||||
case "utf8", "utf-8", "ascii", "us-ascii", "":
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isUTF8(name string) bool {
|
||||
switch name {
|
||||
case "", "utf-8", "utf8", "ascii", "us-ascii":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package linkmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// gbkPage 把 UTF-8 字符串编成 GBK——老派中文站就是这么发的。
|
||||
func gbkPage(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
out, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDecodeGBKMetaCharset(t *testing.T) {
|
||||
// 只在 <meta> 里声明,HTTP 头不带 charset——这是最常见的写法
|
||||
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="围棋职业棋士的直播间"></head></html>`
|
||||
raw := gbkPage(t, doc)
|
||||
got := parse(decode(raw, "text/html"), mustBase(t, "https://example.com/"))
|
||||
if got.Title != "围棋职业棋士的直播间" {
|
||||
t.Fatalf("GBK 未正确解码: %q", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHTTPHeaderWins(t *testing.T) {
|
||||
doc := `<html><head><title>标题测试</title></head></html>`
|
||||
raw := gbkPage(t, doc)
|
||||
got := parse(decode(raw, `text/html; charset=GB2312`), mustBase(t, "https://example.com/"))
|
||||
if got.Title != "标题测试" {
|
||||
t.Fatalf("HTTP 头声明的字符集未生效: %q", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHTTPEquivMeta(t *testing.T) {
|
||||
doc := `<html><head><meta http-equiv="Content-Type" content="text/html; charset=gbk"><title>中文标题</title></head></html>`
|
||||
got := parse(decode(gbkPage(t, doc), "text/html"), mustBase(t, "https://example.com/"))
|
||||
if got.Title != "中文标题" {
|
||||
t.Fatalf("http-equiv 形式未识别: %q", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePassthroughAndUnknown(t *testing.T) {
|
||||
utf8 := `<html><head><title>已经是 UTF-8</title></head></html>`
|
||||
if got := decode([]byte(utf8), "text/html; charset=utf-8"); got != utf8 {
|
||||
t.Error("UTF-8 不该走转换管线")
|
||||
}
|
||||
if got := decode([]byte(utf8), "text/html; charset=bogus-9999"); got != utf8 {
|
||||
t.Error("认不出的字符集应退回原始字节而不是报错")
|
||||
}
|
||||
if got := decode([]byte("\ufeff"+utf8), "text/html"); got != utf8 {
|
||||
t.Error("BOM 应被剥掉")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetFromHeader(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`text/html; charset=UTF-8`: "utf-8",
|
||||
`text/html;charset="gbk"`: "gbk",
|
||||
`text/html; charset=GB2312`: "gbk", // 别名归一:GBK 是超集
|
||||
`text/html`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := charsetFromHeader(in); got != want {
|
||||
t.Errorf("charsetFromHeader(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetFromMeta(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`<html><head><meta charset='Big5'>`: "big5",
|
||||
`<html><head><meta charset=gb2312>`: "gbk",
|
||||
`<meta http-equiv="Content-Type" content="text/html; charset=x-sjis">`: "shift_jis",
|
||||
`<html><head><title>没有声明</title>`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := charsetFromMeta([]byte(in)); got != want {
|
||||
t.Errorf("charsetFromMeta(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:真发一个 GBK 页面(HTTP 头故意不写 charset),确认整条链路出正确卡片。
|
||||
func TestFetcherDecodesGBKEndToEnd(t *testing.T) {
|
||||
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="鱼妹妹下棋"><meta property="og:site_name" content="某中文站"></head>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html") // 不声明 charset,逼它去嗅 meta
|
||||
fmt.Fprint(w, string(gbkPage(t, doc)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}}
|
||||
c, err := f.Fetch(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Title != "鱼妹妹下棋" || c.Site != "某中文站" {
|
||||
t.Fatalf("card=%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func mustBase(t *testing.T, s string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package linkmeta 从正文里的链接抓一份「链接卡片」素材:标题、描述、站点名、封面图。
|
||||
//
|
||||
// 抓取由站主写作时触发(不是读者请求触发),但目标地址仍是任意公网 URL,
|
||||
// 所以按对外抓取的标准对待:只放行 http/https、限时限量限跳转,
|
||||
// 并在拨号那一刻解析并拒绝内网地址——防的是「服务器自己打自己」这类 SSRF,
|
||||
// 以及 DNS 先返回公网 IP、拨号时换成内网 IP 的重绑定把戏。
|
||||
package linkmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBody 只读文档开头就够拿到 <head>,多一个字节都不多读。
|
||||
maxBody = 512 << 10
|
||||
// maxRedirects 跳转链上限。
|
||||
maxRedirects = 3
|
||||
// 浏览器形态的 UA:不少站点(X、微博、部分门户)对未知爬虫 UA 给的
|
||||
// og 元数据不全或直接拒绝,对浏览器 UA 则照常输出 <head> 里的标签。
|
||||
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
// Card 是抓取结果的数据形状;定义在 model 里(要落库、要给前端),
|
||||
// 这里用别名保持本包的写法。
|
||||
type Card = model.LinkCard
|
||||
|
||||
var (
|
||||
urlRe = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`)
|
||||
trailingCut = ".,;:!?、。)]》」》"
|
||||
)
|
||||
|
||||
// FirstURL 取正文里第一个 http(s) 链接(Markdown 原文,含代码块里的也算,
|
||||
// 站主自己不会在代码块里贴想展示的链接)。没有则空串。
|
||||
func FirstURL(md string) string {
|
||||
m := urlRe.FindString(md)
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(m, trailingCut)
|
||||
}
|
||||
|
||||
// Fetcher 抓取器。Dial 为 nil 时用带 SSRF 防护的默认拨号器;
|
||||
// 测试里注入普通拨号器才能打到 httptest 的回环地址(默认会被拦掉)。
|
||||
type Fetcher struct {
|
||||
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
Timeout time.Duration // 0 = 默认 5s
|
||||
}
|
||||
|
||||
// Fetch 用默认安全拨号器抓 rawURL 的元信息。ctx 控制整体时限。
|
||||
func Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
||||
return (&Fetcher{}).Fetch(ctx, rawURL)
|
||||
}
|
||||
|
||||
// Fetch 抓 rawURL 的元信息。
|
||||
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linkmeta: bad url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errors.New("linkmeta: empty host")
|
||||
}
|
||||
|
||||
client := f.client()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linkmeta: fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("linkmeta: status %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
return nil, fmt.Errorf("linkmeta: not html (%q)", ct)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linkmeta: read: %w", err)
|
||||
}
|
||||
final := resp.Request.URL // 跟完跳转后的最终地址
|
||||
card := parse(decode(body, resp.Header.Get("Content-Type")), final)
|
||||
if card.Empty() {
|
||||
return nil, errors.New("linkmeta: no usable metadata")
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用)
|
||||
func (f *Fetcher) client() *http.Client {
|
||||
dial := f.Dial
|
||||
if dial == nil {
|
||||
dial = safeDial
|
||||
}
|
||||
timeout := f.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: dial,
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
|
||||
ForceAttemptHTTP2: false,
|
||||
},
|
||||
Timeout: timeout,
|
||||
CheckRedirect: limitRedirects,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套
|
||||
// SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。
|
||||
// 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。
|
||||
func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
|
||||
return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes)
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("linkmeta: bad url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, "", fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, "", errors.New("linkmeta: empty host")
|
||||
}
|
||||
client := f.client()
|
||||
// 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用
|
||||
if f.Timeout <= 0 {
|
||||
client.Timeout = 30 * time.Second
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("linkmeta: fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("linkmeta: status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("linkmeta: read: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(data)
|
||||
}
|
||||
return data, ct, nil
|
||||
}
|
||||
|
||||
func limitRedirects(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > maxRedirects {
|
||||
return fmt.Errorf("linkmeta: too many redirects")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("linkmeta: redirect to %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeDial 解析域名后逐个筛掉内网地址,再直接拨那个 IP:
|
||||
// 校验和连接之间不再重新解析,DNS 重绑定就没有窗口。
|
||||
func safeDial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d net.Dialer
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if blocked(ip) {
|
||||
return nil, fmt.Errorf("linkmeta: %s is not a public address", ip)
|
||||
}
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
ips, err := d.Resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
for _, ia := range ips {
|
||||
if blocked(ia.IP) {
|
||||
continue
|
||||
}
|
||||
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("linkmeta: no public address for %s", host)
|
||||
}
|
||||
|
||||
// blocked 报告 IP 是否属于不该由本站去连的地址段。
|
||||
func blocked(ip net.IP) bool {
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() {
|
||||
return true
|
||||
}
|
||||
// 运营商级 NAT 与 6to4 前缀:IsPrivate 不覆盖,但同样不该出现在公网抓取里
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 // 100.64.0.0/10
|
||||
}
|
||||
if ip.To16() != nil {
|
||||
return ip[0] == 0x20 || ip[0] == 0x3f // 2001::/32 Teredo、3ffe::/16 等保留段
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package linkmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFirstURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"看看 https://example.com/a?x=1 这个": "https://example.com/a?x=1",
|
||||
"[Go 语言](https://go.dev/doc) 官方文档": "https://go.dev/doc",
|
||||
"结尾标点要剪掉 https://a.cn/page。": "https://a.cn/page",
|
||||
"没有链接就是空": "",
|
||||
"两个 https://first.cn https://second.cn": "https://first.cn",
|
||||
"裸 www.example.com 不算": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := FirstURL(in); got != want {
|
||||
t.Errorf("FirstURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
doc := `<html><head>
|
||||
<title>兜底标题</title>
|
||||
<meta name="description" content="兜底描述">
|
||||
<meta property="og:title" content="OG 标题">
|
||||
<meta content="OG 描述" property="og:description">
|
||||
<meta name="og:site_name" content="示例站">
|
||||
<meta property="og:image" content="/pics/a.png">
|
||||
</head><body><p>正文里的 <b>标签</b> 不该被当成标题</p></body></html>`
|
||||
base, _ := url.Parse("https://example.com/post/1")
|
||||
c := parse(doc, base)
|
||||
|
||||
if c.Title != "OG 标题" {
|
||||
t.Errorf("title=%q 应优先 og:title", c.Title)
|
||||
}
|
||||
if c.Desc != "OG 描述" {
|
||||
t.Errorf("desc=%q", c.Desc)
|
||||
}
|
||||
if c.Site != "示例站" {
|
||||
t.Errorf("site=%q", c.Site)
|
||||
}
|
||||
if c.Image != "https://example.com/pics/a.png" {
|
||||
t.Errorf("相对图片未补全: %q", c.Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFallsBackToTitleTag(t *testing.T) {
|
||||
base, _ := url.Parse("https://example.com/")
|
||||
c := parse(`<html><head><title> 只有
|
||||
标题 </title></head></html>`, base)
|
||||
if c.Title != "只有 标题" {
|
||||
t.Errorf("title=%q", c.Title)
|
||||
}
|
||||
if c.Site != "example.com" {
|
||||
t.Errorf("site=%q 应退回主机名", c.Site)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnescapesEntities(t *testing.T) {
|
||||
base, _ := url.Parse("https://example.com/")
|
||||
c := parse(`<head><meta property="og:title" content="Tom & Jerry "quoted""></head>`, base)
|
||||
if c.Title != `Tom & Jerry "quoted"` {
|
||||
t.Errorf("title=%q", c.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedAddresses(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"127.0.0.1": true,
|
||||
"10.0.3.5": true,
|
||||
"172.16.0.1": true,
|
||||
"192.168.1.1": true,
|
||||
"169.254.169.254": true, // 云元数据
|
||||
"100.64.0.1": true, // CGNAT
|
||||
"::1": true,
|
||||
"fe80::1": true,
|
||||
"fc00::1": true, // ULA
|
||||
"93.184.216.34": false,
|
||||
"2606:2800:220:1:248:1893:25c8:1946": false,
|
||||
}
|
||||
for s, wantBlocked := range want {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad test ip %q", s)
|
||||
}
|
||||
if got := blocked(ip); got != wantBlocked {
|
||||
t.Errorf("blocked(%s) = %v, want %v", s, got, wantBlocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认拨号器必须拒掉回环地址——httptest 的服务就在 127.0.0.1,
|
||||
// 这条同时验证了「防护生效」和「测试用的注入通道确实是必要的」。
|
||||
func TestDefaultFetchRejectsLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<html><head><title>x</title></head></html>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Fetch(context.Background(), srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("默认 Fetcher 竟然后到了回环地址,SSRF 防护形同虚设")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a public address") && !strings.Contains(err.Error(), "no public address") {
|
||||
t.Fatalf("err=%v,应因内网地址被拒", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherParsesRealPage(t *testing.T) {
|
||||
var gotUA, gotAccept string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUA, gotAccept = r.Header.Get("User-Agent"), r.Header.Get("Accept")
|
||||
if r.URL.Path == "/nope" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<html><head><meta property="og:title" content="标题"><meta property="og:image" content="https://cdn.example/i.png"></head>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}, Timeout: 2 * time.Second}
|
||||
|
||||
c, err := f.Fetch(context.Background(), srv.URL+"/page")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Title != "标题" || c.Image != "https://cdn.example/i.png" {
|
||||
t.Fatalf("card=%+v", c)
|
||||
}
|
||||
if !strings.HasPrefix(c.URL, srv.URL) {
|
||||
t.Errorf("url=%q", c.URL)
|
||||
}
|
||||
if gotUA != userAgent {
|
||||
t.Errorf("ua=%q", gotUA)
|
||||
}
|
||||
if !strings.Contains(gotAccept, "text/html") {
|
||||
t.Errorf("accept=%q", gotAccept)
|
||||
}
|
||||
if _, err := f.Fetch(context.Background(), srv.URL+"/nope"); err == nil {
|
||||
t.Error("404 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsNonHTMLAndBadScheme(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte("binary"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}}
|
||||
if _, err := f.Fetch(context.Background(), srv.URL); err == nil {
|
||||
t.Error("非 HTML 应拒绝")
|
||||
}
|
||||
if _, err := f.Fetch(context.Background(), "ftp://example.com/x"); err == nil {
|
||||
t.Error("非 http(s) 协议应拒绝")
|
||||
}
|
||||
if _, err := f.Fetch(context.Background(), "not a url"); err == nil {
|
||||
t.Error("坏 URL 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardEmpty(t *testing.T) {
|
||||
if !(&Card{URL: "https://x.cn"}).Empty() {
|
||||
t.Error("只有 URL 不算有内容")
|
||||
}
|
||||
if (&Card{URL: "https://x.cn", Title: "t"}).Empty() {
|
||||
t.Error("有标题不该判空")
|
||||
}
|
||||
var nilCard *Card
|
||||
if !nilCard.Empty() {
|
||||
t.Error("nil 应判空")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package linkmeta
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
headRe = regexp.MustCompile(`(?is)<head[^>]*>(.*?)</head>`)
|
||||
metaRe = regexp.MustCompile(`(?is)<meta\b[^>]*?>`)
|
||||
attrRe = regexp.MustCompile(`(?is)\b(property|name|http-equiv|content)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>"']+))`)
|
||||
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// parse 从 HTML 里挑出卡片字段。优先 Open Graph,其次 Twitter Card,最后
|
||||
// 退到 <title> 与 description——绝大多数站点至少满足其中一个。
|
||||
func parse(doc string, base *url.URL) *Card {
|
||||
c := &Card{URL: base.String(), Site: base.Hostname()}
|
||||
|
||||
head := doc
|
||||
if m := headRe.FindStringSubmatch(doc); m != nil {
|
||||
head = m[1]
|
||||
} else if len(head) > 64<<10 {
|
||||
head = head[:64<<10] // 没有闭合 <head> 时也别整篇扫
|
||||
}
|
||||
|
||||
// 同一个 key 出现多次时先出现的赢(后面的往往是重复声明)
|
||||
meta := map[string]string{}
|
||||
for _, tag := range metaRe.FindAllString(head, -1) {
|
||||
key, val := metaTag(tag)
|
||||
if key == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := meta[key]; !seen {
|
||||
meta[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
c.Title = first(meta["og:title"], meta["twitter:title"])
|
||||
if c.Title == "" {
|
||||
if m := titleRe.FindStringSubmatch(head); m != nil {
|
||||
c.Title = clean(m[1])
|
||||
}
|
||||
}
|
||||
c.Desc = first(meta["og:description"], meta["twitter:description"], meta["description"])
|
||||
if site := meta["og:site_name"]; site != "" {
|
||||
c.Site = site
|
||||
}
|
||||
if img := first(meta["og:image"], meta["twitter:image"], meta["twitter:image:src"]); img != "" {
|
||||
c.Image = resolve(img, base)
|
||||
}
|
||||
// X(Twitter)对非白名单爬虫只回一张全站通用灰图占位符,
|
||||
// 拿它当封面只会显示一张无意义的底图——当作没有图处理。
|
||||
// 真推文图只发给 Twitterbot 等白名单 UA,服务端拿不到,不硬来。
|
||||
if strings.Contains(c.Image, "abs.twimg.com/rweb/ssr/default") {
|
||||
c.Image = ""
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// metaTag 从单个 <meta> 标签里取 (key, content),属性顺序不限。
|
||||
func metaTag(tag string) (string, string) {
|
||||
var key, val string
|
||||
for _, a := range attrRe.FindAllStringSubmatch(tag, -1) {
|
||||
v := first(a[3], a[4], a[5])
|
||||
switch strings.ToLower(a[1]) {
|
||||
case "property", "name", "http-equiv":
|
||||
key = strings.ToLower(v)
|
||||
case "content":
|
||||
val = v
|
||||
}
|
||||
}
|
||||
return key, clean(val)
|
||||
}
|
||||
|
||||
// clean 去标签、解实体、压空白:抓来的文本可能带内联标签或连续换行。
|
||||
func clean(s string) string {
|
||||
s = html.UnescapeString(tagRe.ReplaceAllString(s, " "))
|
||||
return strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
|
||||
}
|
||||
|
||||
// resolve 把相对的图片地址补成绝对 URL;解析不了就丢掉这个字段。
|
||||
func resolve(ref string, base *url.URL) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" || strings.HasPrefix(ref, "data:") {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base.ResolveReference(u).String()
|
||||
}
|
||||
|
||||
func first(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -29,6 +29,26 @@ type Post struct {
|
||||
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
|
||||
ContentLen int64 `json:"content_len"`
|
||||
Tags []string `json:"tags"`
|
||||
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
|
||||
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
|
||||
Images []string `json:"images"`
|
||||
// LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。
|
||||
// nil = 没有链接或抓取失败,前台不渲染卡片。
|
||||
LinkCard *LinkCard `json:"link_card,omitempty"`
|
||||
}
|
||||
|
||||
// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。
|
||||
type LinkCard struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// Empty 报告卡片有没有可展示的字段(只有 URL 不算)。
|
||||
func (c *LinkCard) Empty() bool {
|
||||
return c == nil || (c.Title == "" && c.Desc == "" && c.Image == "")
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
@@ -42,6 +62,10 @@ type PostInput struct {
|
||||
PublishedAt string `json:"published_at"`
|
||||
Tags []string `json:"tags"`
|
||||
ReadingMinutes *int `json:"reading_minutes"`
|
||||
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
|
||||
Images []string `json:"images"`
|
||||
// LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段)
|
||||
LinkCard *LinkCard `json:"link_card,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
@@ -100,6 +124,47 @@ type Page struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// File 是一条上传文件的索引行。内容本体在对象存储里(store 字段记来源:
|
||||
// r2 | local),key 是存储端的对象名,URL 由 API 层按「来源 + PublicBase」
|
||||
// 在响应时解析——切存储端不破坏存量链接。
|
||||
type File struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Mime string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Store string `json:"store"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// FilePage 是文件管理的分页容器(Items 用 File,与文章的 Page 区分开)。
|
||||
type FilePage struct {
|
||||
Items []File `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// FileRef 是「谁在用这个文件」的一处记录,供删除前的引用检查。
|
||||
// Kind 取 post / project / avatar;avatar 没有可跳转的行,ID 为 0。
|
||||
type FileRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
|
||||
type CommentPage struct {
|
||||
Items []Comment `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// Dashboard is the snapshot rendered on /admin (homepage).
|
||||
type Dashboard struct {
|
||||
TotalPosts int `json:"total_posts"`
|
||||
@@ -120,6 +185,13 @@ type MonthBucket struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// SocialLink 是站主在后台填写的社交 / 源码入口(label + url),
|
||||
// 前台渲染成图标或文字链接。icon 不入库 —— 由前端按 url 域名推导。
|
||||
type SocialLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteDesc string `json:"site_desc"`
|
||||
@@ -128,6 +200,13 @@ type Settings struct {
|
||||
FooterNote string `json:"footer_note"`
|
||||
ICPLicense string `json:"icp"`
|
||||
PostsPerPage int `json:"posts_per_page"`
|
||||
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links),
|
||||
// 解析失败/为空时前台拿到空数组,区块自动隐藏。
|
||||
SocialLinks []SocialLink `json:"social_links"`
|
||||
// 评论开关(comments_enabled):关着时前台整个评论区不渲染。
|
||||
CommentsEnabled bool `json:"comments_enabled"`
|
||||
// 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。
|
||||
CommentsReview bool `json:"comments_review"`
|
||||
// LightSkinID is the front-end skin used when the client (or system)
|
||||
// prefers light. Valid values: paper / sage / rose.
|
||||
// Dark side is fixed to ink for now — kept implicit so we can add
|
||||
@@ -147,4 +226,88 @@ type Settings struct {
|
||||
// Only injected for the vivid UI, and never on /admin. Always non-nil so
|
||||
// the JSON response is {} rather than null.
|
||||
CustomCSS map[string]string `json:"custom_css"`
|
||||
// CustomJS holds owner-authored JavaScript (analytics snippets like
|
||||
// Google Analytics / Plausible / Umami). Injected on every public page
|
||||
// of either UI, never on /admin. Stored raw — it's the owner's own code,
|
||||
// sanitizing it would only break the snippet.
|
||||
CustomJS string `json:"custom_js"`
|
||||
// AuthorAvatarKey 是站主头像在 files 表里的 key(不是 URL):换存储端不破坏
|
||||
// 存量链接,和 images/link_card 一样属于「存标识、读时解析」。
|
||||
// 故意不并进 UpdateSettings 的全量替换 —— 后台「站点设置」PUT 不该顺手清掉
|
||||
// 账户页设置的头像,两者写入路径分开。
|
||||
AuthorAvatarKey string `json:"author_avatar_key,omitempty"`
|
||||
// AuthorAvatarURL 由 API 层按 AuthorAvatarKey + 存储端解析出来,
|
||||
// 不落库;key 指向的文件已删除时为空串。
|
||||
AuthorAvatarURL string `json:"author_avatar_url"`
|
||||
}
|
||||
|
||||
// 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleReader = "reader"
|
||||
)
|
||||
|
||||
// Reader 是一条评论区身份:既包括站主(role=owner,provider=admin),
|
||||
// 也包括通过 GitHub / Google / Telegram 登录进来的访客(role=reader)。
|
||||
// Banned = 禁言中。
|
||||
type Reader struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
URL string `json:"url"`
|
||||
Banned bool `json:"banned"`
|
||||
// Role 区分站主与访客:绑定到 owner 的第三方身份登录时会话升级为管理员。
|
||||
Role string `json:"role"`
|
||||
// CommentCount 是累计评论数(后台用户列表展示用)
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserIdentity 是「这个账号绑定了哪个第三方身份」。
|
||||
// ExternUID 用各平台的稳定主键(GitHub 数字 id / Google sub / Telegram id),
|
||||
// 不用可改的用户名;Display 只是列表里给人看的标签。
|
||||
type UserIdentity struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Provider string `json:"provider"`
|
||||
ExternUID string `json:"extern_uid"`
|
||||
Display string `json:"display"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Passkey 是一把已注册的 WebAuthn 凭据。PublicKey 以 base64 存
|
||||
// (model 包保持零依赖,字节解码在 auth 层做);SignCount 用于检测
|
||||
// 凭据被克隆(计数回退即异常),LastUsedAt 给管理页显示「上次使用」。
|
||||
type Passkey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at"`
|
||||
}
|
||||
|
||||
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
|
||||
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"post_id"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
RootID int64 `json:"root_id"`
|
||||
User *Reader `json:"user"`
|
||||
UserID int64 `json:"user_id"`
|
||||
BodyMd string `json:"body_md"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
Status string `json:"status"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
EditedAt string `json:"edited_at"`
|
||||
Replies []Comment `json:"replies"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
// PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充)
|
||||
PostTitle string `json:"post_title"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器,
|
||||
// 后台登录与读者登录 / 评论写入共用同一实现。
|
||||
// 只计失败/写入这类「不该高频」的事件;键取连接层地址,
|
||||
// X-Forwarded-For 可伪造,不作为限速键。
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Window struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func New(max int, window time.Duration) *Window {
|
||||
return &Window{hits: make(map[string][]time.Time), max: max, window: window}
|
||||
}
|
||||
|
||||
// Blocked 报告 key 在窗口内是否已达上限。
|
||||
func (l *Window) Blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= l.max
|
||||
}
|
||||
|
||||
// Add 记录一次事件。
|
||||
func (l *Window) Add(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.recent(key, now), now)
|
||||
}
|
||||
|
||||
// Reset 清空 key 的计数(如登录成功后)。
|
||||
func (l *Window) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// recent 返回窗口内的事件时间;调用方必须持有 l.mu。
|
||||
func (l *Window) recent(key string, now time.Time) []time.Time {
|
||||
hs := l.hits[key]
|
||||
cut := now.Add(-l.window)
|
||||
i := 0
|
||||
for ; i < len(hs); i++ {
|
||||
if hs[i].After(cut) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > 0 {
|
||||
hs = hs[i:]
|
||||
l.hits[key] = hs
|
||||
}
|
||||
if len(hs) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
// SourceKey 取连接层来源地址(IPv6 去掉端口)。
|
||||
func SourceKey(r *http.Request) string {
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 盘古之白:在 CJK 与拉丁字母/数字之间补一个空格,中文混排的阅读观感
|
||||
// 差别很大(「把MVP搬进仓库」→「把 MVP 搬进仓库」)。
|
||||
// 在 HTML 层做:标签原样保留,<code>/<pre>/<script>/<style> 内的文本
|
||||
// 一律不动(代码语义容不得我们加空格),其余文本段做补空格。
|
||||
package render
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// code 上下文里的文本不处理
|
||||
var codeOpenRe = regexp.MustCompile(`(?i)<(code|pre|script|style|kbd|samp)\b[^>]*>`)
|
||||
var codeCloseRe = regexp.MustCompile(`(?i)</(code|pre|script|style|kbd|samp)>`)
|
||||
|
||||
// CJK 统一表意区 + 扩展A/兼容 + 日文假名已在 2E80-9FFF 覆盖,
|
||||
// 再补全角标点区(FF00-FFEF)与 CJK 标点(3000-303F)
|
||||
var cjkRe = regexp.MustCompile(`[\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}]`)
|
||||
var latinTailRe = regexp.MustCompile(`([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])([A-Za-z0-9_$@])`)
|
||||
var latinHeadRe = regexp.MustCompile(`([A-Za-z0-9+%,.;:!?%)\]])([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])`)
|
||||
|
||||
func isCJK(r rune) bool {
|
||||
return cjkRe.MatchString(string(r))
|
||||
}
|
||||
|
||||
// panguText 对纯文本做补空格。已经隔着空格的不动(正则天然要求相邻)。
|
||||
func panguText(s string) string {
|
||||
if !hasCJKAndLatin(s) {
|
||||
return s
|
||||
}
|
||||
prev := ""
|
||||
for prev != s {
|
||||
prev = s
|
||||
s = latinTailRe.ReplaceAllString(s, "$1 $2")
|
||||
s = latinHeadRe.ReplaceAllString(s, "$1 $2")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hasCJKAndLatin(s string) bool {
|
||||
var cjk, latin bool
|
||||
for _, r := range s {
|
||||
if isCJK(r) {
|
||||
cjk = true
|
||||
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
latin = true
|
||||
}
|
||||
if cjk && latin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PanguHTML 给渲染出的正文 HTML 做盘古之白。流式扫描:标签原样输出,
|
||||
// 普通文本段补空格,代码上下文里的文本跳过。
|
||||
func PanguHTML(html string) string {
|
||||
if !strings.Contains(html, "<") {
|
||||
return html
|
||||
}
|
||||
var b strings.Builder
|
||||
inCode := false
|
||||
for {
|
||||
loc := tagRe.FindStringIndex(html)
|
||||
if loc == nil {
|
||||
rest := html
|
||||
if !inCode {
|
||||
rest = panguText(rest)
|
||||
}
|
||||
b.WriteString(rest)
|
||||
return b.String()
|
||||
}
|
||||
head, tag := html[:loc[0]], html[loc[0]:loc[1]]
|
||||
if !inCode {
|
||||
head = panguText(head)
|
||||
}
|
||||
b.WriteString(head)
|
||||
b.WriteString(tag)
|
||||
if !inCode {
|
||||
if m := codeOpenRe.FindStringSubmatch(tag); m != nil {
|
||||
// 自闭合或行内 code 直接开着也按进/出配对处理(</code> 总会出现)
|
||||
inCode = true
|
||||
}
|
||||
} else if codeCloseRe.MatchString(tag) {
|
||||
inCode = false
|
||||
}
|
||||
html = html[loc[1]:]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package render
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPanguText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"把MVP搬进仓库": "把 MVP 搬进仓库",
|
||||
"今天写了3个小时代码": "今天写了 3 个小时代码",
|
||||
"使用Go和Vue3开发": "使用 Go 和 Vue3 开发",
|
||||
"纯中文没有混排": "纯中文没有混排",
|
||||
"已经隔了空格的 Go 语言": "已经隔了空格的 Go 语言",
|
||||
"数字100在前": "数字 100 在前",
|
||||
"英文后接标点。followed": "英文后接标点。 followed",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := panguText(in); got != want {
|
||||
t.Errorf("panguText(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanguHTML(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// 普通文本:处理
|
||||
"<p>把MVP搬进仓库</p>": "<p>把 MVP 搬进仓库</p>",
|
||||
// 行内 code:不动
|
||||
"<p>运行<code>go build ./...</code>即可</p>": "<p>运行<code>go build ./...</code>即可</p>",
|
||||
// pre 代码块:整个内部不动
|
||||
"<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>": "<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>",
|
||||
// 属性里不是文本:不动(src 含字母数字混合不受影响)
|
||||
`<img alt="测试图1" src="a1.png">`: `<img alt="测试图1" src="a1.png">`,
|
||||
// 嵌套:code 结束后恢复处理
|
||||
"<p>先<code>跑起来</code>再看结果100%</p>": "<p>先<code>跑起来</code>再看结果 100%</p>",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := PanguHTML(in); got != want {
|
||||
t.Errorf("PanguHTML(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalStore 把文件落在本地磁盘(DataDir/uploads),零配置兜底。
|
||||
// key 允许带「2026/09/」这类前缀,实现里做路径清洗防目录穿越。
|
||||
type LocalStore struct {
|
||||
Root string
|
||||
}
|
||||
|
||||
func NewLocal(root string) *LocalStore {
|
||||
return &LocalStore{Root: root}
|
||||
}
|
||||
|
||||
func (s *LocalStore) path(key string) (string, error) {
|
||||
clean := filepath.Clean("/" + strings.TrimPrefix(key, "/"))[1:] // 锚到根再去掉,防 ../
|
||||
if clean == "" || strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("bad key: %q", key)
|
||||
}
|
||||
return filepath.Join(s.Root, filepath.FromSlash(clean)), nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
dst, err := s.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (s *LocalStore) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
||||
dst, err := s.path(key)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f, err := os.Open(dst)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, st.Size(), nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Delete(ctx context.Context, key string) error {
|
||||
dst, err := s.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// R2Store 走 Cloudflare R2 的 S3 兼容 API。endpoint 是账户级端点
|
||||
// (https://<accountid>.r2.cloudflarestorage.com),R2 两种寻址都支持,
|
||||
// 这里用 path-style 最稳。
|
||||
//
|
||||
// 端点 URL 里的路径段(如果有)不交给 SDK —— path-style 下它会被折进
|
||||
// 对象 key,导致「数据库 key」和「实际对象 key」对不上(直链 404)。
|
||||
// 这里只取 scheme://host 当端点;路径段由 EndpointKeyPrefix 暴露给
|
||||
// 上传 handler 拼进 key,三处(存储 / 数据库 / 直链)从此一致。
|
||||
type R2Store struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewR2(endpoint, bucket, accessKey, secretKey string) (*R2Store, error) {
|
||||
if endpoint == "" || bucket == "" {
|
||||
return nil, fmt.Errorf("r2: endpoint/bucket required")
|
||||
}
|
||||
// R2 不认 region,SDK 又必须有——官方文档给的值就是 "auto"。
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion("auto"),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||
config.WithBaseEndpoint(StripEndpointPath(endpoint)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("r2: load aws config: %w", err)
|
||||
}
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
return &R2Store{client: client, bucket: bucket}, nil
|
||||
}
|
||||
|
||||
func (s *R2Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: r,
|
||||
ContentLength: aws.Int64(size),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *R2Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out.Body, *out.ContentLength, nil
|
||||
}
|
||||
|
||||
func (s *R2Store) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package storage 是文件上传的存储抽象。R2(S3 兼容 API)是第一个实现,
|
||||
// 本地磁盘是零配置兜底;以后接其他存储端 = 再写一个 BlobStore 实现,
|
||||
// 从 FromEnv 的选择逻辑里加一个分支,调用方(admin API / 公开路由)不变。
|
||||
//
|
||||
// URL 不在存储层决定:文件行里记 store 来源,读取时按「来源 + PublicBase」
|
||||
// 解析直链或后端代理路由,因此切换存储端不破坏存量链接。
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BlobStore 是对象存储的最小面。size 只是提示(S3 PutObject 需要预知长度,
|
||||
// 用临时文件或内存缓冲满足),实现方不得依赖它做校验——类型与大小上限
|
||||
// 在上传 handler 里统一把关。
|
||||
type BlobStore interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// FileURL 解析文件的公开访问地址:R2 上的文件且配了公开域名(PublicBase,
|
||||
// 尾部不带 /)走 CDN 直链;其余(本地兜底、或没配公开域名)走后端的
|
||||
// /uploads/key 流式路由。调用方传入文件行里的 store 来源。
|
||||
func FileURL(storeName, key, publicBase string) string {
|
||||
if storeName == "r2" && publicBase != "" {
|
||||
return publicBase + "/" + key
|
||||
}
|
||||
return "/uploads/" + key
|
||||
}
|
||||
|
||||
// EndpointKeyPrefix 提取端点 URL 里的路径段(去首尾斜杠)作为上传 key 的
|
||||
// 前缀:S3Api 写成 https://host/oss 时,对象 key 会带上 oss/ 前缀,
|
||||
// 数据库 / 直链 / 存储端三方都用这个前缀对齐。没有路径段返回空串。
|
||||
func EndpointKeyPrefix(endpoint string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(u.Path, "/")
|
||||
}
|
||||
|
||||
// StripEndpointPath 去掉端点 URL 的路径,只留 scheme://host[:port]:
|
||||
// SDK 的 BaseEndpoint 用它(路径段已经折进 key 前缀,见 EndpointKeyPrefix)。
|
||||
func StripEndpointPath(endpoint string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
if err != nil {
|
||||
return endpoint
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func TestEnsureOwner(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
owner, err := s.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner.Role != model.RoleOwner || owner.Provider != "admin" {
|
||||
t.Fatalf("owner row wrong: %+v", owner)
|
||||
}
|
||||
// 幂等:再取一次是同一行
|
||||
again, err := s.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.ID != owner.ID {
|
||||
t.Fatalf("重复 EnsureOwner 建了两行: %d vs %d", owner.ID, again.ID)
|
||||
}
|
||||
// 改了环境变量里的用户名,handle 要跟上
|
||||
got, err := s.EnsureOwner("newname")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != owner.ID || got.Handle != "newname" {
|
||||
t.Fatalf("handle 未同步: %+v", got)
|
||||
}
|
||||
// 全库只应有一个 owner
|
||||
all, err := s.ListReaders()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, r := range all {
|
||||
if r.Role == model.RoleOwner {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("owner 行数 = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerNotClobberedByReaderUpsert(t *testing.T) {
|
||||
// 站主行是 provider=admin,OAuth 登录建的是 provider=github 行,
|
||||
// 两者不能互相覆盖 —— 这是「站主能自定义资料」成立的前提。
|
||||
s := openTestStore(t)
|
||||
owner, err := s.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "someone", Name: "路人"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetOwner()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != owner.ID || got.Provider != "admin" {
|
||||
t.Fatalf("站主行被改动: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindIdentityGuards(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
owner, _ := s.EnsureOwner("admin")
|
||||
other, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "intruder", Name: "n"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重复绑同一个:幂等
|
||||
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
|
||||
t.Fatalf("重复绑定应幂等,got %v", err)
|
||||
}
|
||||
// 关键护栏:同一个外部账号不能被第二个用户占走
|
||||
if err := s.BindIdentity(other.ID, "github", "12345", "hijack"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("抢占他人身份应 ErrConflict, got %v", err)
|
||||
}
|
||||
// 换平台可以(UNIQUE 是 user_id+provider)
|
||||
if err := s.BindIdentity(owner.ID, "telegram", "999", "tg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ids, err := s.ListIdentities(owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("绑定数 = %d, want 2", len(ids))
|
||||
}
|
||||
// 登录查找:按外部身份找到站主,且 role 正确
|
||||
u, err := s.GetUserByIdentity("github", "12345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.ID != owner.ID || u.Role != model.RoleOwner {
|
||||
t.Fatalf("身份反查结果错: %+v", u)
|
||||
}
|
||||
if _, err := s.GetUserByIdentity("github", "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("未绑定应 ErrNotFound, got %v", err)
|
||||
}
|
||||
// 解绑
|
||||
if err := s.UnbindIdentity(owner.ID, "github"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetUserByIdentity("github", "12345"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal("解绑后仍能查到")
|
||||
}
|
||||
if err := s.UnbindIdentity(owner.ID, "github"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("重复解绑应 ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyLifecycle(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
owner, _ := s.EnsureOwner("admin")
|
||||
other, _ := s.UpsertReader(model.Reader{Provider: "github", Handle: "x", Name: "x"})
|
||||
|
||||
p, err := s.AddPasskey(model.Passkey{UserID: owner.ID, CredentialID: "cred-1", PublicKey: "base64key", Name: "MacBook", SignCount: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ID == 0 {
|
||||
t.Fatal("没拿到自增 id")
|
||||
}
|
||||
// 同一把凭据不能注册两次
|
||||
if _, err := s.AddPasskey(model.Passkey{UserID: other.ID, CredentialID: "cred-1", PublicKey: "k", Name: "dup"}); err == nil {
|
||||
t.Fatal("重复 credential_id 应被拒")
|
||||
}
|
||||
|
||||
list, err := s.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name != "MacBook" {
|
||||
t.Fatalf("list=%+v", list)
|
||||
}
|
||||
if list[0].PublicKey != "" {
|
||||
t.Fatal("列表接口不该回传公钥")
|
||||
}
|
||||
|
||||
got, err := s.GetPasskeyByCredentialID("cred-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PublicKey != "base64key" || got.SignCount != 3 {
|
||||
t.Fatalf("got=%+v", got)
|
||||
}
|
||||
|
||||
if err := s.TouchPasskey(got.ID, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := s.GetPasskeyByCredentialID("cred-1")
|
||||
if after.SignCount != 9 || after.LastUsedAt == "" {
|
||||
t.Fatalf("TouchPasskey 未生效: %+v", after)
|
||||
}
|
||||
|
||||
// 越权删除必须失败:id 是站主的,user_id 给别人
|
||||
if err := s.DeletePasskey(after.ID, other.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("跨用户删除应 ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := s.DeletePasskey(after.ID, owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rest, _ := s.ListPasskeys(owner.ID); len(rest) != 0 {
|
||||
t.Fatalf("删除后仍有 %d 条", len(rest))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func countKind(refs []model.FileRef, kind string) int {
|
||||
n := 0
|
||||
for _, r := range refs {
|
||||
if r.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 同一个 key 在正文里的三种形态都要认出来:R2 直链、本地 /uploads、缩略图路径。
|
||||
func TestFileReferencesMatchesKeyNotURL(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
const key = "2026/09/deadbeefcafe.png"
|
||||
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindLong, Title: "直链引用", Slug: "r2",
|
||||
CoverURL: "https://cdn.example.com/" + key,
|
||||
ContentMd: "正文配图 ",
|
||||
Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindShort, Slug: "local",
|
||||
ContentMd: "本地形态  和缩略图 ",
|
||||
Status: model.StatusDraft,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
refs, err := s.FileReferences(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 两篇文章各算一处:同一行里多次命中不重复计
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("want 2 refs, got %+v", refs)
|
||||
}
|
||||
if countKind(refs, "post") != 2 {
|
||||
t.Fatalf("两条都该是 post: %+v", refs)
|
||||
}
|
||||
// 草稿也要报出来(发布后就会图裂)
|
||||
if refs[1].Status != model.StatusDraft {
|
||||
t.Fatalf("草稿状态没带出来: %+v", refs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileReferencesProjectsAndAvatar(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
const key = "2026/09/111122223333.webp"
|
||||
|
||||
if _, err := s.CreateProject(model.ProjectInput{
|
||||
Title: "ONE", Slug: "one", CoverURL: "/uploads/" + key, Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSetting("owner_avatar_key", key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
refs, err := s.FileReferences(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if countKind(refs, "project") != 1 {
|
||||
t.Fatalf("项目封面没查到: %+v", refs)
|
||||
}
|
||||
if countKind(refs, "avatar") != 1 {
|
||||
t.Fatalf("站主头像没查到: %+v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// 没被引用、以及 key 为空时都不能误报——空 pattern 会 LIKE '%%' 命中全库。
|
||||
func TestFileReferencesNoFalsePositive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindLong, Title: "无关", Slug: "x",
|
||||
ContentMd: "正文里没有这张图", CoverURL: "/uploads/2026/09/aaaa.png",
|
||||
Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同内容不同月上传出的 key 只是月份段不同,不该互相命中
|
||||
refs, err := s.FileReferences("2026/10/aaaa.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("换月的 key 误报: %+v", refs)
|
||||
}
|
||||
|
||||
empty, err := s.FileReferences("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("空 key 不该匹配任何东西,得到 %+v", empty)
|
||||
}
|
||||
}
|
||||
+890
-21
File diff suppressed because it is too large.
Load diff
@@ -490,4 +490,3 @@ func TestProjectCRUD(t *testing.T) {
|
||||
t.Errorf("after delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式):
|
||||
// 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件;
|
||||
// 生成失败写 .failed 标记,1 小时内不反复重试。
|
||||
package thumbs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。
|
||||
MaxSrcBytes = 25 << 20 // 25MB
|
||||
// maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。
|
||||
maxDim = 10000
|
||||
// JPEGQuality 转码输出质量。
|
||||
JPEGQuality = 82
|
||||
// failedTTL 失败后的重试冷却。
|
||||
failedTTL = time.Hour
|
||||
)
|
||||
|
||||
// Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。
|
||||
func Supported(mime string) bool {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/gif":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通
|
||||
// (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG——
|
||||
// 缩略图不需要 alpha,透明区铺白底。
|
||||
func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
|
||||
return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
if cfg.Width <= w && Supported(mime) {
|
||||
return src, mime, true, nil
|
||||
}
|
||||
|
||||
var img image.Image
|
||||
switch mime {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(bytes.NewReader(src))
|
||||
case "image/png":
|
||||
img, err = png.Decode(bytes.NewReader(src))
|
||||
case "image/gif":
|
||||
img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画
|
||||
default:
|
||||
return nil, "", false, fmt.Errorf("unsupported mime %q", mime)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", false, nil
|
||||
}
|
||||
|
||||
// Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。
|
||||
// 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。
|
||||
func Resize(src image.Image, nw int) *image.RGBA {
|
||||
b := src.Bounds()
|
||||
ow, oh := b.Dx(), b.Dy()
|
||||
if nw <= 0 || nw >= ow {
|
||||
nw = ow
|
||||
}
|
||||
nh := (oh*nw + ow/2) / ow
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
flat := flatten(src, b)
|
||||
|
||||
for y := 0; y < nh; y++ {
|
||||
y0 := y * oh / nh
|
||||
y1 := (y+1)*oh/nh + 1
|
||||
if y1 > oh {
|
||||
y1 = oh
|
||||
}
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
for x := 0; x < nw; x++ {
|
||||
x0 := x * ow / nw
|
||||
x1 := (x+1)*ow/nw + 1
|
||||
if x1 > ow {
|
||||
x1 = ow
|
||||
}
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
var r, g, bl, a, n uint64
|
||||
for sy := y0; sy < y1; sy++ {
|
||||
row := sy * ow * 4
|
||||
for sx := x0; sx < x1; sx++ {
|
||||
i := row + sx*4
|
||||
al := uint64(flat[i+3])
|
||||
r += uint64(flat[i]) * al
|
||||
g += uint64(flat[i+1]) * al
|
||||
bl += uint64(flat[i+2]) * al
|
||||
a += al
|
||||
n++
|
||||
}
|
||||
}
|
||||
d := y*nw*4 + x*4
|
||||
if a == 0 || n == 0 {
|
||||
dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255
|
||||
continue
|
||||
}
|
||||
dst.Pix[d] = uint8(r / a)
|
||||
dst.Pix[d+1] = uint8(g / a)
|
||||
dst.Pix[d+2] = uint8(bl / a)
|
||||
dst.Pix[d+3] = uint8(a / n)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。
|
||||
func flatten(src image.Image, b image.Rectangle) []byte {
|
||||
w, h := b.Dx(), b.Dy()
|
||||
out := make([]byte, w*h*4)
|
||||
i := 0
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘
|
||||
pa := a >> 8
|
||||
if pa == 0 {
|
||||
out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0
|
||||
} else {
|
||||
out[i] = uint8(min255((r >> 8) * 0xff / pa))
|
||||
out[i+1] = uint8(min255((g >> 8) * 0xff / pa))
|
||||
out[i+2] = uint8(min255((bl >> 8) * 0xff / pa))
|
||||
out[i+3] = uint8(pa)
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func min255(v uint32) uint32 {
|
||||
if v > 0xff {
|
||||
return 0xff
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Store 是缩略图磁盘缓存:
|
||||
//
|
||||
// {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif)
|
||||
// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记
|
||||
//
|
||||
// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。
|
||||
// 缓存只增不减会慢慢吃磁盘,所以 Put 之后按 MaxBytes 做容量闸淘汰(LRU:
|
||||
// 命中时 Touch 更新 mtime,淘汰时先下手最久没人要的)。
|
||||
type Store struct {
|
||||
Dir string
|
||||
MaxBytes int64
|
||||
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
lastSweep time.Time
|
||||
sweeping bool
|
||||
}
|
||||
|
||||
// DefaultMaxBytes 是缓存总量上限:约 1700 张 150KB 的缩略图,
|
||||
// 远超单用户博客的实际存量,纯粹是防多年以后无限涨。
|
||||
const DefaultMaxBytes = 256 << 20
|
||||
|
||||
// sweepInterval 限制扫描频率——淘汰是后台维护,不该拖慢写缓存的请求。
|
||||
const sweepInterval = 10 * time.Minute
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{Dir: dir, MaxBytes: DefaultMaxBytes, locks: make(map[string]*sync.Mutex)}
|
||||
}
|
||||
|
||||
func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) }
|
||||
|
||||
func (s *Store) jpegPath(sha string, w int) string {
|
||||
return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w))
|
||||
}
|
||||
|
||||
func (s *Store) failedPath(sha string) string {
|
||||
return filepath.Join(s.dir(sha), sha[:32]+".failed")
|
||||
}
|
||||
|
||||
// FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。
|
||||
func (s *Store) FindCached(sha string, w int) string {
|
||||
base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg")
|
||||
for _, ext := range []string{".jpg", ".png", ".gif"} {
|
||||
if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() {
|
||||
return base + ext
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。
|
||||
func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) {
|
||||
p := s.jpegPath(sha, w)
|
||||
if mime != "image/jpeg" {
|
||||
p = strings.TrimSuffix(p, ".jpg") + extFor(mime)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
_ = os.Remove(s.failedPath(sha))
|
||||
s.maybeSweep()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Touch 把缓存文件的 mtime 拨到现在:命中一次就等于「最近还要用」,
|
||||
// 供容量闸按 LRU 淘汰。失败无所谓(只影响淘汰顺序)。
|
||||
func (s *Store) Touch(path string) {
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(path, now, now)
|
||||
}
|
||||
|
||||
// Purge 清掉某个原图(按内容哈希)的全部缩略图与失败标记。
|
||||
// 删除上传文件时调用,否则缓存会一直留着已删图片的产物。
|
||||
func (s *Store) Purge(sha string) {
|
||||
if len(sha) < 32 {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(s.dir(sha))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), sha[:32]) { // {sha}-{w}.jpg / {sha}.failed
|
||||
_ = os.Remove(filepath.Join(s.dir(sha), e.Name()))
|
||||
}
|
||||
}
|
||||
_ = os.Remove(s.dir(sha)) // 目录空了就收掉
|
||||
}
|
||||
|
||||
// FailedRecently 报告是否在冷却期(近期生成失败过)。
|
||||
func (s *Store) FailedRecently(sha string) bool {
|
||||
fi, err := os.Stat(s.failedPath(sha))
|
||||
return err == nil && time.Since(fi.ModTime()) < failedTTL
|
||||
}
|
||||
|
||||
// MarkFailed 落失败标记。
|
||||
func (s *Store) MarkFailed(sha string) {
|
||||
if err := os.MkdirAll(s.dir(sha), 0o755); err == nil {
|
||||
_ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
// Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。
|
||||
func (s *Store) Lock(sha string, w int) *sync.Mutex {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.locks) > lockCacheMax {
|
||||
clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针),
|
||||
} // 清掉的只是历史条目,最坏情况是同一图并发重生成一次
|
||||
k := fmt.Sprintf("%s-%d", sha[:32], w)
|
||||
l, ok := s.locks[k]
|
||||
if !ok {
|
||||
l = &sync.Mutex{}
|
||||
s.locks[k] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。
|
||||
const lockCacheMax = 4096
|
||||
|
||||
// maybeSweep 在写入新缓存后检查总量;超上限就按 mtime 从旧到新删,
|
||||
// 删到 3/4 水位(留滞回,避免每次写入都刚好卡在边界反复删)。
|
||||
// 每 sweepInterval 最多跑一次,且同一时刻只有一个在跑。
|
||||
func (s *Store) maybeSweep() {
|
||||
if s.MaxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.sweeping || time.Since(s.lastSweep) < sweepInterval {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.sweeping, s.lastSweep = true, time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.sweeping = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
var files []fileInfo
|
||||
var total int64
|
||||
_ = filepath.WalkDir(s.Dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// 半途而废的临时文件(进程被杀)直接清
|
||||
if strings.HasSuffix(path, ".tmp") {
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
total += fi.Size()
|
||||
files = append(files, fileInfo{path: path, size: fi.Size(), mtime: fi.ModTime()})
|
||||
return nil
|
||||
})
|
||||
if total <= s.MaxBytes {
|
||||
return
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) })
|
||||
lowWater := s.MaxBytes * 3 / 4
|
||||
for _, f := range files {
|
||||
if total <= lowWater {
|
||||
break
|
||||
}
|
||||
if err := os.Remove(f.path); err == nil {
|
||||
total -= f.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
path string
|
||||
size int64
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func extFor(mime string) string {
|
||||
switch mime {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
}
|
||||
return ".jpg"
|
||||
}
|
||||
|
||||
// ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。
|
||||
func ReadAllLimited(r io.Reader, max int64) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > max {
|
||||
return nil, fmt.Errorf("source exceeds %d bytes", max)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package thumbs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
// 左红右绿两半,方便验证盒均值没把通道混错
|
||||
c := color.RGBA{255, 0, 0, 255}
|
||||
if x >= w/2 {
|
||||
c = color.RGBA{0, 255, 0, 255}
|
||||
}
|
||||
if transparent {
|
||||
c = color.RGBA{0, 0, 0, 0}
|
||||
}
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestGenerateDownscale(t *testing.T) {
|
||||
src := makePNG(t, 800, 400, false)
|
||||
out, mime, pass, err := Generate(src, "image/png", 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pass {
|
||||
t.Fatal("800px 图在 w=400 不应直通")
|
||||
}
|
||||
if mime != "image/jpeg" {
|
||||
t.Fatalf("mime=%s", mime)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Width != 400 || cfg.Height != 200 {
|
||||
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
|
||||
r, g, _, _ := img.At(100, 100).RGBA()
|
||||
if r>>8 < 200 || g>>8 > 60 {
|
||||
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
|
||||
}
|
||||
r, g, _, _ = img.At(300, 100).RGBA()
|
||||
if g>>8 < 200 || r>>8 > 60 {
|
||||
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassThrough(t *testing.T) {
|
||||
src := makePNG(t, 300, 150, false)
|
||||
out, mime, pass, err := Generate(src, "image/png", 960)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
|
||||
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsGarbage(t *testing.T) {
|
||||
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
|
||||
t.Fatal("垃圾字节应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAlphaUnpremultiply(t *testing.T) {
|
||||
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
|
||||
flat := flatten(img, img.Bounds())
|
||||
if flat[0] < 240 || flat[3] != 128 {
|
||||
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepEvictsOldest(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个
|
||||
sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) }
|
||||
|
||||
// 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 跳过节流,让这次 Put 真的触发扫描
|
||||
s.lastSweep = time.Time{}
|
||||
oldest := time.Now().Add(-3 * time.Hour)
|
||||
for i := 0; i < 2; i++ { // 前两个假装很久没人访问
|
||||
os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest)
|
||||
}
|
||||
if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var total int64
|
||||
var kept []string
|
||||
filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
fi, _ := d.Info()
|
||||
total += fi.Size()
|
||||
kept = append(kept, d.Name())
|
||||
return nil
|
||||
})
|
||||
if total > s.MaxBytes {
|
||||
t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept)
|
||||
}
|
||||
if len(kept) >= 4 {
|
||||
t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept)
|
||||
}
|
||||
// 最新写入的那个必须还在
|
||||
if s.FindCached(sha(3), 480) == "" {
|
||||
t.Fatalf("刚写入的缓存被误删: %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeRemovesAllEntriesForFile(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
sha := strings.Repeat("f", 64)
|
||||
for _, w := range []int{480, 960} {
|
||||
if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.MarkFailed(sha)
|
||||
other := strings.Repeat("a", 64)
|
||||
if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s.Purge(sha)
|
||||
|
||||
for _, w := range []int{480, 960} {
|
||||
if p := s.FindCached(sha, w); p != "" {
|
||||
t.Fatalf("purge 后仍残留 %s", p)
|
||||
}
|
||||
}
|
||||
if s.FailedRecently(sha) {
|
||||
t.Fatal("失败标记没清掉")
|
||||
}
|
||||
if s.FindCached(other, 480) == "" {
|
||||
t.Fatal("误删了别的文件的缓存")
|
||||
}
|
||||
}
|
||||
+66
-2
@@ -18,9 +18,13 @@ import (
|
||||
|
||||
"oneblog/internal/admin"
|
||||
"oneblog/internal/api"
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/hub"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -46,12 +50,64 @@ func main() {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
|
||||
public := &api.API{Store: st, Cfg: cfg}
|
||||
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
|
||||
// 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。
|
||||
// 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。
|
||||
var blobs storage.BlobStore
|
||||
switch cfg.StorageDriver {
|
||||
case "r2":
|
||||
blobs, err = storage.NewR2(cfg.S3Endpoint, cfg.R2Bucket, cfg.R2AccessKey, cfg.R2SecretKey)
|
||||
if err != nil {
|
||||
log.Fatalf("storage: %v", err)
|
||||
}
|
||||
default:
|
||||
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
|
||||
}
|
||||
|
||||
// 评论区的读者会话(cookie one_reader),30 天有效
|
||||
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
|
||||
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
|
||||
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
|
||||
// 评论变更广播(SSE):公开端订阅、双端发布,共用同一实例
|
||||
commentHub := hub.New()
|
||||
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
|
||||
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
|
||||
// Passkey(WebAuthn):只有显式配了 ONE_WEBAUTHN_ORIGINS 才启用。
|
||||
// 没配就不构造 —— 相关端点保持 404,前端也不会显示入口。
|
||||
// 显示名固定 "ONE":站点标题存在 settings 表里,为浏览器弹窗去读库不值当。
|
||||
var passkeys *auth.Passkeys
|
||||
if cfg.WebauthnRPID != "" && len(cfg.WebauthnOrigins) > 0 {
|
||||
passkeys, err = auth.NewPasskeys("ONE", cfg.WebauthnRPID, cfg.WebauthnOrigins)
|
||||
if err != nil {
|
||||
log.Fatalf("passkeys: %v", err)
|
||||
}
|
||||
log.Printf("passkey 登录已启用 (rp_id=%s origins=%s)", cfg.WebauthnRPID, strings.Join(cfg.WebauthnOrigins, ","))
|
||||
}
|
||||
|
||||
public := &api.API{
|
||||
Store: st,
|
||||
Cfg: cfg,
|
||||
Blobs: blobs,
|
||||
Thumbs: thumbCache,
|
||||
ReaderSessions: readerSessions,
|
||||
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
|
||||
GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret},
|
||||
TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken},
|
||||
AdminSessions: adminSessions,
|
||||
Passkeys: passkeys,
|
||||
Hub: commentHub,
|
||||
}
|
||||
adminAPI := admin.NewAPI(st, cfg, adminSessions)
|
||||
adminAPI.Hub = commentHub
|
||||
adminAPI.Blobs = blobs
|
||||
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
|
||||
adminAPI.Passkeys = passkeys
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/api/admin/", adminAPI.Routes())
|
||||
public.Mount(root)
|
||||
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
|
||||
root.Handle("/uploads/thumb/", public.ThumbHandler())
|
||||
root.Handle("/uploads/", public.UploadsHandler())
|
||||
root.Handle("/", spaHandler(cfg.WebDist))
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -149,6 +205,14 @@ type statusRecorder struct {
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }
|
||||
|
||||
// Flush 透传给底层 writer——SSE 这类流式响应靠它判定可刷新,
|
||||
// 包装器不实现 Flusher 的话流式端点会直接不可用。
|
||||
func (s *statusRecorder) Flush() {
|
||||
if f, ok := s.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderPage() string {
|
||||
return `<!doctype html><meta charset="utf-8"><title>ONE</title>
|
||||
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b;
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
<meta name="theme-color" content="#1f1d1a" media="(prefers-color-scheme: dark)" />
|
||||
<title>ONE · 一个博客</title>
|
||||
<meta name="description" content="长文与短文,同一种节奏。" />
|
||||
<!-- 方案 A「一横」:SVG 优先(内部有 prefers-color-scheme,深浅皮肤各自变色),
|
||||
PNG 只给不认 SVG 的老浏览器;apple-touch-icon 必须 PNG(iOS 不支持 SVG,
|
||||
且会把透明压成黑底,所以那张铺了纸色实底)。 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
|
||||
<!--
|
||||
UI 版本在 <html> 上以 data-ui 表达,服务端设置要等 /api/site 才知道。
|
||||
|
||||
@@ -5,11 +5,16 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@milkdown/crepe": "^7.22.1",
|
||||
"dompurify": "^3.1.6",
|
||||
"vue": "^3.4.0",
|
||||
|
||||
Generated
+63
-48
@@ -8,6 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.11.1
|
||||
version: 6.11.1
|
||||
'@codemirror/lang-markdown':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/language':
|
||||
specifier: ^6.12.4
|
||||
version: 6.12.4
|
||||
'@codemirror/state':
|
||||
specifier: ^6.7.6
|
||||
version: 6.7.6
|
||||
'@codemirror/view':
|
||||
specifier: ^6.43.13
|
||||
version: 6.43.13
|
||||
'@milkdown/crepe':
|
||||
specifier: ^7.22.1
|
||||
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
|
||||
@@ -140,14 +155,14 @@ packages:
|
||||
'@codemirror/search@6.7.2':
|
||||
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
|
||||
|
||||
'@codemirror/state@6.7.5':
|
||||
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
|
||||
'@codemirror/state@6.7.6':
|
||||
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.43.12':
|
||||
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
|
||||
'@codemirror/view@6.43.13':
|
||||
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
@@ -1118,15 +1133,15 @@ snapshots:
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/commands@6.11.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
@@ -1147,7 +1162,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.6
|
||||
|
||||
@@ -1155,7 +1170,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/go': 1.0.1
|
||||
|
||||
@@ -1165,8 +1180,8 @@ snapshots:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.6
|
||||
'@lezer/html': 1.3.13
|
||||
@@ -1181,8 +1196,8 @@ snapshots:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/javascript': 1.5.5
|
||||
|
||||
@@ -1191,8 +1206,8 @@ snapshots:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1215,8 +1230,8 @@ snapshots:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1226,8 +1241,8 @@ snapshots:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/markdown': 1.7.2
|
||||
|
||||
@@ -1235,7 +1250,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/php': 1.0.6
|
||||
|
||||
@@ -1243,7 +1258,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/python': 1.1.19
|
||||
|
||||
@@ -1256,7 +1271,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/sass': 1.1.0
|
||||
|
||||
@@ -1264,7 +1279,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1289,8 +1304,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/xml': 1.0.6
|
||||
|
||||
@@ -1298,7 +1313,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1332,8 +1347,8 @@ snapshots:
|
||||
|
||||
'@codemirror/language@6.12.4':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1345,30 +1360,30 @@ snapshots:
|
||||
|
||||
'@codemirror/lint@6.9.7':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/search@6.7.2':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/state@6.7.5':
|
||||
'@codemirror/state@6.7.6':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.4
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.43.12':
|
||||
'@codemirror/view@6.43.13':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.4
|
||||
w3c-keyname: 2.2.8
|
||||
@@ -1550,11 +1565,11 @@ snapshots:
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4': {}
|
||||
|
||||
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
|
||||
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@floating-ui/dom': 1.8.0
|
||||
'@milkdown/core': 7.22.1
|
||||
'@milkdown/ctx': 7.22.1
|
||||
@@ -1594,10 +1609,10 @@ snapshots:
|
||||
'@codemirror/commands': 6.11.1
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language-data': 6.5.2
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.43.12
|
||||
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
|
||||
'@codemirror/view': 6.43.13
|
||||
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
|
||||
'@types/lodash-es': 4.17.12
|
||||
clsx: 2.1.1
|
||||
codemirror: 6.0.2
|
||||
@@ -1621,9 +1636,9 @@ snapshots:
|
||||
|
||||
'@milkdown/exception@7.22.1': {}
|
||||
|
||||
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
|
||||
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
|
||||
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
|
||||
'@milkdown/core': 7.22.1
|
||||
'@milkdown/ctx': 7.22.1
|
||||
'@milkdown/exception': 7.22.1
|
||||
@@ -2036,8 +2051,8 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/search': 6.7.2
|
||||
'@codemirror/state': 6.7.5
|
||||
'@codemirror/view': 6.43.12
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
|
||||
commander@15.0.0: {}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 358 B |
Binary file not shown.
|
After Width: | Height: | Size: 700 B |
@@ -1,32 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 128.81 128.17">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#gradient3);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="gradient3" x1="57.54" y1="31.44" x2="128.81" y2="31.44" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff4d4d"/>
|
||||
<stop offset=".99" stop-color="#f9cb28"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="b">
|
||||
<g>
|
||||
<path class="cls-2" d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"/>
|
||||
<path class="cls-2" d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"/>
|
||||
<polygon class="cls-1" points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"/>
|
||||
<polygon class="cls-2" points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"/>
|
||||
</g>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ONE">
|
||||
<!-- 方案 A「一横」:墨圆 + 一道白横,既是字母 O 也是汉字「一」。
|
||||
横杠用 fill-rule="evenodd" 挖成真洞(不是叠一块纸色矩形),
|
||||
所以它在任何底色上都是透明的——不需要为深浅皮肤各出一版。 -->
|
||||
<style>
|
||||
.m { fill: #33302b }
|
||||
@media (prefers-color-scheme: dark) { .m { fill: #e8e3d9 } }
|
||||
</style>
|
||||
<path class="m" fill-rule="evenodd" d="M32 4a28 28 0 1 0 0 56 28 28 0 1 0 0-56Zm-16 23.5h32a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H16a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 648 B |
+30
-1
@@ -1,13 +1,39 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import ThemeSwitcher from './components/ThemeSwitcher.vue'
|
||||
import ImgLightbox from './components/ImgLightbox.vue'
|
||||
import YohakuTop from './ui/yohaku/YohakuTop.vue'
|
||||
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
||||
import { site, setAdminScope } from './site'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
|
||||
function onCopyClick(e) {
|
||||
const btn = e.target.closest?.('.pre-copy')
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.pre-wrap')?.querySelector('pre')
|
||||
if (!pre) return
|
||||
navigator.clipboard
|
||||
.writeText(pre.innerText.replace(/\n$/, ''))
|
||||
.then(() => {
|
||||
btn.textContent = '已复制'
|
||||
btn.classList.add('done')
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制'
|
||||
btn.classList.remove('done')
|
||||
}, 1600)
|
||||
})
|
||||
.catch(() => {
|
||||
btn.textContent = '复制失败'
|
||||
setTimeout(() => (btn.textContent = '复制'), 1600)
|
||||
})
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onCopyClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
|
||||
|
||||
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
||||
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
|
||||
@@ -35,6 +61,9 @@ watch(
|
||||
<TopBar v-else-if="!isAdmin" />
|
||||
<RouterView />
|
||||
<YohakuFoot v-if="isVivid" />
|
||||
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
|
||||
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
|
||||
<ImgLightbox />
|
||||
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
|
||||
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
|
||||
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { isSupported as waSupported, register as waRegister } from '../webauthn'
|
||||
|
||||
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
|
||||
// (密码走环境变量、第三方身份绑定、passkey)。
|
||||
//
|
||||
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
|
||||
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
|
||||
|
||||
const acct = ref(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const regBusy = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
acct.value = await adminApi.account()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '读取账户信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
|
||||
// 提示一次就把参数抹掉,免得刷新页面重复弹。
|
||||
async function reportBindResult() {
|
||||
const p = route.query.bound
|
||||
if (typeof p !== 'string' || !p) return
|
||||
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
|
||||
async function save(patch) {
|
||||
saving.value = true
|
||||
try {
|
||||
acct.value = await adminApi.saveAccount(patch)
|
||||
toastOk('已保存')
|
||||
} catch (e) {
|
||||
toastErr(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickAvatar(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许连续选同一个文件
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const data = await adminApi.uploadFile(file)
|
||||
const f = Array.isArray(data) ? data[0] : null
|
||||
if (!f || !f.key) throw new Error('上传失败')
|
||||
await save({ avatar_key: f.key })
|
||||
} catch (e) {
|
||||
toastErr(e.message || '头像上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
await save({ avatar_key: '' })
|
||||
}
|
||||
|
||||
function startBind(provider) {
|
||||
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
|
||||
window.location.assign('/api/auth/' + provider + '/bind')
|
||||
}
|
||||
|
||||
async function unbind(provider) {
|
||||
const label = provider === 'github' ? 'GitHub' : provider
|
||||
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
|
||||
try {
|
||||
await adminApi.unbindIdentity(provider)
|
||||
toastOk('已解绑')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addPasskey() {
|
||||
if (!waSupported()) {
|
||||
toastErr('这个浏览器不支持 passkey')
|
||||
return
|
||||
}
|
||||
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
|
||||
if (name === null) return
|
||||
regBusy.value = true
|
||||
try {
|
||||
const { options, token } = await adminApi.passkeyBegin()
|
||||
const cred = await waRegister(options)
|
||||
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
|
||||
toastOk('passkey 已添加')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || 'passkey 注册失败')
|
||||
} finally {
|
||||
regBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskey(pk) {
|
||||
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
|
||||
try {
|
||||
await adminApi.deletePasskey(pk.id)
|
||||
toastOk('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
reportBindResult()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="loading">载入中…</section>
|
||||
<section v-else-if="acct">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
|
||||
<p class="field-hint" style="margin-bottom: 20px;">
|
||||
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
|
||||
<router-link to="/admin/settings">设置</router-link>。
|
||||
</p>
|
||||
|
||||
<!-- ---------- 资料 ---------- -->
|
||||
<div class="panel">
|
||||
<div class="panel-title"><h2>公开资料</h2></div>
|
||||
|
||||
<div class="field">
|
||||
<label>头像</label>
|
||||
<div class="avatar-row">
|
||||
<span class="avatar-box">
|
||||
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
|
||||
<span v-else class="avatar-ph">无</span>
|
||||
</span>
|
||||
<div class="avatar-acts">
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
|
||||
<button class="btn" :disabled="uploading" @click="fileInput.click()">
|
||||
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
|
||||
</button>
|
||||
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>昵称</label>
|
||||
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
|
||||
<p class="field-hint">评论区与文章署名用的名字。</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>简介</label>
|
||||
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>用户名(handle)</label>
|
||||
<input :value="acct.handle" class="input" disabled />
|
||||
<p class="field-hint">
|
||||
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
|
||||
它是账号的身份锚点,改了要连密码一起换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
|
||||
{{ saving ? '保存中…' : '保存资料' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录方式 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>密码</b>
|
||||
<span class="tag on">始终可用</span>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
|
||||
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
|
||||
删光所有 passkey,你仍然能登录进来。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>第三方账号</b>
|
||||
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
|
||||
<span v-else class="tag">未绑定</span>
|
||||
</div>
|
||||
<ul v-if="acct.identities.length" class="list">
|
||||
<li v-for="it in acct.identities" :key="it.id">
|
||||
<span>{{ providerLabel[it.provider] || it.provider }}</span>
|
||||
<code>{{ it.display }}</code>
|
||||
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
|
||||
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
|
||||
<div v-if="acct.providers.length" class="bind-acts">
|
||||
<button
|
||||
v-for="p in acct.providers"
|
||||
:key="p"
|
||||
class="btn btn-sm"
|
||||
:disabled="acct.identities.some((i) => i.provider === p)"
|
||||
@click="startBind(p)"
|
||||
>
|
||||
绑定 {{ providerLabel[p] || p }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
|
||||
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>Passkey</b>
|
||||
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
|
||||
<span v-else class="tag">未添加</span>
|
||||
</div>
|
||||
<ul v-if="acct.passkeys.length" class="list">
|
||||
<li v-for="pk in acct.passkeys" :key="pk.id">
|
||||
<span>{{ pk.name || '未命名设备' }}</span>
|
||||
<span class="when">
|
||||
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
|
||||
</span>
|
||||
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
|
||||
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
|
||||
</button>
|
||||
<p class="field-hint">
|
||||
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
|
||||
<template v-if="!acct.passkeys.length && !acct.providers.length">
|
||||
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.avatar-box {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--admin-sunken, #f3efe6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.avatar-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.avatar-ph {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.avatar-acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.method {
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--admin-line, #e6e0d4);
|
||||
}
|
||||
.method:first-of-type {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.method-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--admin-line, #e6e0d4);
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.tag.on {
|
||||
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
|
||||
color: var(--admin-accent, #3d7f9c);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.list code {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.when {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.bind-acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import ToastStack from './ToastStack.vue'
|
||||
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
|
||||
import { checkAuth } from './auth'
|
||||
import { adminApi, session } from '../api'
|
||||
@@ -59,6 +60,8 @@ const crumb = computed(() => {
|
||||
const p = route.path
|
||||
if (p.endsWith('/new')) return '写新的'
|
||||
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
|
||||
if (p.endsWith('/files')) return '文件'
|
||||
if (p.endsWith('/comments')) return '评论'
|
||||
if (p.endsWith('/tags')) return '标签'
|
||||
if (p.endsWith('/settings')) return '设置'
|
||||
if (p === '/admin' || p === '/admin/') return '总览'
|
||||
@@ -94,6 +97,7 @@ watch(() => route.path, () => loadCounts())
|
||||
|
||||
<template>
|
||||
<div v-if="ready" class="admin-shell">
|
||||
<ToastStack />
|
||||
<aside class="admin-side">
|
||||
<div class="brand">
|
||||
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
@@ -119,6 +123,14 @@ watch(() => route.path, () => loadCounts())
|
||||
<span class="ic">◈</span>
|
||||
<span>作品</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/files" class="item">
|
||||
<span class="ic">▣</span>
|
||||
<span>文件</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/comments" class="item">
|
||||
<span class="ic">❝</span>
|
||||
<span>评论</span>
|
||||
</RouterLink>
|
||||
<div class="group">操作</div>
|
||||
<RouterLink to="/admin/new" class="item">
|
||||
<span class="ic">✎</span>
|
||||
@@ -128,6 +140,10 @@ watch(() => route.path, () => loadCounts())
|
||||
<span class="ic">⚙</span>
|
||||
<span>设置</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/account" class="item">
|
||||
<span class="ic">◉</span>
|
||||
<span>账户</span>
|
||||
</RouterLink>
|
||||
<div class="group">前台</div>
|
||||
<a href="/" target="_blank" class="item">
|
||||
<span class="ic">↗</span>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { relativeDate, stripTags } from '../utils'
|
||||
|
||||
// 评论管理:待审队列优先,通过 / 删除 / 禁言都在行内完成。
|
||||
// 删除走软删(墓碑保楼层);禁言按读者维度,禁言中的行上有标记。
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const status = ref(route.query.status === 'any' || route.query.status === 'visible' ? route.query.status : 'pending')
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
const size = 20
|
||||
|
||||
let seq = 0
|
||||
async function load() {
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.comments({ status: status.value, page: page.value, size })
|
||||
if (my !== seq) return
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
if (my !== seq) return
|
||||
error.value = e.message || '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
watch([status, page], load)
|
||||
|
||||
function setStatus(v) {
|
||||
status.value = v
|
||||
router.replace({ query: { ...route.query, status: v === 'pending' ? undefined : v, page: 1 } })
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
await adminApi.approveComment(id)
|
||||
toastOk('已通过,前台可见')
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
|
||||
await adminApi.deleteComment(id)
|
||||
toastOk('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
async function banToggle(c) {
|
||||
const banned = !c.user?.banned
|
||||
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
|
||||
await adminApi.setReaderBanned(c.user?.id, banned)
|
||||
toastOk(banned ? '已禁言' : '已解除禁言')
|
||||
c.user = { ...(c.user || {}), banned }
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '待审', value: 'pending' },
|
||||
{ label: '已通过', value: 'visible' },
|
||||
{ label: '全部', value: 'any' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px;">
|
||||
评论 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="posts-toolbar" style="margin-bottom: 16px;">
|
||||
<button class="chip" :class="{ on: status === 'pending' }" @click="setStatus('pending')">待审</button>
|
||||
<button class="chip" :class="{ on: status === 'visible' }" @click="setStatus('visible')">已通过</button>
|
||||
<button class="chip" :class="{ on: status === 'any' }" @click="setStatus('any')">全部</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
{{ status === 'pending' ? '没有待审的评论。' : '还没有评论。' }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="c in items" :key="c.id" class="cm-row">
|
||||
<div class="cm-ava" aria-hidden="true">{{ (c.user?.name || '?').slice(0, 1) }}</div>
|
||||
<div class="cm-body-col">
|
||||
<div class="cm-row-head">
|
||||
<span class="cm-name" :title="c.user?.handle || ''">{{ c.user?.name || '匿名' }}</span>
|
||||
<span v-if="c.user?.banned" class="cm-flag">禁言中</span>
|
||||
<span v-if="c.status === 'pending'" class="cm-flag pend">待审</span>
|
||||
<span class="cm-time" :title="c.created_at">{{ relativeDate(c.created_at) }}</span>
|
||||
</div>
|
||||
<div class="cm-text">{{ stripTags(c.body_html) }}</div>
|
||||
<div class="cm-post">on {{ c.post_title || '无题' }}</div>
|
||||
</div>
|
||||
<div class="cm-acts">
|
||||
<a v-if="c.status === 'pending'" href="#" @click.prevent="approve(c.id)">通过</a>
|
||||
<a href="#" class="danger" @click.prevent="remove(c.id)">删除</a>
|
||||
<a href="#" @click.prevent="banToggle(c)">{{ c.user?.banned ? '解除禁言' : '禁言' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="pageCount > 1"
|
||||
aria-label="分页"
|
||||
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
|
||||
>
|
||||
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
|
||||
← 上一页
|
||||
</button>
|
||||
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 14px 4px;
|
||||
border-bottom: 1px solid var(--admin-line-soft);
|
||||
}
|
||||
|
||||
.cm-ava {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-paper-sunken);
|
||||
color: var(--admin-ink);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cm-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-name {
|
||||
font-weight: 600;
|
||||
color: var(--admin-ink);
|
||||
}
|
||||
|
||||
.cm-flag {
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-flag.pend {
|
||||
color: #9a5b45;
|
||||
border-color: #ddcdc2;
|
||||
}
|
||||
|
||||
.cm-time {
|
||||
color: var(--admin-faint);
|
||||
}
|
||||
|
||||
.cm-text {
|
||||
margin-top: 4px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--admin-ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cm-post {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--admin-faint);
|
||||
}
|
||||
|
||||
.cm-acts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.cm-acts a {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-acts a:hover {
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.cm-acts a.danger:hover {
|
||||
color: #b4553f;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { site } from '../site'
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
import { EditorView as CMView, keymap } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { markdown as markdownLang } from '@codemirror/lang-markdown'
|
||||
import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -141,6 +147,11 @@ async function initEditor() {
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
|
||||
},
|
||||
// 图片上传:wysiwyg 里的粘贴 / 拖拽 / 插图按钮全部走后台文件上传,
|
||||
// onUpload 返回 URL 后由 Crepe 自己插节点
|
||||
[CrepeFeature.ImageBlock]: {
|
||||
onUpload: (file) => uploadOne(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -179,6 +190,10 @@ onBeforeUnmount(() => {
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
if (cmView) {
|
||||
cmView.destroy()
|
||||
cmView = null
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- mode switching ----------
|
||||
@@ -190,6 +205,10 @@ async function switchMode(next) {
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
|
||||
await nextTick()
|
||||
mountCM()
|
||||
syncCM()
|
||||
} else {
|
||||
// MD → WYSIWYG: spin up crepe with the current markdown
|
||||
await nextTick()
|
||||
@@ -262,6 +281,9 @@ async function autosave() {
|
||||
function applySaved(saved) {
|
||||
form.slug = saved.slug
|
||||
form.status = saved.status
|
||||
// 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空)
|
||||
if (typeof saved.summary === 'string') form.summary = saved.summary
|
||||
if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url
|
||||
publishedLocal.value = isoToLocal(saved.published_at)
|
||||
}
|
||||
|
||||
@@ -298,7 +320,7 @@ function buildPayload() {
|
||||
async function save(status) {
|
||||
if (status) form.status = status
|
||||
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
|
||||
error.value = '先写点什么再保存'
|
||||
toastErr('先写点什么再保存')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
@@ -312,12 +334,17 @@ async function save(status) {
|
||||
const created = await adminApi.createPost(payload)
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
dirty.value = false
|
||||
toastOk('已创建')
|
||||
router.replace(`/admin/${created.id}`)
|
||||
return
|
||||
}
|
||||
dirty.value = false
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
|
||||
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
|
||||
toastOk(label + ' · ' + savedAt.value)
|
||||
} catch (e) {
|
||||
toastErr(e.message || '保存失败')
|
||||
error.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
@@ -360,6 +387,14 @@ onBeforeRouteLeave(() => {
|
||||
|
||||
// ---------- 标签 ----------
|
||||
|
||||
// 标签是长文的组织方式;切到短文时把已选的清掉(后端落库时也会强制丢弃)
|
||||
watch(
|
||||
() => form.kind,
|
||||
(k) => {
|
||||
if (k === 'short') form.tags = []
|
||||
}
|
||||
)
|
||||
|
||||
function addTag() {
|
||||
const v = tagInput.value.trim().replace(/[,,]$/, '')
|
||||
if (!v) return
|
||||
@@ -386,6 +421,82 @@ function clearCover() {
|
||||
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
|
||||
|
||||
const mdPane = ref(null)
|
||||
|
||||
// ---------- CodeMirror 源码模式 ----------
|
||||
// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
|
||||
// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
|
||||
// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
|
||||
let cmView = null
|
||||
const cmTheme = CMView.theme({
|
||||
'&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
|
||||
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
|
||||
'.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
|
||||
'.cm-line': { padding: '0 2px' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
|
||||
'.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
|
||||
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
|
||||
'.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
|
||||
'.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
|
||||
'.cm-emphasis': { fontStyle: 'italic' },
|
||||
'.cm-strong': { fontWeight: '700' },
|
||||
'.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
|
||||
})
|
||||
|
||||
function handleEditorFiles(files, view) {
|
||||
const list = [...files].filter((f) => f.type.startsWith('image/'))
|
||||
if (!list.length) return false
|
||||
view.dispatch({
|
||||
changes: { from: view.state.selection.main.from, insert: ' ' }
|
||||
})
|
||||
;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
function mountCM() {
|
||||
if (cmView || !mdPane.value) return
|
||||
cmView = new CMView({
|
||||
parent: mdPane.value,
|
||||
state: EditorState.create({
|
||||
doc: form.content_md,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([
|
||||
{ key: 'Mod-b', run: () => { tbBold(); return true } },
|
||||
{ key: 'Mod-i', run: () => { tbItalic(); return true } },
|
||||
{ key: 'Mod-e', run: () => { tbCode(); return true } },
|
||||
{ key: 'Mod-k', run: () => { openLink(); return true } }
|
||||
]),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
||||
markdownLang(),
|
||||
cmTheme,
|
||||
CMView.updateListener.of((u) => {
|
||||
if (u.docChanged) form.content_md = u.state.doc.toString()
|
||||
}),
|
||||
CMView.domEventHandlers({
|
||||
paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
|
||||
drop: (e, view) => {
|
||||
if (e.dataTransfer?.files?.length) {
|
||||
e.preventDefault()
|
||||
return handleEditorFiles(e.dataTransfer.files, view)
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
|
||||
function syncCM() {
|
||||
if (!cmView) return
|
||||
const cur = cmView.state.doc.toString()
|
||||
if (cur === form.content_md) return
|
||||
cmView.dispatch({
|
||||
changes: { from: 0, to: cur.length, insert: form.content_md }
|
||||
})
|
||||
}
|
||||
const showLinkInput = ref(false)
|
||||
const showImageInput = ref(false)
|
||||
const linkText = ref('')
|
||||
@@ -394,22 +505,31 @@ const imageUrl = ref('')
|
||||
const imageAlt = ref('')
|
||||
|
||||
function insertAtCursor(text, selStart, selEnd) {
|
||||
if (cmView) {
|
||||
if (selStart == null) {
|
||||
const s = cmView.state.selection.main
|
||||
selStart = s.from
|
||||
selEnd = s.to
|
||||
}
|
||||
cmView.dispatch({
|
||||
changes: { from: selStart, to: selEnd, insert: text },
|
||||
selection: { anchor: selStart + text.length }
|
||||
})
|
||||
cmView.focus()
|
||||
return
|
||||
}
|
||||
const before = form.content_md.slice(0, selStart)
|
||||
const after = form.content_md.slice(selEnd)
|
||||
form.content_md = before + text + after
|
||||
nextTick(() => {
|
||||
if (!mdPane.value) return
|
||||
const newPos = selStart + text.length
|
||||
mdPane.value.focus()
|
||||
mdPane.value.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
function withSelection(fn) {
|
||||
const el = mdPane.value
|
||||
const start = el ? el.selectionStart : form.content_md.length
|
||||
const end = el ? el.selectionEnd : form.content_md.length
|
||||
fn(start, end)
|
||||
if (cmView) {
|
||||
const sel = cmView.state.selection.main
|
||||
fn(sel.from, sel.to)
|
||||
return
|
||||
}
|
||||
fn(form.content_md.length, form.content_md.length)
|
||||
}
|
||||
|
||||
function tbBold() {
|
||||
@@ -494,6 +614,15 @@ function tbTask() {
|
||||
function tbHr() {
|
||||
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
|
||||
}
|
||||
function tbTable() {
|
||||
withSelection((s, e) =>
|
||||
insertAtCursor(
|
||||
'\n| 列 A | 列 B |\n| --- | --- |\n| 内容 | 内容 |\n| 内容 | 内容 |\n',
|
||||
s,
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
showLinkInput.value = true
|
||||
@@ -520,20 +649,201 @@ function commitImage() {
|
||||
showImageInput.value = false
|
||||
}
|
||||
|
||||
// ---------- 上传(封面 / 编辑器插图共用) ----------
|
||||
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
|
||||
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
// 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
|
||||
const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
|
||||
const MAX_UPLOAD_MB = 50
|
||||
|
||||
function validateFile(file) {
|
||||
const ext = (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!ALLOW_UPLOAD_EXT.includes(ext)) {
|
||||
return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
|
||||
return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
|
||||
async function uploadOne(file, opts = {}) {
|
||||
const bad = validateFile(file)
|
||||
if (bad) {
|
||||
toast(bad, 'err')
|
||||
throw new Error(bad)
|
||||
}
|
||||
const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
|
||||
try {
|
||||
const data = await adminApi.uploadFile(file, (p) =>
|
||||
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
|
||||
)
|
||||
const f = Array.isArray(data) ? data[0] : null
|
||||
if (!f || !f.url) throw new Error('上传失败')
|
||||
toastDone(tid, `${file.name} 上传完成`)
|
||||
if (opts.cmView) {
|
||||
const v = opts.cmView
|
||||
const pos = v.state.selection.main.from
|
||||
v.dispatch({ changes: { from: pos, insert: `\n` } })
|
||||
}
|
||||
return f.url
|
||||
} catch (e) {
|
||||
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
|
||||
error.value = e.message || '上传失败'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 外链图片转存 ----------
|
||||
// xLog 的 Cloud:正文里的外链 <img> 一键抓回自己的存储(后端走 SSRF
|
||||
// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
|
||||
const importing = ref(false)
|
||||
|
||||
function externalImages(md) {
|
||||
const own = [site.uploads_public_base, location.origin].filter(Boolean)
|
||||
const out = []
|
||||
const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
|
||||
let m
|
||||
while ((m = re.exec(md))) {
|
||||
const u = m[1]
|
||||
if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
|
||||
}
|
||||
return [...new Set(out)]
|
||||
}
|
||||
|
||||
async function importExternal() {
|
||||
if (importing.value) return
|
||||
const urls = externalImages(form.content_md)
|
||||
if (!urls.length) {
|
||||
toast('正文里没有外链图片', 'info')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
|
||||
try {
|
||||
const data = await adminApi.importFiles(urls)
|
||||
// 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
|
||||
const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
|
||||
const errs = data.errors || {}
|
||||
let done = 0
|
||||
let changed = false
|
||||
form.content_md = form.content_md.replace(
|
||||
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
|
||||
(whole, alt, u) => {
|
||||
if (!ok.has(u)) return whole
|
||||
done++
|
||||
toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
|
||||
changed = true
|
||||
return `.url})`
|
||||
}
|
||||
)
|
||||
if (changed) syncCM()
|
||||
const fail = Object.keys(errs).length
|
||||
if (fail) {
|
||||
toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
|
||||
} else {
|
||||
toastDone(tid, `转存完成:${done} 张已入自己的存储`)
|
||||
}
|
||||
} catch (e) {
|
||||
toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const coverInput = ref(null)
|
||||
async function onCoverPick(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
e.target.value = '' // 允许重复选同一个文件
|
||||
if (!file) return
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
form.cover_url = await uploadOne(file)
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const imageInput = ref(null)
|
||||
async function onImagePick(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
imageUrl.value = await uploadOne(file)
|
||||
if (!imageAlt.value) imageAlt.value = file.name.replace(/\.[^.]+$/, '')
|
||||
commitImage()
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// md 面板的粘贴 / 拖拽图片:上传后在原光标处插入 markdown。
|
||||
// 上传是异步的,先记光标位置,逐张插入时用 pos 追踪偏移。
|
||||
function captureSel() {
|
||||
const el = mdPane.value
|
||||
const s = el ? el.selectionStart : form.content_md.length
|
||||
const e = el ? el.selectionEnd : s
|
||||
return { s, e: Math.max(s, e) }
|
||||
}
|
||||
async function onMdFiles(files, sel) {
|
||||
let pos = sel.s
|
||||
for (const f of files) {
|
||||
try {
|
||||
const url = await uploadOne(f)
|
||||
const md = `\n`
|
||||
insertAtCursor(md, pos, pos)
|
||||
pos += md.length
|
||||
} catch (_) {
|
||||
/* uploadOne 已把错误写进 error */
|
||||
}
|
||||
}
|
||||
}
|
||||
function onMdPaste(e) {
|
||||
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'))
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
onMdFiles(files, captureSel())
|
||||
}
|
||||
function onMdDrop(e) {
|
||||
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type.startsWith('image/'))
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
onMdFiles(files, captureSel())
|
||||
}
|
||||
|
||||
function insertDate() {
|
||||
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
|
||||
}
|
||||
|
||||
// 工具栏按钮的统一定义(用作 v-for 渲染)
|
||||
// 快捷键的展示形态(xLog 同款):Mod 在 Mac 上显示 ⌘,其余显示 Ctrl
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform || '')
|
||||
function keyDisplay(shortcut) {
|
||||
if (!shortcut) return ''
|
||||
return '(' + shortcut
|
||||
.replace('Mod', isMac ? '⌘' : 'Ctrl')
|
||||
.replace(/-([a-z])$/i, (_, c) => c.toUpperCase()) + ')'
|
||||
}
|
||||
|
||||
const tbGroups = computed(() => [
|
||||
{
|
||||
label: '格式',
|
||||
items: [
|
||||
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
|
||||
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
|
||||
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
|
||||
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
|
||||
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
|
||||
{ key: 'b', title: '加粗', shortcut: 'Mod-b', run: tbBold, show: mode.value === 'md',
|
||||
icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
|
||||
{ key: 'i', title: '斜体', shortcut: 'Mod-i', run: tbItalic, show: mode.value === 'md',
|
||||
icon: 'M19 4h-9M14 20H5M15 4L9 20' },
|
||||
{ key: 's', title: '删除线', run: tbStrike, show: mode.value === 'md',
|
||||
icon: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16' },
|
||||
{ key: 'code', title: '行内代码', shortcut: 'Mod-e', run: tbCode, show: mode.value === 'md',
|
||||
icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
|
||||
{ key: 'cb', title: '代码块', run: tbCodeBlock, show: mode.value === 'md',
|
||||
icon: 'M9 10l-2 2.5L9 15M15 10l2 2.5-2 2.5M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -541,19 +851,29 @@ const tbGroups = computed(() => [
|
||||
items: [
|
||||
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
|
||||
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
|
||||
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
|
||||
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
|
||||
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
|
||||
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
|
||||
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
|
||||
{ key: 'q', title: '引用', run: tbQuote, show: mode.value === 'md',
|
||||
icon: 'M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1zM15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1z', fill: true },
|
||||
{ key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
|
||||
icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
|
||||
{ key: 'ol', title: '有序列表', run: tbOl, show: mode.value === 'md',
|
||||
icon: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1' },
|
||||
{ key: 'task', title: '任务列表', run: tbTask, show: mode.value === 'md',
|
||||
icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
|
||||
{ key: 'hr', title: '分隔线', run: tbHr, show: mode.value === 'md',
|
||||
icon: 'M5 12h14' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '插入',
|
||||
items: [
|
||||
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
|
||||
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
|
||||
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
|
||||
{ key: 'link', title: '链接', shortcut: 'Mod-k', run: openLink, show: mode.value === 'md',
|
||||
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
|
||||
{ key: 'img', title: '图片', run: openImage, show: mode.value === 'md',
|
||||
icon: 'M21 15l-5-5L5 21M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 8.5h.01' },
|
||||
{ key: 'table', title: '表格', run: tbTable, show: mode.value === 'md',
|
||||
icon: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 10h18M10 21V10' },
|
||||
{ key: 'date', title: '插入今天日期', run: insertDate, show: mode.value === 'md',
|
||||
icon: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -600,6 +920,14 @@ const tbGroups = computed(() => [
|
||||
<div class="editor-mode">
|
||||
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
|
||||
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="importing"
|
||||
title="把正文里的外链图片抓回自己的存储"
|
||||
@click="importExternal"
|
||||
>
|
||||
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -619,11 +947,18 @@ const tbGroups = computed(() => [
|
||||
:key="it.key"
|
||||
type="button"
|
||||
class="tb-btn"
|
||||
:class="{ italic: it.italic, mono: it.mono }"
|
||||
:title="it.title"
|
||||
:title="it.title + keyDisplay(it.shortcut)"
|
||||
:aria-label="it.title"
|
||||
@click="it.run()"
|
||||
>{{ it.label }}</button>
|
||||
>
|
||||
<svg v-if="it.icon" viewBox="0 0 24 24" aria-hidden="true"
|
||||
:fill="it.fill ? 'currentColor' : 'none'"
|
||||
:stroke="it.fill ? 'none' : 'currentColor'" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path :d="it.icon" />
|
||||
</svg>
|
||||
<span v-else class="tb-text">{{ it.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -670,20 +1005,25 @@ const tbGroups = computed(() => [
|
||||
aria-label="图片 URL"
|
||||
@keydown.enter="commitImage"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="commitImage">插入</button>
|
||||
<button class="btn" @click="showImageInput = false">取消</button>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn btn-primary" style="flex: 1;" @click="commitImage">插入</button>
|
||||
<button class="btn" style="flex: 1;" :disabled="uploadingImage" @click="imageInput.click()">
|
||||
{{ uploadingImage ? '上传中…' : '上传' }}
|
||||
</button>
|
||||
<button class="btn" @click="showImageInput = false">取消</button>
|
||||
</div>
|
||||
<input ref="imageInput" type="file" accept="image/*" hidden @change="onImagePick" />
|
||||
</div>
|
||||
|
||||
<!-- editor area -->
|
||||
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
|
||||
<textarea
|
||||
v-show="mode === 'md'"
|
||||
ref="mdPane"
|
||||
class="md-pane"
|
||||
v-model="form.content_md"
|
||||
placeholder="直接写 Markdown…"
|
||||
aria-label="Markdown 正文"
|
||||
></textarea>
|
||||
<!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
|
||||
<div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
|
||||
|
||||
<!-- 轻提示栈 -->
|
||||
<div class="toast-stack" aria-live="polite">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-stats">
|
||||
<span><strong>{{ cjkChars }}</strong> 汉字</span>
|
||||
@@ -715,15 +1055,21 @@ const tbGroups = computed(() => [
|
||||
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
|
||||
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
|
||||
</div>
|
||||
<input
|
||||
id="cover"
|
||||
v-model="form.cover_url"
|
||||
placeholder="封面图 URL(可省)"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
spellcheck="false"
|
||||
aria-label="封面图 URL"
|
||||
/>
|
||||
<div class="cover-row">
|
||||
<input
|
||||
id="cover"
|
||||
v-model="form.cover_url"
|
||||
placeholder="封面图 URL(可省)"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
spellcheck="false"
|
||||
aria-label="封面图 URL"
|
||||
/>
|
||||
<button class="btn" type="button" :disabled="uploadingImage" @click="coverInput.click()">
|
||||
{{ uploadingImage ? '上传中…' : '上传' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="coverInput" type="file" accept="image/*" hidden @change="onCoverPick" />
|
||||
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
|
||||
</div>
|
||||
|
||||
@@ -749,7 +1095,7 @@ const tbGroups = computed(() => [
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div v-if="form.kind !== 'short'" class="field">
|
||||
<label>标签</label>
|
||||
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">
|
||||
<span v-for="t in form.tags" :key="t" class="chip">
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { relativeDate } from '../utils'
|
||||
|
||||
// 文件管理:上传(点击 / 拖拽,多选)、网格缩略图、复制链接、删除。
|
||||
// 缩略图与链接都用后端解析好的 f.url(R2 直链或 /uploads 流式路由)。
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
const size = 24
|
||||
|
||||
const uploading = ref(false)
|
||||
const uploadError = ref('')
|
||||
const dragOver = ref(false)
|
||||
const copiedId = ref(0)
|
||||
const fileInput = ref(null)
|
||||
|
||||
let seq = 0
|
||||
async function load() {
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.files({ page: page.value, q: String(route.query.q || '') })
|
||||
if (my !== seq) return
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
if (my !== seq) return
|
||||
error.value = e.message || '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
watch(() => route.query.page, load)
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||
|
||||
// ---------- 上传 ----------
|
||||
async function upload(files) {
|
||||
if (!files || !files.length || uploading.value) return
|
||||
uploading.value = true
|
||||
uploadError.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('file', f)
|
||||
await adminApi.uploadFiles(fd)
|
||||
toastOk(`已上传 ${files.length} 个文件`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '上传失败')
|
||||
uploadError.value = e.message || '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
function onPick(e) {
|
||||
upload(e.target.files)
|
||||
e.target.value = '' // 允许重复选同一个文件
|
||||
}
|
||||
function onDrop(e) {
|
||||
dragOver.value = false
|
||||
upload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
async function copyUrl(f) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(f.url)
|
||||
toastOk('链接已复制')
|
||||
copiedId.value = f.id
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === f.id) copiedId.value = 0
|
||||
}, 2000)
|
||||
} catch (_) {
|
||||
// 非 https 环境没有 clipboard API:退回手动复制
|
||||
window.prompt('复制此链接:', f.url)
|
||||
}
|
||||
}
|
||||
|
||||
// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。
|
||||
// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。
|
||||
function refLabel(x) {
|
||||
if (x.kind === 'avatar') return '站主头像'
|
||||
const head = x.kind === 'project' ? '项目' : '文章'
|
||||
const name = x.title || x.slug || '未命名'
|
||||
return `${head}《${name}》${x.status === 'draft' ? '(草稿)' : ''}`
|
||||
}
|
||||
|
||||
async function remove(f) {
|
||||
// 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来,
|
||||
// 用户看完仍然要删才带 force=1 过去。
|
||||
let refs = []
|
||||
try {
|
||||
refs = (await adminApi.fileRefs(f.id)).items || []
|
||||
} catch (_) {
|
||||
// 查询失败就按无引用走:真被引用时后端会返回 409,不会静默删掉在用文件
|
||||
}
|
||||
let tip = `删除「${f.name}」?存储里的文件会一并删除。`
|
||||
if (refs.length) {
|
||||
const list = refs.slice(0, 3).map(refLabel).join('、')
|
||||
tip = `删除「${f.name}」?\n它正被 ${refs.length} 处引用:${list}${refs.length > 3 ? ' 等' : ''}。\n删掉这些地方会图裂。`
|
||||
}
|
||||
if (!window.confirm(tip)) return
|
||||
try {
|
||||
await adminApi.deleteFile(f.id, refs.length > 0)
|
||||
toastOk(`已删除「${f.name}」`)
|
||||
// 当前页删空时退一页,其余直接重载
|
||||
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
|
||||
else load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
error.value = e.message || '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 展示助手 ----------
|
||||
function humanSize(n) {
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
|
||||
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
|
||||
return (n / 1073741824).toFixed(2) + ' GB'
|
||||
}
|
||||
function ext(name) {
|
||||
const i = name.lastIndexOf('.')
|
||||
return i < 0 ? '文件' : name.slice(i + 1).toUpperCase()
|
||||
}
|
||||
|
||||
// 拖拽悬停态在离开窗口时复位
|
||||
function onWindowDragEnd() {
|
||||
dragOver.value = false
|
||||
}
|
||||
onMounted(() => window.addEventListener('dragend', onWindowDragEnd))
|
||||
onBeforeUnmount(() => window.removeEventListener('dragend', onWindowDragEnd))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px;">
|
||||
文件 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="dropzone"
|
||||
:class="{ over: dragOver, uploading }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="上传文件"
|
||||
@click="fileInput && fileInput.click()"
|
||||
@keydown.enter="fileInput && fileInput.click()"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input ref="fileInput" type="file" multiple hidden accept=".jpg,.jpeg,.png,.webp,.gif,.avif,.pdf,.zip,.txt" @change="onPick" />
|
||||
<span v-if="uploading">上传中…</span>
|
||||
<span v-else>点击选择或把文件拖到这里上传 · 图片 / pdf / zip / txt · 单文件 ≤ 50MB</span>
|
||||
</div>
|
||||
<p v-if="uploadError" class="upload-error" role="alert">{{ uploadError }}</p>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">还没有上传过文件。</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="file-grid">
|
||||
<div v-for="f in items" :key="f.id" class="file-card">
|
||||
<div class="thumb">
|
||||
<img
|
||||
v-if="f.mime.startsWith('image/')"
|
||||
:src="f.url"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span v-else class="ph" aria-hidden="true">{{ ext(f.name) }}</span>
|
||||
</div>
|
||||
<div class="name" :title="f.name">{{ f.name }}</div>
|
||||
<div class="meta">
|
||||
<span>{{ humanSize(f.size) }}</span>
|
||||
<span style="margin: 0 5px;" aria-hidden="true">·</span>
|
||||
<span :title="f.created_at">{{ relativeDate(f.created_at) }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="#" @click.prevent="copyUrl(f)">{{ copiedId === f.id ? '已复制 ✓' : '复制链接' }}</a>
|
||||
<a :href="f.url" target="_blank" rel="noopener">打开</a>
|
||||
<a href="#" class="danger" @click.prevent="remove(f)">删除</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="pageCount > 1"
|
||||
aria-label="分页"
|
||||
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
|
||||
>
|
||||
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
|
||||
← 上一页
|
||||
</button>
|
||||
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropzone {
|
||||
border: 1.5px dashed var(--admin-line);
|
||||
border-radius: 6px;
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: var(--admin-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.over {
|
||||
border-color: var(--admin-accent);
|
||||
background: color-mix(in srgb, var(--admin-accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.dropzone.uploading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
color: var(--admin-danger);
|
||||
font-size: 13px;
|
||||
margin: -8px 0 12px;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 6px;
|
||||
background: var(--admin-card);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
height: 110px;
|
||||
border-radius: 4px;
|
||||
background: var(--admin-paper-sunken);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb .ph {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--admin-faint);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--admin-muted);
|
||||
margin: 3px 0 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.actions a {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.actions a:hover {
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.actions a.danger:hover {
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import ToastStack from './ToastStack.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminApi, session } from '../api'
|
||||
import { site } from '../site'
|
||||
@@ -17,8 +19,10 @@ async function submit() {
|
||||
const data = await adminApi.login(username.value, password.value)
|
||||
session.user = username.value
|
||||
void data
|
||||
toastOk('登录成功')
|
||||
router.push('/admin')
|
||||
} catch (e) {
|
||||
toastErr('用户名或密码不对')
|
||||
error.value = '用户名或密码不对'
|
||||
} finally {
|
||||
busy.value = false
|
||||
@@ -27,6 +31,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastStack />
|
||||
<div class="page">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<p class="eyebrow">后台</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { formatDateShort, relativeDate } from '../utils'
|
||||
@@ -125,7 +126,7 @@ async function bulk(action) {
|
||||
await adminApi.bulkPosts([...selected.value], action)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '操作失败')
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ async function remove(id, title) {
|
||||
await adminApi.deletePost(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const projects = ref([])
|
||||
@@ -40,7 +41,7 @@ onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (!form.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
toastErr('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -56,7 +57,7 @@ async function create() {
|
||||
form.value = blank()
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '创建失败')
|
||||
toastErr(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ function cancelEdit() {
|
||||
|
||||
async function saveEdit() {
|
||||
if (!draft.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
toastErr('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -97,7 +98,7 @@ async function saveEdit() {
|
||||
editing.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '保存失败')
|
||||
toastErr(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
|
||||
await adminApi.updateProject(p.id, { status: next })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '更新失败')
|
||||
toastErr(e.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ async function remove(p) {
|
||||
await adminApi.deleteProject(p.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||||
import { SECTIONS } from '../ui/sections'
|
||||
@@ -11,14 +12,35 @@ const error = ref('')
|
||||
const saving = ref(false)
|
||||
const savedAt = ref('')
|
||||
|
||||
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
|
||||
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
|
||||
// ---------- 社交链接(settings.social_links <-> 多行文本) ----------
|
||||
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
|
||||
const socialText = ref('')
|
||||
|
||||
function socialToText(ls) {
|
||||
return (Array.isArray(ls) ? ls : [])
|
||||
.filter((l) => l && l.label && l.url)
|
||||
.map((l) => `${l.label} | ${l.url}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function textToSocial(text) {
|
||||
return String(text || '')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const i = line.indexOf('|')
|
||||
if (i < 0) return null
|
||||
const label = line.slice(0, i).trim()
|
||||
const url = line.slice(i + 1).trim()
|
||||
return label && url ? { label, url } : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
|
||||
const lightSkins = [
|
||||
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
|
||||
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
|
||||
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
|
||||
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
|
||||
]
|
||||
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
|
||||
const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
|
||||
|
||||
// ---------- 界面与自定义(第二套 UI) ----------
|
||||
|
||||
@@ -122,6 +144,9 @@ async function load() {
|
||||
if (!UI_CONSTANTS.UIS.includes(settings.value.ui_id)) {
|
||||
settings.value.ui_id = UI_CONSTANTS.DEFAULT
|
||||
}
|
||||
socialText.value = socialToText(settings.value.social_links)
|
||||
settings.value.comments_enabled = !!settings.value.comments_enabled
|
||||
settings.value.comments_review = !!settings.value.comments_review
|
||||
// 服务端保证非 null,这里再兜一层,并预建分区键,
|
||||
// 这样 v-model 绑定的文本框一开始就有值可写。
|
||||
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
|
||||
@@ -145,8 +170,10 @@ async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value.social_links = textToSocial(socialText.value)
|
||||
const s = await adminApi.saveSettings(settings.value)
|
||||
settings.value = s
|
||||
socialText.value = socialToText(s.social_links)
|
||||
// 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦
|
||||
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
|
||||
settings.value.custom_css = {}
|
||||
@@ -157,7 +184,9 @@ async function save() {
|
||||
}
|
||||
}
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
toastOk('设置已保存')
|
||||
} catch (e) {
|
||||
toastErr('保存失败:' + (e.message || ''))
|
||||
error.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
@@ -191,18 +220,17 @@ async function save() {
|
||||
<label>站点副标题</label>
|
||||
<input v-model="settings.site_desc" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>作者昵称</label>
|
||||
<input v-model="settings.author_name" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>作者简介</label>
|
||||
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
|
||||
</div>
|
||||
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
|
||||
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
|
||||
<div class="field">
|
||||
<label>页脚备注</label>
|
||||
<input v-model="settings.footer_note" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
作者昵称、简介与头像在
|
||||
<RouterLink to="/admin/account">账户</RouterLink>
|
||||
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- theme -->
|
||||
@@ -212,19 +240,12 @@ async function save() {
|
||||
<div class="field">
|
||||
<label>亮色皮肤</label>
|
||||
<div class="theme-swatches">
|
||||
<div
|
||||
v-for="sk in lightSkins"
|
||||
:key="sk.id"
|
||||
class="sw"
|
||||
:class="{ on: settings.light_skin_id === sk.id }"
|
||||
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
|
||||
@click="settings.light_skin_id = sk.id"
|
||||
>
|
||||
<span :style="{ background: sk.fg }">{{ sk.label }}</span>
|
||||
<div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
|
||||
<span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||||
访客在前台选择「自动」且系统偏好浅色时使用。
|
||||
亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -355,6 +376,45 @@ async function save() {
|
||||
<label>ICP 备案号</label>
|
||||
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>评论</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
|
||||
<input v-model="settings.comments_enabled" type="checkbox" />
|
||||
开放评论(关着时前台整个评论区不渲染)
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
|
||||
<input v-model="settings.comments_review" type="checkbox" />
|
||||
新评论需审核,通过后公开
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>社交链接(每行一条:名称 | 链接)</label>
|
||||
<textarea
|
||||
v-model="socialText"
|
||||
class="input"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
placeholder="GitHub | https://github.com/yourname 邮箱 | mailto:you@example.com"
|
||||
></textarea>
|
||||
<p class="field-hint">
|
||||
显示在经典版左栏「社交账号」和右栏页脚小字区。链接按域名自动配图标
|
||||
(GitHub / Telegram / X / 邮箱等),没有图标的显示名称文字。
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>自定义 JS 代码(统计脚本等)</label>
|
||||
<textarea
|
||||
v-model="settings.custom_js"
|
||||
class="input"
|
||||
rows="6"
|
||||
spellcheck="false"
|
||||
placeholder="<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script> <script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments)} gtag('js', new Date()); gtag('config', 'G-XXXX');<\/script>"
|
||||
></textarea>
|
||||
<p class="field-hint">
|
||||
贴完整的 <script> 片段(Google Analytics / Plausible / Umami 等),
|
||||
会在前台每个页面的 <head> 里执行,后台不注入。保存后刷新前台生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
|
||||
@@ -433,4 +493,11 @@ async function save() {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const tags = ref([])
|
||||
@@ -30,7 +31,7 @@ async function create() {
|
||||
newColor.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '创建失败')
|
||||
toastErr(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ async function update(t) {
|
||||
await adminApi.updateTag(t.id, { name: t.name, color: t.color })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '更新失败')
|
||||
toastErr(e.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ async function remove(t) {
|
||||
await adminApi.deleteTag(t.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ function cancelMerge() {
|
||||
async function doMerge(fromTag) {
|
||||
const toId = Number(mergeInto.value)
|
||||
if (!toId || toId === fromTag.id) {
|
||||
alert('选一个不同的目标标签')
|
||||
toastErr('选一个不同的目标标签')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
|
||||
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
|
||||
cancelMerge()
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '合并失败')
|
||||
toastErr(e.message || '合并失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
// 后台共享 toast 的渲染层:挂在 AdminLayout(所有管理页)与 LoginView。
|
||||
// 样式在全局 styles.css(.toast-stack / .toast),与编辑器共用一份。
|
||||
import { toasts } from './toast'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-stack" aria-live="polite">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 后台共享的轻提示:所有视图的操作反馈(成功/失败/进度)从这里发,
|
||||
// ToastStack.vue 负责渲染(挂在 AdminLayout 与 LoginView)。
|
||||
// EditorView 的上传/转存也走它——原先那套局部实现已并入。
|
||||
export const toasts = reactive([])
|
||||
let seq = 0
|
||||
|
||||
export function toast(text, type = 'info', ttl = 2600) {
|
||||
const id = ++seq
|
||||
toasts.push({ id, text, type })
|
||||
setTimeout(() => {
|
||||
const i = toasts.findIndex((t) => t.id === id)
|
||||
if (i >= 0) toasts.splice(i, 1)
|
||||
}, ttl)
|
||||
return id
|
||||
}
|
||||
|
||||
export function toastOk(text) {
|
||||
return toast(text, 'ok', 2200)
|
||||
}
|
||||
|
||||
export function toastErr(text) {
|
||||
return toast(text, 'err', 4200)
|
||||
}
|
||||
|
||||
// 更新一条已存在的 toast(上传进度用)
|
||||
export function toastUpdate(id, text, type = 'info') {
|
||||
const t = toasts.find((x) => x.id === id)
|
||||
if (t) {
|
||||
t.text = text
|
||||
t.type = type
|
||||
}
|
||||
}
|
||||
|
||||
// 标记成功并很快消失(进度条收尾)
|
||||
export function toastDone(id, text) {
|
||||
toastUpdate(id, text, 'ok')
|
||||
const idx = toasts.findIndex((t) => t.id === id)
|
||||
if (idx >= 0) {
|
||||
const t = toasts[idx]
|
||||
setTimeout(() => {
|
||||
const i = toasts.indexOf(t)
|
||||
if (i >= 0) toasts.splice(i, 1)
|
||||
}, 1600)
|
||||
}
|
||||
}
|
||||
+77
-1
@@ -82,7 +82,83 @@ export const adminApi = {
|
||||
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||||
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
|
||||
account: () => request('/api/admin/account'),
|
||||
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
|
||||
unbindIdentity: (provider) =>
|
||||
request('/api/admin/account/identities/' + encodeURIComponent(provider), { method: 'DELETE' }),
|
||||
passkeyBegin: () => request('/api/admin/account/passkeys/begin', { method: 'POST' }),
|
||||
passkeyFinish: (body) => request('/api/admin/account/passkeys/finish', { method: 'POST', body }),
|
||||
deletePasskey: (id) => request('/api/admin/account/passkeys/' + id, { method: 'DELETE' }),
|
||||
// 前台 passkey 登录(公开端点)
|
||||
passkeyLoginBegin: () => request('/api/auth/passkey/begin', { method: 'POST' }),
|
||||
passkeyLoginFinish: (body) => request('/api/auth/passkey/finish', { method: 'POST', body }),
|
||||
// ---------- 评论管理 ----------
|
||||
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
|
||||
approveComment: (id) =>
|
||||
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
|
||||
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
|
||||
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
|
||||
setReaderBanned: (id, banned) =>
|
||||
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
|
||||
// ---------- 文件上传 ----------
|
||||
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
|
||||
// 删除前查引用(被哪些文章/项目/站主头像用着);force 用于明知有引用仍要删
|
||||
fileRefs: (id) => request('/api/admin/files/' + id + '/refs'),
|
||||
deleteFile: (id, force = false) =>
|
||||
request('/api/admin/files/' + id + (force ? '?force=1' : ''), { method: 'DELETE' }),
|
||||
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
|
||||
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
|
||||
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
|
||||
uploadFile: (file, onProgress) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', base + '/api/admin/files')
|
||||
xhr.withCredentials = true
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||||
reject(new Error('未登录或登录已过期'))
|
||||
return
|
||||
}
|
||||
let data = {}
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText)
|
||||
} catch {}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
|
||||
else reject(new Error(data.error || `上传失败(${xhr.status})`))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
xhr.send(fd)
|
||||
}),
|
||||
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
|
||||
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
|
||||
uploadFiles: async (formData) => {
|
||||
const res = await fetch(base + '/api/admin/files', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||||
const err = new Error('未登录或登录已过期')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || `上传失败(${res.status})`)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
import { thumbURL } from '../utils'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
|
||||
// 作者头像位:站主设了头像就用图,没设就回落到站标「一横」。
|
||||
// 尺寸由外层容器决定(和 LogoMark 一样靠 width/height:100%)。
|
||||
//
|
||||
// 走缩略图路由:头像位最大也才 64px,没必要拉原图。
|
||||
// thumbURL 的签名是 (url, width) —— 公开域名由 site.js 经 setThumbBase 注入,
|
||||
// 不在参数里传。
|
||||
const url = computed(() => thumbURL(site.author_avatar_url, 128))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="url" class="author-avatar" :src="url" alt="" decoding="async" />
|
||||
<LogoMark v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.author-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
|
||||
|
||||
// 图片预览层(手机重做版):App.vue 全局挂一次。
|
||||
//
|
||||
// 手机上出过两次事故,这次的实现原则:
|
||||
// 1. 不碰 body 的 overflow——之前用 inline overflow 锁背景,把样式表的
|
||||
// overflow-x: clip 覆盖成 hidden,横向从「裁剪」变「可滚动容器」,
|
||||
// 整页被拖宽。现在滚动封锁靠遮罩自己的 @wheel/@touchmove.prevent:
|
||||
// 事件不落到页面里,背景自然滚不动,滚动位置也天然保留。
|
||||
// 2. 图片尺寸全部用百分比(max-width/height: 100%),不引入 vw/vh/dvh——
|
||||
// 这些单位在全面屏 URL 栏收展时各有各的坑,遮罩本身就是视口大小。
|
||||
// 3. 手势全归遮罩:touch-action: none + touchend 位移判滑动翻页,
|
||||
// 双击缩放/长按菜单/下拉刷新这些浏览器手势都不会来捣乱。
|
||||
// 4. 桌面能力不变:Esc / 方向键 / 点空白关闭 / 悬停箭头。
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') closeLightbox()
|
||||
if (e.key === 'ArrowLeft') stepLightbox(-1)
|
||||
if (e.key === 'ArrowRight') stepLightbox(1)
|
||||
}
|
||||
|
||||
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
|
||||
// (z-index 更高也拦不住)——开预览时整个藏掉
|
||||
watch(
|
||||
() => st.open,
|
||||
(v) => {
|
||||
if (v) window.addEventListener('keydown', onKey)
|
||||
else window.removeEventListener('keydown', onKey)
|
||||
document.body.classList.toggle('lb-open', v)
|
||||
}
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
document.body.classList.remove('lb-open')
|
||||
})
|
||||
|
||||
// 滑动翻页:横向位移超 44px 且明显大于纵向才算一次滑动
|
||||
let touchX = 0
|
||||
let touchY = 0
|
||||
function onTouchStart(e) {
|
||||
touchX = e.touches[0].clientX
|
||||
touchY = e.touches[0].clientY
|
||||
}
|
||||
function onTouchEnd(e) {
|
||||
if (st.list.length < 2) return
|
||||
const dx = e.changedTouches[0].clientX - touchX
|
||||
const dy = e.changedTouches[0].clientY - touchY
|
||||
if (Math.abs(dx) > 44 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||
stepLightbox(dx < 0 ? 1 : -1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="lb" :duration="220">
|
||||
<div
|
||||
v-if="st.open"
|
||||
class="lb-overlay"
|
||||
@click="closeLightbox"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
@wheel.prevent
|
||||
@touchmove.prevent
|
||||
>
|
||||
<img class="lb-img" :src="st.list[st.index]" alt="" @click.stop />
|
||||
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
|
||||
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
|
||||
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
|
||||
<span v-if="st.list.length > 1" class="lb-count">{{ st.index + 1 }} / {{ st.list.length }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 130;
|
||||
background: rgba(12, 11, 10, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
|
||||
.lb-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.lb-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-bottom: 4px;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.lb-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lb-nav.prev {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.lb-nav.next {
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.lb-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 22px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.lb-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lb-count {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.08em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
|
||||
@media (pointer: coarse) {
|
||||
.lb-img {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.lb-nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 22px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.lb-nav.prev {
|
||||
left: calc(6px + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.lb-nav.next {
|
||||
right: calc(6px + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.lb-close {
|
||||
top: calc(10px + env(safe-area-inset-top));
|
||||
right: calc(10px + env(safe-area-inset-right));
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.lb-count {
|
||||
bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.lb-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.lb-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.lb-enter-from,
|
||||
.lb-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +1,22 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
import { publicApi } from '../api'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { socialIcon } from '../socialIcons'
|
||||
import ThemeSwitcher from './ThemeSwitcher.vue'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
|
||||
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染,顺带省掉一次
|
||||
// 标签请求 —— 视图本身不用知道 UI 版本。
|
||||
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染。
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const tags = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isVivid.value) return
|
||||
try {
|
||||
const data = await publicApi.tags()
|
||||
tags.value = (data.tags || []).slice(0, 8)
|
||||
} catch (e) {
|
||||
tags.value = []
|
||||
}
|
||||
})
|
||||
// 社交 / 源码链接(后台「高级 → 社交链接」填写);url 必填,空的丢弃
|
||||
const social = computed(() => (site.social_links || []).filter((s) => s && s.url))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-if="!isVivid" class="rail-left">
|
||||
<div class="inner">
|
||||
<RouterLink to="/" class="brand">
|
||||
<span class="mark">○</span>
|
||||
<span class="mark"><LogoMark /></span>
|
||||
<span class="name">{{ site.site_title || 'ONE' }}</span>
|
||||
</RouterLink>
|
||||
<p class="desc">{{ site.site_desc }}</p>
|
||||
@@ -48,12 +39,23 @@ onMounted(async () => {
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div v-if="tags.length" class="tagbox">
|
||||
<p class="eyebrow">常读标签</p>
|
||||
<div class="tags">
|
||||
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
|
||||
{{ t.name }}
|
||||
</RouterLink>
|
||||
<!-- 社交账号(后台「高级 → 社交链接」填写):已知域名给图标,认不出显示名称 -->
|
||||
<div v-if="social.length" class="social-box">
|
||||
<p class="eyebrow">社交账号</p>
|
||||
<div class="social-row">
|
||||
<a
|
||||
v-for="s in social"
|
||||
:key="s.label + s.url"
|
||||
class="social-link"
|
||||
:href="s.url"
|
||||
:title="s.label"
|
||||
:aria-label="s.label"
|
||||
:target="s.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
|
||||
>
|
||||
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
|
||||
<span v-else>{{ s.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,9 +87,13 @@ onMounted(async () => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 原来这里是一个排版字符 ○ 充当标记,换成真站标。
|
||||
flex:none:它是 .brand(flex)的子项,不锁住会被长站名压成椭圆 */
|
||||
.mark {
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -142,18 +148,45 @@ onMounted(async () => {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tagbox {
|
||||
.social-box {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
.social-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--accent);
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
/* 认不出的域名没有图标,回落成名称文字 */
|
||||
.social-link span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.social-link svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.theme-box {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 链接预览卡片:正文里第一个外链在发布时由后端抓好(posts.link_card),
|
||||
// 这里只负责展示。没有可展示内容时父组件根本不会渲染它。
|
||||
//
|
||||
// 用 div[role=link] 而不是 <a>:vivid 的整条短文包在 RouterLink(一个 <a>)里,
|
||||
// 再嵌一个 <a> 是非法 HTML。也因此点击必须 preventDefault——
|
||||
// 只 stop 的话祖先 <a> 的默认激活行为照样会把点击变成跳转。
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const imgBroken = ref(false)
|
||||
|
||||
function open() {
|
||||
window.open(props.card.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="link-card"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:title="card.url"
|
||||
@click.prevent.stop="open"
|
||||
@keydown.enter.prevent="open"
|
||||
>
|
||||
<div class="lc-text">
|
||||
<span v-if="card.site" class="lc-site">{{ card.site }}</span>
|
||||
<span v-if="card.title" class="lc-title">{{ card.title }}</span>
|
||||
<span v-if="card.desc" class="lc-desc">{{ card.desc }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="card.image && !imgBroken"
|
||||
class="lc-img"
|
||||
:src="card.image"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="imgBroken = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 颜色一律写成「vivid 变量优先、classic 变量兜底」:同一份组件在两套 UI 里
|
||||
各归各的调色板,不必再维护第二份样式表。 */
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--v-line, var(--line-soft));
|
||||
border-radius: var(--v-radius-sm, 10px);
|
||||
background: var(--v-surface, var(--card));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
border-color: var(--v-line-strong, var(--accent-line));
|
||||
}
|
||||
|
||||
.link-card:focus-visible {
|
||||
outline: 2px solid var(--v-accent, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lc-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lc-site {
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--v-muted, var(--faint));
|
||||
/* 主机名是最典型的不可断行长 token(子域一长串),不给它 anywhere
|
||||
就会把卡片左列顶宽、整行溢出 */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lc-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
color: var(--v-text, var(--ink));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lc-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--v-text-soft, var(--muted));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lc-img {
|
||||
flex: 0 0 auto;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--v-radius-sm, 8px);
|
||||
background: var(--v-surface-2, var(--paper-sunken));
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.lc-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
// 站标「一横」:墨圆 + 一道白横 —— 字母 O 与汉字「一」同形,对应站名 ONE / 一个博客。
|
||||
//
|
||||
// 横杠是 fill-rule="evenodd" 挖出来的真洞,不是叠一块纸色矩形,所以任何底色
|
||||
// (纸感 / 墨色暗端 / 后台卡片)都直接透过去,不必为皮肤各出一版。
|
||||
// 颜色跟随 currentColor,尺寸由外层容器决定。
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="logo-mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M32 4a28 28 0 1 0 0 56 28 28 0 1 0 0-56Zm-16 23.5h32a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H16a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo-mark {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
// 两套 UI 共用的菜单开合钮:两条横线,open 时并成 ✕。颜色走 token
|
||||
// (--ink-soft / --accent 在 classic 和 vivid 里都有定义),尺寸 40px、
|
||||
// 触屏 44px。菜单开合的其余行为(锁滚动、Esc、路由收起)归各个顶栏管。
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false }
|
||||
})
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="nav-burger"
|
||||
type="button"
|
||||
:class="{ open }"
|
||||
:aria-expanded="open"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<i /><i />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-burger {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.nav-burger i {
|
||||
display: block;
|
||||
width: 17px;
|
||||
height: 1.6px;
|
||||
margin: 0 auto;
|
||||
border-radius: 2px;
|
||||
background: var(--ink-soft);
|
||||
transition: transform 0.3s var(--ease, ease), background-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-burger:hover i {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 两条线向中间并拢旋转成 ✕:位移 = (线高 + gap) / 2 */
|
||||
.nav-burger.open i:nth-child(1) {
|
||||
transform: translateY(3.3px) rotate(45deg);
|
||||
}
|
||||
.nav-burger.open i:nth-child(2) {
|
||||
transform: translateY(-3.3px) rotate(-45deg);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.nav-burger {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-burger i {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { site } from '../site'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
|
||||
import LinkCard from './LinkCard.vue'
|
||||
import AuthorAvatar from './AuthorAvatar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true }
|
||||
@@ -9,10 +15,18 @@ const props = defineProps({
|
||||
|
||||
const isShort = computed(() => props.post.kind === 'short')
|
||||
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
|
||||
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
|
||||
// 用户在选中文字时不跳转(复制内容是第一意图)。
|
||||
function openPost() {
|
||||
if (window.getSelection()?.toString()) return
|
||||
router.push(`/post/${props.post.slug}`)
|
||||
}
|
||||
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
|
||||
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
|
||||
const body = computed(() => {
|
||||
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
|
||||
return sanitizeHtml(props.post.content_html)
|
||||
return sanitizeHtml(thumbifyHtml(props.post.content_html))
|
||||
})
|
||||
|
||||
// 长文没写摘要时,从正文里截一段纯文本
|
||||
@@ -21,12 +35,13 @@ const summaryText = computed(() => {
|
||||
return text.length > 110 ? text.slice(0, 110) + '…' : text
|
||||
})
|
||||
|
||||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||||
// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰)
|
||||
const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="row-feed" :class="{ short: isShort }">
|
||||
<div class="avatar" aria-hidden="true">{{ initial }}</div>
|
||||
<article class="row-feed" :class="{ short: isShort }" @click="openPost">
|
||||
<div class="avatar"><AuthorAvatar /></div>
|
||||
|
||||
<div class="body">
|
||||
<div class="byline">
|
||||
@@ -42,13 +57,38 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
<h2 class="title">{{ post.title || '无题' }}</h2>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 长文:摘要;短文:正文直接铺开 -->
|
||||
<!-- 长文:摘要 + 封面媒体位;短文:正文直接铺开 -->
|
||||
<div v-if="isShort" class="prose short-body" v-html="body"></div>
|
||||
<div v-else class="summary">{{ summaryText }}</div>
|
||||
<template v-else>
|
||||
<div class="summary">{{ summaryText }}</div>
|
||||
<div v-if="post.cover_url" class="imgs cover">
|
||||
<img :src="thumbURL(post.cover_url, 640)" alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
|
||||
<div
|
||||
v-if="isShort && post.images && post.images.length"
|
||||
class="imgs"
|
||||
:class="'n' + Math.min(post.images.length, 4)"
|
||||
>
|
||||
<img
|
||||
v-for="(u, i) in post.images.slice(0, 4)"
|
||||
:key="i"
|
||||
:src="thumbURL(u, gridW)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@click.stop="openLightbox(post.images, i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
|
||||
|
||||
<div class="meta">
|
||||
<RouterLink :to="`/post/${post.slug}`" class="read">
|
||||
{{ isShort ? '查看' : '继续阅读' }} →
|
||||
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
|
||||
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
|
||||
</RouterLink>
|
||||
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
|
||||
<span v-if="post.tags && post.tags.length" class="tags">
|
||||
@@ -57,6 +97,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
:key="t"
|
||||
:to="`/tag/${encodeURIComponent(t)}`"
|
||||
class="tag-chip"
|
||||
@click.stop
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
@@ -76,6 +117,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
padding: 14px 30px;
|
||||
margin: 0 -16px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -83,18 +125,12 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
/* 站标本身就是个圆,所以原来那层 accent-soft 圆底 / 衬线首字母都不需要了,
|
||||
只留尺寸与颜色。想换回降饱和蓝只需改 color 一行。 */
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
user-select: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.byline {
|
||||
@@ -154,6 +190,57 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 短文配图:Twitter 式网格。1 张全宽(限高裁切),2/4 等分,3 张左大右二 */
|
||||
.imgs {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 10px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imgs img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.imgs.n1 img {
|
||||
max-height: 440px;
|
||||
}
|
||||
|
||||
.imgs.n2,
|
||||
.imgs.n4 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.imgs.n3 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.imgs.n2 img,
|
||||
.imgs.n4 img {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.imgs.n3 img {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.imgs.n3 img:first-child {
|
||||
grid-row: span 2;
|
||||
aspect-ratio: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 长文封面:与短文配图同款圆角媒体位,但点击语义是进文章而非预览 */
|
||||
.imgs.cover img {
|
||||
max-height: 340px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -164,7 +251,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
|
||||
.read {
|
||||
color: var(--faint);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.read:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -174,11 +267,19 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 出血必须跟 .shell 的页边距同步:styles.css 里 .shell 在 640px 断点从 20px
|
||||
收到 12px,这里就得同时从 -16px 收到 -12px。之前写的是 520px,于是
|
||||
521~640 这段(窄窗口 / 横屏手机)出血比父容器内边距多 4px,整页横向溢出。 */
|
||||
@media (max-width: 640px) {
|
||||
.row-feed {
|
||||
margin: 0 -12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.row-feed {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
padding: 16px 12px;
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { adminApi } from '../api'
|
||||
import AuthorAvatar from './AuthorAvatar.vue'
|
||||
|
||||
// Twitter 式短文发布盒,挂在时间线顶部。
|
||||
//
|
||||
// 只有管理员会话在场才渲染(adminApi.me 探测):发布走真实的
|
||||
// adminApi.createPost(kind: short —— 后端自动生成标题 / slug / 阅读时长)。
|
||||
// 读者侧的社交登录是评论区阶段二的产物,后端就绪前访客本来就无法
|
||||
// 「登录」,所以这里天然只有站主能看到;以后要放开给读者发,把探测和
|
||||
// 发布接口换成读者体系的即可,UI 不用动。
|
||||
//
|
||||
// 桌面(>640px):内联在时间线顶部;手机:右下角 FAB,点开底部上拉 sheet。
|
||||
// 两份 textarea 共享同一个 draft(v-model 同源),媒体查询切换可见性。
|
||||
// 发布后 emit('published'),由宿主刷新时间线。
|
||||
|
||||
const emit = defineEmits(['published'])
|
||||
|
||||
const shown = ref(false)
|
||||
const draft = ref('')
|
||||
const publishing = ref(false)
|
||||
const error = ref('')
|
||||
const sheetOpen = ref(false)
|
||||
|
||||
// 配图:选图即上传(走 /api/admin/files),最多 4 张,发布时随帖子带上
|
||||
const images = ref([]) // [{ url, name }]
|
||||
const MAX_IMAGES = 4
|
||||
const imgInput = ref(null)
|
||||
const imgUploading = ref(false)
|
||||
|
||||
function pickImages() {
|
||||
imgInput.value?.click()
|
||||
}
|
||||
async function onImagesPick(e) {
|
||||
const files = [...(e.target.files || [])].slice(0, MAX_IMAGES - images.value.length)
|
||||
e.target.value = '' // 允许分批续选
|
||||
if (!files.length) return
|
||||
imgUploading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('file', f)
|
||||
const data = await adminApi.uploadFiles(fd)
|
||||
for (const f of data) images.value.push({ url: f.url, name: f.name })
|
||||
} catch (e2) {
|
||||
error.value = e2.message || '图片上传失败'
|
||||
} finally {
|
||||
imgUploading.value = false
|
||||
}
|
||||
}
|
||||
function removeImage(i) {
|
||||
images.value.splice(i, 1)
|
||||
}
|
||||
|
||||
const canPost = computed(
|
||||
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const d = await adminApi.me()
|
||||
if (d.user) {
|
||||
shown.value = true
|
||||
}
|
||||
} catch {
|
||||
shown.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 系统会话过期(发布 401)时提示重新登录
|
||||
const expired = ref(false)
|
||||
|
||||
function onKey(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') publish()
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!canPost.value) return
|
||||
publishing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await adminApi.createPost({
|
||||
kind: 'short',
|
||||
content_md: draft.value.trim(),
|
||||
status: 'published',
|
||||
images: images.value.map((i) => i.url)
|
||||
})
|
||||
draft.value = ''
|
||||
images.value = []
|
||||
sheetOpen.value = false
|
||||
emit('published')
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('401') || String(e.message).includes('Unauthorized')) {
|
||||
expired.value = true
|
||||
error.value = '登录已过期,刷新页面重新登录后再发。'
|
||||
} else {
|
||||
error.value = e.message || '发布失败'
|
||||
}
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shown" class="qs-root">
|
||||
<!-- 桌面:内联 -->
|
||||
<div class="qs-inline">
|
||||
<div class="qs-card">
|
||||
<span class="qs-av"><AuthorAvatar /></span>
|
||||
<div class="qs-body">
|
||||
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
|
||||
<div v-if="images.length" class="qs-thumbs">
|
||||
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
|
||||
<img :src="im.url" alt="">
|
||||
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-acts">
|
||||
<button
|
||||
type="button"
|
||||
class="qs-img-btn"
|
||||
:disabled="images.length >= MAX_IMAGES || imgUploading"
|
||||
title="添加图片(最多 4 张)"
|
||||
aria-label="添加图片"
|
||||
@click="pickImages"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
||||
</button>
|
||||
<span v-if="error" class="qs-err">{{ error }}</span>
|
||||
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
|
||||
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
|
||||
{{ publishing ? '发布中…' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手机:FAB + 上拉 sheet -->
|
||||
<button type="button" class="qs-fab" aria-label="写短文" @click="sheetOpen = true">+</button>
|
||||
|
||||
<Transition name="qs-sheet">
|
||||
<div v-if="sheetOpen" class="qs-overlay">
|
||||
<div class="qs-scrim" @click="sheetOpen = false" />
|
||||
<div class="qs-sheet">
|
||||
<div class="qs-sheet-head">
|
||||
<span>写短文</span>
|
||||
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
|
||||
</div>
|
||||
<textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" />
|
||||
<div v-if="images.length" class="qs-thumbs">
|
||||
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
|
||||
<img :src="im.url" alt="">
|
||||
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-acts">
|
||||
<button
|
||||
type="button"
|
||||
class="qs-img-btn"
|
||||
:disabled="images.length >= MAX_IMAGES || imgUploading"
|
||||
title="添加图片(最多 4 张)"
|
||||
aria-label="添加图片"
|
||||
@click="pickImages"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
||||
</button>
|
||||
<span v-if="error" class="qs-err">{{ error }}</span>
|
||||
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
|
||||
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
|
||||
{{ publishing ? '发布中…' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qs-inline {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.qs-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* 站标自身是圆,去掉原来的 accent-soft 圆底与首字母字号;flex:none 防被压扁 */
|
||||
.qs-av {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.qs-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qs-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--ink);
|
||||
min-height: 44px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.qs-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.qs-acts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qs-tip {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.qs-err {
|
||||
font-size: 12.5px;
|
||||
color: #a64953;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.qs-send {
|
||||
flex: none;
|
||||
padding: 6px 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.qs-send:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 配图:缩略图条 + 添加按钮(最多 4 张,Twitter 同款) */
|
||||
.qs-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qs-thumb {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.qs-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qs-thumb .x {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.qs-img-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.qs-img-btn:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.qs-img-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.qs-img-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* 手机:FAB 常驻右下,点开上拉 sheet */
|
||||
.qs-fab {
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 72px;
|
||||
z-index: 80;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 10px 30px -8px rgba(28, 28, 30, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qs-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.qs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, #000 32%, transparent);
|
||||
}
|
||||
|
||||
.qs-sheet {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--card);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qs-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.qs-close {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.qs-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qs-fab {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.qs-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qs-sheet-enter-active,
|
||||
.qs-sheet-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* sheet 过渡(进:从底部长出来;出:沉回去) */
|
||||
.qs-sheet-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.qs-sheet-enter-active .qs-sheet {
|
||||
transition: transform 0.4s cubic-bezier(0.22, 1.2, 0.36, 1), opacity 0.3s ease;
|
||||
}
|
||||
.qs-sheet-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.qs-sheet-enter-from .qs-sheet {
|
||||
transform: translateY(48px);
|
||||
opacity: 0;
|
||||
}
|
||||
.qs-sheet-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.qs-sheet-leave-active .qs-sheet {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.qs-sheet-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.qs-sheet-leave-to .qs-sheet {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,81 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site } from '../site'
|
||||
import { relativeDate } from '../utils'
|
||||
import { socialIcon } from '../socialIcons'
|
||||
import { relativeDate, stripTags, thumbURL } from '../utils'
|
||||
|
||||
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
|
||||
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
|
||||
const props = defineProps({
|
||||
toc: { type: Array, default: () => [] }
|
||||
})
|
||||
const activeId = ref('')
|
||||
let tocObserver = null
|
||||
|
||||
function watchToc() {
|
||||
if (tocObserver) tocObserver.disconnect()
|
||||
if (!props.toc.length) return
|
||||
tocObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) activeId.value = e.target.id
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -65% 0px' }
|
||||
)
|
||||
nextTick(() => {
|
||||
for (const t of props.toc) {
|
||||
const el = document.getElementById(t.id)
|
||||
if (el) tocObserver.observe(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
watch(() => props.toc, watchToc, { deep: false })
|
||||
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
|
||||
|
||||
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
|
||||
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
|
||||
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
||||
|
||||
// 社交 / 源码链接(后台「高级 → 社交链接」填写);url 必填,空的丢弃。
|
||||
// 有图标的(按域名推导)渲染成单色图标,认不出的显示名称文字。
|
||||
const social = computed(() => (site.social_links || []).filter((s) => s && s.url && s.url !== PROJECT_REPO))
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: '首页' },
|
||||
{ to: '/archive', label: '归档' },
|
||||
{ to: '/tags', label: '标签' },
|
||||
{ to: '/projects', label: '作品' },
|
||||
{ to: '/about', label: '关于' }
|
||||
]
|
||||
|
||||
const latest = ref([])
|
||||
const tags = ref([])
|
||||
|
||||
// 短文没有标题,侧栏拿正文开头当标识(flomo/Twitter 的做法),
|
||||
// 纯图片短文显示占位;有配图时右侧带一张小缩略图。
|
||||
const EXCERPT_LEN = 22
|
||||
function excerpt(p) {
|
||||
if (p.kind !== 'short') return p.title || '无题'
|
||||
// 先剥 <a>:贴链接的短文开头是 URL 本身,摘出来没有信息量;
|
||||
// 剥完没字了(纯链接贴)就退到链接卡片的标题
|
||||
const noLinks = (p.content_html || '').replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ')
|
||||
const text = stripTags(noLinks).replace(/\s+/g, ' ').trim()
|
||||
if (text) return text.length > EXCERPT_LEN ? text.slice(0, EXCERPT_LEN) + '…' : text
|
||||
if (p.link_card && p.link_card.title) {
|
||||
const t = p.link_card.title
|
||||
return t.length > EXCERPT_LEN ? t.slice(0, EXCERPT_LEN) + '…' : t
|
||||
}
|
||||
return p.images && p.images.length ? '📷 图片' : '短文'
|
||||
}
|
||||
function thumbOf(p) {
|
||||
return p.kind === 'short' && p.images && p.images.length ? thumbURL(p.images[0], 160) : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isVivid.value) return
|
||||
try {
|
||||
@@ -25,6 +91,15 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<aside v-if="!isVivid" class="rail-right">
|
||||
<section v-if="toc.length" class="card toc-card">
|
||||
<p class="eyebrow">目录</p>
|
||||
<ul class="toc-list">
|
||||
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
|
||||
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">关于这里</p>
|
||||
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
||||
@@ -35,12 +110,22 @@ onMounted(async () => {
|
||||
<p class="eyebrow">最近更新</p>
|
||||
<ul class="list">
|
||||
<li v-for="p in latest" :key="p.id">
|
||||
<RouterLink :to="`/post/${p.slug}`" class="row">
|
||||
<span class="title">
|
||||
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
|
||||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||||
<RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
|
||||
<span class="row-main">
|
||||
<span class="title">
|
||||
{{ excerpt(p) }}
|
||||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||||
</span>
|
||||
<span class="date">{{ relativeDate(p.published_at) }}</span>
|
||||
</span>
|
||||
<span class="date">{{ relativeDate(p.published_at) }}</span>
|
||||
<img
|
||||
v-if="thumbOf(p)"
|
||||
class="thumb"
|
||||
:src="thumbOf(p)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -55,11 +140,45 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">订阅</p>
|
||||
<p class="bio">用 RSS 阅读器跟进更新。</p>
|
||||
<a class="pill pill-ghost" href="/rss.xml">RSS / feed.xml</a>
|
||||
</section>
|
||||
<!-- 订阅卡删掉了:RSS 入口在下方页脚小字里,一张卡片只为一个链接不值得 -->
|
||||
|
||||
<!-- 页脚信息(Twitter 式:右栏卡片下方的小字区)。通栏 ClassicFoot
|
||||
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
|
||||
所有宽度都不缺位。 -->
|
||||
<footer class="rail-foot">
|
||||
<div v-if="social.length" class="social-row">
|
||||
<a
|
||||
v-for="s in social"
|
||||
:key="s.label + s.url"
|
||||
class="social-icon"
|
||||
:href="s.url"
|
||||
:title="s.label"
|
||||
:aria-label="s.label"
|
||||
:target="s.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
|
||||
>
|
||||
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
|
||||
<span v-else>{{ s.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="foot-links" aria-label="页脚导航">
|
||||
<template v-for="(l, i) in links" :key="l.to">
|
||||
<span v-if="i" class="dot">·</span>
|
||||
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
|
||||
</template>
|
||||
<span class="dot">·</span>
|
||||
<a href="/rss.xml">RSS</a>
|
||||
</nav>
|
||||
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
|
||||
<div class="foot-meta">
|
||||
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(PROJECT_REPO)" /></svg>
|
||||
</a>
|
||||
<span class="copyright">
|
||||
{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -93,6 +212,24 @@ onMounted(async () => {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 文章目录:当前小节高亮,层级缩进 */
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.toc-list li {margin: 2px 0;
|
||||
border-left: 2px solid transparent;
|
||||
padding-left: 8px;}
|
||||
.toc-list li.h3 {padding-left: 20px;
|
||||
font-size: 12.5px;}
|
||||
.toc-list li.on {border-left-color: var(--accent);}
|
||||
.toc-list a {color: var(--muted);
|
||||
text-decoration: none;}
|
||||
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
@@ -100,7 +237,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.row {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px dashed var(--line-soft);
|
||||
}
|
||||
@@ -109,10 +248,27 @@ onMounted(async () => {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* 文字列吃满剩余宽度,缩略图固定在右 */
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -142,4 +298,79 @@ onMounted(async () => {
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 页脚小字区:无卡片边框,Twitter 式侧栏页脚。classic 的页脚信息只在这里
|
||||
出现(通栏 ClassicFoot 已删):右栏在窄屏会堆到信息流下方,卡片之后
|
||||
正好接页脚小字,所有宽度都不缺位。 */
|
||||
.rail-foot {
|
||||
padding: 4px 6px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.foot-links a {
|
||||
color: var(--faint);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.foot-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dot {
|
||||
margin: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 社交按钮:页脚第一排居中 */
|
||||
.social-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.foot-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* 项目信息行和上面的社交账号排拉开一截:它们是两类信息 */
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.social-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* 窄屏:底部留白加高,右下角浮动主题钮压不到页脚小字 */
|
||||
@media (max-width: 780px) {
|
||||
.rail-right {
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,141 @@
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { site } from '../site'
|
||||
import NavBurger from './NavBurger.vue'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
|
||||
// 手机端导航。原来把 5 个链接平铺在顶栏里:375px 挤成一排小字,
|
||||
// 触点只有十几像素高,320px 的屏还会折行。改成 brand + 汉堡钮,
|
||||
// 点开一片下拉「纸片」(不是全屏——classic 的性格是轻,浮一层纸就够)。
|
||||
// 主题切换不走这里:App.vue 的浮动 ThemeSwitcher 只在窄屏出现,职责不重。
|
||||
// 桌面(>780px)整个顶栏不存在,导航走左栏。
|
||||
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
const hidden = ref(false)
|
||||
const burger = ref(null)
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: '首页' },
|
||||
{ to: '/archive', label: '归档' },
|
||||
{ to: '/tags', label: '标签' },
|
||||
{ to: '/projects', label: '作品' },
|
||||
{ to: '/about', label: '关于' }
|
||||
]
|
||||
|
||||
function isOn(l) {
|
||||
return l.to === '/' ? route.path === '/' : route.path.startsWith(l.to)
|
||||
}
|
||||
|
||||
// 下滑藏顶栏、上滑唤回。迟滞用「同向累计」:往复拖动时正负抵消,
|
||||
// 持续同向滚动超过阈值才切换状态——之前的单事件方向判定
|
||||
// (下滑 4px 就藏、回弹就现身)在阈值附近徘徊时会抽搐。
|
||||
// 滚回页首(y ≤ 80)或菜单开着时强制显示 —— 纸片锚在顶栏下面,
|
||||
// 顶栏藏了纸片会悬空。
|
||||
let lastY = typeof window === 'undefined' ? 0 : window.scrollY
|
||||
let acc = 0
|
||||
function onScroll() {
|
||||
const y = window.scrollY
|
||||
if (open.value || y <= 80) {
|
||||
hidden.value = false
|
||||
lastY = y
|
||||
acc = 0
|
||||
return
|
||||
}
|
||||
const delta = y - lastY
|
||||
lastY = y
|
||||
if (delta === 0) return
|
||||
// 同向累计;反向则从新方向重新累计(小幅往复互相抵消)
|
||||
if (acc === 0 || (delta > 0) === (acc > 0)) {
|
||||
acc += delta
|
||||
} else {
|
||||
acc = delta
|
||||
}
|
||||
if (!hidden.value && acc > 80 && y > 120) {
|
||||
hidden.value = true
|
||||
acc = 0
|
||||
} else if (hidden.value && acc < -60) {
|
||||
hidden.value = false
|
||||
acc = 0
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value ? close() : (open.value = true)
|
||||
}
|
||||
function close() {
|
||||
open.value = false
|
||||
const b = burger.value?.$el || burger.value
|
||||
nextTick(() => b?.focus?.({ preventScroll: true }))
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
// 点顶栏和纸片以外的地方收起(scrim 也兜了一层)
|
||||
function onDocDown(e) {
|
||||
if (open.value && !e.target.closest?.('.topbar')) close()
|
||||
}
|
||||
// 点了链接 → 路由变了 → 收起
|
||||
watch(() => route.fullPath, () => { open.value = false })
|
||||
// 顶栏藏没藏广播到 <html>:首页的 sticky-head(标题 + 内容切换 tabs)吸顶
|
||||
// 偏移用的是 var(--topbar-h),这里翻成 0 它就跟着顶栏一起滑上去
|
||||
watch(hidden, (v) => {
|
||||
document.documentElement.classList.toggle('topbar-hidden', v)
|
||||
})
|
||||
|
||||
// 窗口拉宽回桌面必须自己退场:v-if 是 JS 状态,CSS 媒体查询管不着它
|
||||
const mqWide = window.matchMedia('(min-width: 781px)')
|
||||
function onWide(e) { if (e.matches) open.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.addEventListener('pointerdown', onDocDown, true)
|
||||
mqWide.addEventListener('change', onWide)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('pointerdown', onDocDown, true)
|
||||
mqWide.removeEventListener('change', onWide)
|
||||
document.documentElement.classList.remove('topbar-hidden')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 窄屏时左栏收起,导航回到顶部 -->
|
||||
<header class="topbar">
|
||||
<RouterLink to="/" class="brand">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
<nav class="links">
|
||||
<RouterLink to="/">首页</RouterLink>
|
||||
<RouterLink to="/archive">归档</RouterLink>
|
||||
<RouterLink to="/tags">标签</RouterLink>
|
||||
<RouterLink to="/projects">作品</RouterLink>
|
||||
<RouterLink to="/about">关于</RouterLink>
|
||||
</nav>
|
||||
<!-- 窄屏时左栏收起,导航回到顶部;下滑隐藏、上滑唤回 -->
|
||||
<header class="topbar" :class="{ hidden }">
|
||||
<RouterLink to="/" class="brand">
|
||||
<span class="mk"><LogoMark /></span>
|
||||
<span>{{ site.site_title || 'ONE' }}</span>
|
||||
</RouterLink>
|
||||
<NavBurger
|
||||
ref="burger"
|
||||
class="burger"
|
||||
:open="open"
|
||||
aria-controls="c-menu"
|
||||
:aria-label="open ? '关闭菜单' : '打开菜单'"
|
||||
@toggle="toggle"
|
||||
/>
|
||||
|
||||
<!-- 半透明幕布:垫在顶栏(z 20)之下、页面内容之上,点它收起 -->
|
||||
<Transition name="c-scrim">
|
||||
<div v-if="open" class="scrim" @click="close" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="c-sheet">
|
||||
<div v-if="open" id="c-menu" class="sheet">
|
||||
<nav class="links" aria-label="站点导航">
|
||||
<RouterLink
|
||||
v-for="l in links"
|
||||
:key="l.to"
|
||||
:to="l.to"
|
||||
:class="{ on: isOn(l) }"
|
||||
>{{ l.label }}</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -23,16 +146,28 @@ import { site } from '../site'
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
/* 三列网格:brand 落在正中列居中,汉堡钉死右列;下滑整条上移隐藏 */
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 18px;
|
||||
padding: 9px 12px;
|
||||
background: color-mix(in srgb, var(--paper) 80%, transparent);
|
||||
backdrop-filter: blur(10px) saturate(160%);
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: transform 0.32s var(--ease, ease);
|
||||
}
|
||||
|
||||
.topbar.hidden {
|
||||
transform: translateY(-110%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.topbar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
@@ -43,19 +178,115 @@ import { site } from '../site'
|
||||
}
|
||||
|
||||
.brand {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand .mk {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.burger {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* 幕布在自己的顶栏层叠上下文里,z -1 垫在 brand / 汉堡 / 纸片下面,
|
||||
但整个上下文(z 20)仍盖过页面内容 */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: color-mix(in srgb, var(--ink) 24%, transparent);
|
||||
}
|
||||
|
||||
/* 浮动纸片:贴着顶栏下方悬一层,和右栏卡片同一套「纸」的语言 */
|
||||
.sheet {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--paper) 96%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
box-shadow: 0 18px 50px -18px rgba(28, 28, 30, 0.35);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.sheet {
|
||||
background: var(--paper);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.links a.router-link-exact-active {
|
||||
.links a {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.links a.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.links a:active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.c-sheet-enter-active {
|
||||
transition: opacity 0.22s ease, transform 0.3s cubic-bezier(0.22, 1.2, 0.36, 1);
|
||||
}
|
||||
.c-sheet-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
.c-sheet-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.c-sheet-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.c-scrim-enter-active,
|
||||
.c-scrim-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.c-scrim-enter-from,
|
||||
.c-scrim-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-sheet-enter-active,
|
||||
.c-sheet-leave-active,
|
||||
.c-scrim-enter-active,
|
||||
.c-scrim-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +1,38 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { reader } from '../../reader'
|
||||
import { insertAtCursor, placeCursor } from '../../utils'
|
||||
import CommentMarkdown from './CommentMarkdown.vue'
|
||||
import EmojiPicker from './EmojiPicker.vue'
|
||||
import { BRANDS, PROVIDER_LABEL } from './brands'
|
||||
import { relativeDate } from '../../utils'
|
||||
|
||||
// 单条评论。回复在同一个组件里自引用渲染(depth 0 → 1),
|
||||
// 这样「回复框 / 编辑框」的模板只写一遍,两层不会各写一份再漂移。
|
||||
// 只渲染一层:depth >= 1 时不再显示它自己的 replies 与「加载更多」。
|
||||
//
|
||||
// 回复之间的相互回复在扁平列表里靠 @提及 认亲:depth 0 渲染每条回复时
|
||||
// 解析出它回的是谁(parent_id 指向主楼 → 不标;指向同串里另一条回复 →
|
||||
// 把那条的作者名传下去)。父回复没被加载进来时解析不到,就不标。
|
||||
const props = defineProps({
|
||||
c: { type: Object, required: true },
|
||||
api: { type: Object, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
maxLen: { type: Number, default: 500 }
|
||||
maxLen: { type: Number, default: 500 },
|
||||
replyTo: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const s = props.api.s
|
||||
const EDIT_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户')
|
||||
const name = computed(() => (props.c.user && props.c.user.name) || '已注销用户')
|
||||
const relativeTime = computed(() => relativeDate(props.c.created_at))
|
||||
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner))
|
||||
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id))
|
||||
const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
|
||||
const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
|
||||
|
||||
// 外链:优先作者自填的 url,其次 GitHub 主页
|
||||
const authorHref = computed(() => {
|
||||
const a = props.c.author
|
||||
const a = props.c.user
|
||||
if (!a) return ''
|
||||
if (a.url) return a.url
|
||||
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
|
||||
@@ -34,12 +41,11 @@ const authorHref = computed(() => {
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (props.c.is_deleted) return false
|
||||
if (isOwner.value) return true
|
||||
if (!isMine.value) return false
|
||||
const t = new Date(props.c.created_at).getTime()
|
||||
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS
|
||||
})
|
||||
const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value))
|
||||
const canDelete = computed(() => !props.c.is_deleted && isMine.value)
|
||||
|
||||
const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
|
||||
const editing = computed(() => s.editing && s.editing.id === props.c.id)
|
||||
@@ -47,6 +53,27 @@ const editing = computed(() => s.editing && s.editing.id === props.c.id)
|
||||
const replyDraft = ref('')
|
||||
const editDraft = ref('')
|
||||
|
||||
// 表情 / 颜文字:回复框和编辑框各挂一个面板,插入到各自的光标处
|
||||
const replyEl = ref(null)
|
||||
const editEl = ref(null)
|
||||
const replyEmoji = ref(false)
|
||||
const editEmoji = ref(false)
|
||||
function pickReply(text) {
|
||||
const r = insertAtCursor(replyEl.value, replyDraft.value, text)
|
||||
replyDraft.value = r.value
|
||||
nextTick(() => placeCursor(replyEl.value, r.pos))
|
||||
}
|
||||
function pickEdit(text) {
|
||||
const r = insertAtCursor(editEl.value, editDraft.value, text)
|
||||
editDraft.value = r.value
|
||||
nextTick(() => placeCursor(editEl.value, r.pos))
|
||||
}
|
||||
function onCancel() {
|
||||
replyEmoji.value = false
|
||||
editEmoji.value = false
|
||||
props.api.cancel()
|
||||
}
|
||||
|
||||
function startReply() {
|
||||
replyDraft.value = ''
|
||||
props.api.startReply(props.c)
|
||||
@@ -61,13 +88,15 @@ async function onReply() {
|
||||
const ok = await props.api.submit(text, props.c)
|
||||
if (ok) {
|
||||
replyDraft.value = ''
|
||||
replyEmoji.value = false
|
||||
props.api.cancel()
|
||||
}
|
||||
}
|
||||
async function onSave() {
|
||||
const text = editDraft.value.trim()
|
||||
if (!text) return
|
||||
await props.api.saveEdit(props.c, text)
|
||||
const ok = await props.api.saveEdit(props.c, text)
|
||||
if (ok) editEmoji.value = false
|
||||
}
|
||||
async function onRemove() {
|
||||
if (!window.confirm('删掉这条评论?')) return
|
||||
@@ -77,21 +106,34 @@ async function onRemove() {
|
||||
const moreReplies = computed(() =>
|
||||
props.depth === 0 ? Math.max(0, (props.c.reply_count || 0) - ((props.c.replies || []).length)) : 0
|
||||
)
|
||||
|
||||
// 这条回复要 @ 谁:parent 指向主楼(props.c)不用标;指向串里另一条
|
||||
// 已加载的回复才标。正文以块级元素开头(代码块/列表)时 @ 没法浮在
|
||||
// 首行行首,模板里会退成独立一行。
|
||||
const atInline = computed(
|
||||
() => !!props.replyTo && /^<p[\s>]/i.test((props.c.body_html || '').trim())
|
||||
)
|
||||
|
||||
function replyNameOf(r) {
|
||||
if (!r.parent_id || r.parent_id === props.c.id) return ''
|
||||
const t = (props.c.replies || []).find((x) => x.id === r.parent_id)
|
||||
return t ? (t.user && t.user.name) || '' : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
|
||||
<div class="cm-av" :class="{ 'is-owner': isOwner }">
|
||||
<img
|
||||
v-if="c.author && c.author.avatar_url"
|
||||
:src="c.author.avatar_url"
|
||||
v-if="c.user && c.user.avatar_url"
|
||||
:src="c.user.avatar_url"
|
||||
:alt="`${name} 的头像`"
|
||||
loading="lazy"
|
||||
>
|
||||
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
|
||||
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider">
|
||||
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[c.author.provider]" />
|
||||
<i v-if="c.user && c.user.provider" class="cm-pv" :title="PROVIDER_LABEL[c.user.provider] || c.user.provider">
|
||||
<svg v-if="BRANDS[c.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[c.user.provider]" />
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
@@ -112,35 +154,50 @@ const moreReplies = computed(() =>
|
||||
<span v-if="c.edited_at" class="cm-edited-tip">已编辑</span>
|
||||
</div>
|
||||
|
||||
<!-- 编辑态:把气泡换成输入框 -->
|
||||
<!-- 编辑态:把正文换成输入框 -->
|
||||
<div v-if="editing" class="cm-field cm-field-inline">
|
||||
<p class="cm-field-tip">正在改定此评:</p>
|
||||
<textarea v-model="editDraft" class="cm-input" rows="3" :maxlength="maxLen" aria-label="编辑评论" />
|
||||
<textarea ref="editEl" v-model="editDraft" class="cm-input" rows="3" :maxlength="maxLen"
|
||||
placeholder="编辑这条评论…" aria-label="编辑评论" />
|
||||
<div class="cm-bar">
|
||||
<span class="cm-bar-l" />
|
||||
<span class="cm-bar-r">
|
||||
<button type="button" class="cm-link" @click="api.cancel">取消</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cm-emoji-btn"
|
||||
:class="{ on: editEmoji }"
|
||||
aria-label="插入表情"
|
||||
title="表情"
|
||||
@click="editEmoji = !editEmoji"
|
||||
>😊</button>
|
||||
<button type="button" class="cm-link" @click="onCancel">取消</button>
|
||||
<button type="button" class="cm-send" :disabled="!editDraft.trim() || s.submitting" @click="onSave">
|
||||
{{ s.submitting ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</span>
|
||||
<EmojiPicker v-if="editEmoji" @pick="pickEdit" @close="editEmoji = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="cm-bubble">
|
||||
<span v-if="c.is_deleted" class="cm-gone">该评论已删除</span>
|
||||
<CommentMarkdown v-else :html="c.body_html" />
|
||||
<template v-else>
|
||||
<!-- 回复回复时带上 @对方,扁平列表里才看得出在跟谁说话 -->
|
||||
<span v-if="replyTo && atInline" class="cm-at">@{{ replyTo }}</span>
|
||||
<p v-else-if="replyTo" class="cm-at cm-at-block">@{{ replyTo }}</p>
|
||||
<CommentMarkdown :html="c.body_html" />
|
||||
</template>
|
||||
|
||||
<div v-if="!c.is_deleted" class="cm-acts">
|
||||
<button v-if="canEdit" type="button" class="cm-act" aria-label="编辑" title="编辑" @click="startEdit">✎</button>
|
||||
<button v-if="canDelete" type="button" class="cm-act" aria-label="删除" title="删除" @click="onRemove">✕</button>
|
||||
<button v-if="reader.user" type="button" class="cm-act" aria-label="回复" title="回复" @click="startReply">↩</button>
|
||||
<button v-if="reader.user" type="button" class="cm-act" @click="startReply">回复</button>
|
||||
<button v-if="canEdit" type="button" class="cm-act" @click="startEdit">编辑</button>
|
||||
<button v-if="canDelete" type="button" class="cm-act is-danger" @click="onRemove">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复框 -->
|
||||
<div v-if="replying" class="cm-field cm-field-inline">
|
||||
<textarea
|
||||
ref="replyEl"
|
||||
v-model="replyDraft"
|
||||
class="cm-input"
|
||||
rows="3"
|
||||
@@ -151,11 +208,20 @@ const moreReplies = computed(() =>
|
||||
<div class="cm-bar">
|
||||
<span class="cm-bar-l">回应 <b>{{ name }}</b></span>
|
||||
<span class="cm-bar-r">
|
||||
<button type="button" class="cm-link" @click="api.cancel">取消</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cm-emoji-btn"
|
||||
:class="{ on: replyEmoji }"
|
||||
aria-label="插入表情"
|
||||
title="表情"
|
||||
@click="replyEmoji = !replyEmoji"
|
||||
>😊</button>
|
||||
<button type="button" class="cm-link" @click="onCancel">取消</button>
|
||||
<button type="button" class="cm-send" :disabled="!replyDraft.trim() || s.submitting" @click="onReply">
|
||||
{{ s.submitting ? '发送中…' : '发送' }}
|
||||
</button>
|
||||
</span>
|
||||
<EmojiPicker v-if="replyEmoji" @pick="pickReply" @close="replyEmoji = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,6 +234,7 @@ const moreReplies = computed(() =>
|
||||
:api="api"
|
||||
:depth="depth + 1"
|
||||
:max-len="maxLen"
|
||||
:reply-to="replyNameOf(r)"
|
||||
/>
|
||||
</ul>
|
||||
<button v-if="moreReplies > 0" type="button" class="cm-more-r" @click="api.loadMoreReplies(c)">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { site } from '../../site'
|
||||
import { reader, loadReader, signIn, signOut } from '../../reader'
|
||||
import { readerApi } from '../../api'
|
||||
import { useComments } from '../../composables/useComments'
|
||||
import { insertAtCursor, placeCursor } from '../../utils'
|
||||
import CommentItem from './CommentItem.vue'
|
||||
import EmojiPicker from './EmojiPicker.vue'
|
||||
import { BRANDS } from './brands'
|
||||
|
||||
// 文章评论区。classic 和 vivid 共用这一份 DOM —— 两边只是 CSS 不同
|
||||
@@ -18,7 +20,19 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const api = useComments(props.postId)
|
||||
const { s, remaining, load, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
|
||||
const { s, remaining, load, refresh, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
|
||||
|
||||
// 新评论实时出现:订阅这篇文章的 SSE 频道,后端广播「评论变了」就静默刷新。
|
||||
// EventSource 自带断线重连;路由换文章时关旧开新。
|
||||
let stream = null
|
||||
function openStream() {
|
||||
if (stream) stream.close()
|
||||
if (!props.postId || typeof EventSource === 'undefined') return
|
||||
stream = new EventSource(`/api/comments/stream?post_id=${props.postId}`)
|
||||
stream.addEventListener('comments', () => refresh())
|
||||
}
|
||||
watch(() => props.postId, openStream, { immediate: true })
|
||||
onBeforeUnmount(() => stream && stream.close())
|
||||
|
||||
// 总开关在站点设置里。后端阶段二才会下发 comments_enabled —— 在那之前整个
|
||||
// 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。
|
||||
@@ -35,11 +49,21 @@ const MAX_LEN = 500
|
||||
const draft = ref('')
|
||||
const canSend = computed(() => draft.value.trim().length > 0 && !s.submitting && draft.value.length <= MAX_LEN)
|
||||
|
||||
// 表情 / 颜文字:面板挂在工具条上方,插入到光标处
|
||||
const draftEl = ref(null)
|
||||
const emojiOpen = ref(false)
|
||||
function pickEmoji(text) {
|
||||
const r = insertAtCursor(draftEl.value, draft.value, text)
|
||||
draft.value = r.value
|
||||
nextTick(() => placeCursor(draftEl.value, r.pos))
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
if (!canSend.value) return
|
||||
const ok = await submit(draft.value.trim(), s.replyTo)
|
||||
if (ok) {
|
||||
draft.value = ''
|
||||
emojiOpen.value = false
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -105,7 +129,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡 / 输入框 -->
|
||||
<!-- 登录卡 / 禁言 / 输入框 -->
|
||||
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
|
||||
<div v-else-if="!reader.user" class="cm-gate">
|
||||
<p class="cm-gate-t">使用社交账号登录</p>
|
||||
@@ -129,6 +153,10 @@ onMounted(() => {
|
||||
<!-- Telegram 官方按钮落到这里 -->
|
||||
<div ref="tgBox" class="cm-tg" />
|
||||
</div>
|
||||
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
|
||||
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
|
||||
<p class="cm-gate-hint">如有疑问请联系站主。</p>
|
||||
</div>
|
||||
<div v-else class="cm-compose">
|
||||
<div class="cm-av cm-av-me">
|
||||
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
|
||||
@@ -136,6 +164,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="cm-field">
|
||||
<textarea
|
||||
ref="draftEl"
|
||||
v-model="draft"
|
||||
class="cm-input"
|
||||
rows="3"
|
||||
@@ -149,11 +178,20 @@ onMounted(() => {
|
||||
<button type="button" class="cm-link" @click="signOut">退出</button>
|
||||
</span>
|
||||
<span class="cm-bar-r">
|
||||
<button
|
||||
type="button"
|
||||
class="cm-emoji-btn"
|
||||
:class="{ on: emojiOpen }"
|
||||
aria-label="插入表情"
|
||||
title="表情"
|
||||
@click="emojiOpen = !emojiOpen"
|
||||
>😊</button>
|
||||
<span class="cm-len" :class="{ over: draft.length > MAX_LEN }">{{ draft.length }}/{{ MAX_LEN }}</span>
|
||||
<button type="button" class="cm-send" :disabled="!canSend" @click="onSend">
|
||||
{{ s.submitting ? '发送中…' : '发送' }}
|
||||
</button>
|
||||
</span>
|
||||
<EmojiPicker v-if="emojiOpen" @pick="pickEmoji" @close="emojiOpen = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { EMOJI_GROUPS, KAOMOJI } from './emoji'
|
||||
|
||||
// 表情 / 颜文字选择面板。挂在输入区工具条上(宿主的 .cm-bar 要 position:relative),
|
||||
// 面板绝对定位在条上方盖住输入框。点选只往上抛 pick,插到光标还是追加由宿主定;
|
||||
// 自己负责点外面 / Esc 收起。工具条按钮本身不在面板里,outside 判定要放过它,
|
||||
// 否则「点按钮收起」会变成 pointerdown 先收、click 再开,永远关不上。
|
||||
const emit = defineEmits(['pick', 'close'])
|
||||
|
||||
const tab = ref('emoji')
|
||||
const el = ref(null)
|
||||
|
||||
function onDocDown(e) {
|
||||
if (el.value && !el.value.contains(e.target) && !e.target.closest?.('.cm-emoji-btn')) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
onMounted(() => {
|
||||
// 默认朝上弹;工具条离视口顶太近放不下时翻到下方(手机键盘压小视口时常见)
|
||||
const space = el.value.parentElement.getBoundingClientRect().top
|
||||
if (space < el.value.offsetHeight + 16) el.value.classList.add('is-below')
|
||||
document.addEventListener('pointerdown', onDocDown, true)
|
||||
document.addEventListener('keydown', onKey)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocDown, true)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="cm-emoji" role="group" aria-label="插入表情或颜文字">
|
||||
<div class="cm-emoji-tabs" role="tablist" aria-label="表情类型">
|
||||
<button type="button" role="tab" :aria-selected="tab === 'emoji'" :class="{ on: tab === 'emoji' }" @click="tab = 'emoji'">表情</button>
|
||||
<button type="button" role="tab" :aria-selected="tab === 'kaomoji'" :class="{ on: tab === 'kaomoji' }" @click="tab = 'kaomoji'">颜文字</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'emoji'" class="cm-emoji-grid">
|
||||
<template v-for="g in EMOJI_GROUPS" :key="g.label">
|
||||
<span class="cm-emoji-cat">{{ g.label }}</span>
|
||||
<button
|
||||
v-for="e in g.items"
|
||||
:key="e"
|
||||
type="button"
|
||||
class="cm-emoji-item"
|
||||
:aria-label="`插入 ${e}`"
|
||||
@click="emit('pick', e)"
|
||||
>{{ e }}</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="cm-emoji-list">
|
||||
<button
|
||||
v-for="k in KAOMOJI"
|
||||
:key="k"
|
||||
type="button"
|
||||
class="cm-emoji-item cm-kao"
|
||||
:aria-label="`插入 ${k}`"
|
||||
@click="emit('pick', k)"
|
||||
>{{ k }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
// 评论表情选择的数据(仅前端用,后端只存 Markdown 文本,emoji 就是普通字符)。
|
||||
// emoji 手挑常用的一百来个,分组只是为了在面板里好找;颜文字选能打的经典款。
|
||||
// 刻意不引第三方 emoji 库:面板是静态数据 + 两列 tab,一个依赖都不值得加。
|
||||
|
||||
export const EMOJI_GROUPS = [
|
||||
{
|
||||
label: '笑脸',
|
||||
items: [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
|
||||
'😊', '😇', '🙂', '😉', '😍', '🥰', '😘', '😗',
|
||||
'😙', '😚', '😋', '😜', '🤪', '🤨', '🧐', '🤓'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '心情',
|
||||
items: [
|
||||
'😎', '🥳', '😏', '😒', '😞', '😔', '😟', '😕',
|
||||
'🙁', '☹️', '😣', '😖', '😫', '😩', '🥺', '😢',
|
||||
'😭', '😤', '😠', '😡', '🤬', '😱', '😨', '😰'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
items: [
|
||||
'🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😬',
|
||||
'🙄', '😯', '😦', '😧', '😮', '😲', '🥱', '😴',
|
||||
'🤤', '😪', '😵', '🤐', '🤢', '🤮', '🤧', '🤒'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '手势',
|
||||
items: [
|
||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '✌️', '🤞',
|
||||
'🤟', '🤘', '🤙', '👌', '🤌', '👈', '👉', '👆',
|
||||
'👇', '☝️', '✋', '🤚', '🖐️', '🖖', '👋', '🤝'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '其他',
|
||||
items: [
|
||||
'🙏', '💪', '👀', '🧠', '❤️', '🧡', '💛', '💚',
|
||||
'💙', '💜', '🖤', '🤍', '💯', '💢', '💥', '💫',
|
||||
'💦', '💨', '🎉', '🎊', '🎁', '🌸', '🌙', '⭐',
|
||||
'✨', '🔥', '💡', '📚', '✏️', '🎮', '🎵', '🚀'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 颜文字一行一个;反斜杠记得转义
|
||||
export const KAOMOJI = [
|
||||
'(。◕‿◕。)', 'ヾ(≧▽≦*)o', '(^▽^)', '( ̄▽ ̄)', '(*^▽^*)', 'o(≧∇≦)o',
|
||||
'٩(๑❛ᴗ❛๑)۶', '✧(≖ ◡ ≖✿)', '(๑•̀ㅂ•́)و✧', '( •̀ᴗ•́ )و ̑̑', '( ¬‿¬)', '( ˘▽˘ )っ♨',
|
||||
'(´,,•ω•,,)♡', '(。•́‿•̀。)', '(๑•́ ₃ •̀๑)', '♡(˃͈ દ ˂͈ ༶ )', '(´・ω・`)', '(・ω・)',
|
||||
'¯\\_(ツ)_/¯', '┐(´д`)┌', '╮(╯▽╰)╭', '( ̄_ ̄|||)', 'o_O', '( ^_^ )',
|
||||
'(╥﹏╥)', '(ㄒoㄒ)', 'ಥ_ಥ', '(ToT)', 'QAQ', 'T_T',
|
||||
'(╯°□°)╯︵ ┻━┻', '(╬ Ò﹏Ó)', '┬─┬ ノ( ゜-゜ノ)', 'orz', '_(:з」∠)_', 'ᕕ( ᐛ )ᕗ',
|
||||
'(っ˘̩╭╮˘̩)っ', '(ง •_•)ง'
|
||||
]
|
||||
@@ -59,6 +59,24 @@ export function useComments(postId) {
|
||||
load(true)
|
||||
}
|
||||
|
||||
// SSE 触发的静默刷新:回到第一页按当前排序重拉,不亮加载态——
|
||||
// 别人发了新评论时页面只是「悄悄长出」新内容,不该闪一下。
|
||||
// 返回 true 表示列表真的变了(顶层条数有差异)。
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await readerApi.comments(postId, { sort: s.sort, page: 1, size: PAGE_SIZE })
|
||||
if (s.loading || s.loadingMore || s.replyTo || s.editing) return false
|
||||
const items = (data && data.items) || []
|
||||
const changed = (data && data.total) !== s.total || items.some((c, i) => !s.items[i] || s.items[i].id !== c.id || s.items[i].edited_at !== c.edited_at)
|
||||
s.items = items
|
||||
s.page = 1
|
||||
s.total = (data && data.total) || 0
|
||||
return changed
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 发表 / 回复。成功后直接插进列表,不整页重拉 —— 位置按当前排序决定,
|
||||
// 用户刚发的东西必须马上看得见。
|
||||
async function submit(bodyMd, parent = null) {
|
||||
@@ -167,6 +185,7 @@ export function useComments(postId) {
|
||||
s,
|
||||
remaining,
|
||||
load,
|
||||
refresh,
|
||||
setSort,
|
||||
submit,
|
||||
saveEdit,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { site } from './site'
|
||||
|
||||
// 站主自定义 JS 的注入管线(Google Analytics / Plausible / Umami 这类统计脚本)。
|
||||
//
|
||||
// 后台贴的是完整的 <script> 片段(外链 + 内联都可能,GA4 就是两个一起)。
|
||||
// 用 DOMParser 解析后逐个「重建」成新的 script 节点再插进 head —— 直接
|
||||
// innerHTML 塞进去的 script 标签不会执行,必须重建才会跑。
|
||||
//
|
||||
// 只在公开页面注入一次,/admin 不注入(后台流量不该进统计)。
|
||||
// 已经执行过的脚本是收不回来的:从公开页跳进后台时它们继续跑,这是
|
||||
// 注入式统计的天然限制,不做额外处理。
|
||||
//
|
||||
// 安全:站主 = 管理员 = 能改源码的人,custom_js 就是他们自己的代码,
|
||||
// 和 custom_css 同一信任级别,不做任何清洗(洗 JS 只会把统计脚本洗坏)。
|
||||
|
||||
let injected = false
|
||||
|
||||
function injectOnce() {
|
||||
if (injected) return
|
||||
const code = (site.custom_js || '').trim()
|
||||
if (!code) return
|
||||
const doc = new DOMParser().parseFromString(code, 'text/html')
|
||||
const scripts = doc.querySelectorAll('script')
|
||||
for (const old of scripts) {
|
||||
const el = document.createElement('script')
|
||||
for (const attr of old.attributes) el.setAttribute(attr.name, attr.value)
|
||||
el.textContent = old.textContent
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
if (scripts.length) injected = true
|
||||
}
|
||||
|
||||
export function refreshCustomJs(route) {
|
||||
const isAdmin = !!(route && route.path && route.path.startsWith('/admin'))
|
||||
if (!isAdmin) injectOnce()
|
||||
}
|
||||
|
||||
// 只挂 afterEach:首次导航完成也会触发它,所以公开页的首次加载就能注入。
|
||||
// 不在安装时直接注入 —— 那一刻路由还没解析完(currentRoute 是 "/"),
|
||||
// 直接打开 /admin 会被误判成公开页。
|
||||
export function installCustomJs(router) {
|
||||
router.afterEach((to) => refreshCustomJs(to))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
|
||||
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
|
||||
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
|
||||
//
|
||||
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
|
||||
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
|
||||
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
|
||||
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
|
||||
const state = reactive({
|
||||
list: [],
|
||||
index: 0,
|
||||
open: false
|
||||
})
|
||||
|
||||
export const lightboxState = state
|
||||
|
||||
export function openLightbox(list, index = 0) {
|
||||
if (!list || !list.length) return
|
||||
state.list = list
|
||||
state.index = Math.max(0, Math.min(index, list.length - 1))
|
||||
state.open = true
|
||||
}
|
||||
|
||||
export function closeLightbox() {
|
||||
state.open = false
|
||||
}
|
||||
|
||||
export function stepLightbox(delta) {
|
||||
if (!state.open || state.list.length < 2) return
|
||||
state.index = (state.index + delta + state.list.length) % state.list.length
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { loadSite } from './site'
|
||||
import { installCustomCss } from './customCss'
|
||||
import { installCustomJs } from './customJs'
|
||||
import './styles.css'
|
||||
// vivid(余白)是第二套 UI,全部规则都以 html[data-ui="vivid"] 前缀收敛;
|
||||
// 必须排在 styles.css 之后,才能在同特指度时按源码顺序取胜。
|
||||
@@ -21,6 +22,8 @@ import './ui/yohaku/mobile.css'
|
||||
|
||||
loadSite().then(async () => {
|
||||
installCustomCss(router)
|
||||
// 站主自定义 JS(统计脚本):公开页注入一次,后台不注入
|
||||
installCustomJs(router)
|
||||
// 评论区的 dev mock:只有 dev 且带 ?mock 时才会被下载。
|
||||
// import.meta.env.DEV 在 build 时被替换成 false,整个分支连同这个动态 chunk
|
||||
// 一起被 rollup 丢掉(产物里 grep __COMMENT_MOCK__ 应为空)。
|
||||
|
||||
@@ -200,6 +200,13 @@ function mountSwitcher(current) {
|
||||
'font:12px/1.6 ui-sans-serif,system-ui;color:#111;background:rgba(255,255,255,.94);' +
|
||||
'border:1px solid #ddd;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.16)'
|
||||
)
|
||||
// 窄屏上浮到 dock 之上:dock(底部居中悬浮胶囊)会跟右下角的浮层打架
|
||||
const mqNarrow = window.matchMedia('(max-width: 720px)')
|
||||
const lift = () => {
|
||||
box.style.bottom = mqNarrow.matches ? '84px' : '12px'
|
||||
}
|
||||
mqNarrow.addEventListener('change', lift)
|
||||
lift()
|
||||
const title = document.createElement('div')
|
||||
title.textContent = '评论 mock 状态'
|
||||
title.setAttribute('style', 'font-weight:700;margin-bottom:6px')
|
||||
|
||||
@@ -90,9 +90,15 @@ export function baseItems() {
|
||||
'<p>三栏那个侧边目录会跟着滚动高亮,这个细节做得好。<code>IntersectionObserver</code> 还是算 scroll 位置?</p>' +
|
||||
'<p>另外时间线里「更早 / 上一篇」这组标签,是文章自己的字段还是按位置算的?</p>'
|
||||
})
|
||||
const r1 = reply(c1, { author: ME, ago: 5 * HOUR, html: '<p>算 scroll 位置,没用 observer —— 目录只有几条,没必要。</p>' })
|
||||
const r2 = reply(c1, { author: OWNER, ago: 4 * HOUR + 20 * MIN, html: '<p>后面会换成 observer,等条目多了再说。</p>' })
|
||||
// 回复「回复」:parent 指向另一条回复、root 仍指主楼,前端靠 @提及 认亲
|
||||
const r2b = reply(r2, { author: USERS.mason, ago: 4 * HOUR, html: '<p>行,那我等 observer。顺手问下高亮会做吗?</p>' })
|
||||
r2b.root_id = c1.id
|
||||
c1.replies = [
|
||||
reply(c1, { author: ME, ago: 5 * HOUR, html: '<p>算 scroll 位置,没用 observer —— 目录只有几条,没必要。</p>' }),
|
||||
reply(c1, { author: OWNER, ago: 4 * HOUR + 20 * MIN, html: '<p>后面会换成 observer,等条目多了再说。</p>' }),
|
||||
r1,
|
||||
r2,
|
||||
r2b,
|
||||
reply(c1, { author: USERS.yuki, ago: 3 * HOUR, html: '<p>标签是按位置算的:找到当前篇,前后各两篇贴上「上一篇 / 下一篇 / 更早 / 更新」。</p>' }),
|
||||
reply(c1, { author: USERS.mason, ago: 2 * HOUR, html: '<p>学到了。</p>' }),
|
||||
reply(c1, { author: USERS.noli, ago: 80 * MIN, html: '<p>同问 observer,楼上已经答了,收了。</p>' })
|
||||
|
||||
@@ -17,10 +17,13 @@ const routes = [
|
||||
{ path: '', name: 'admin-dashboard', component: () => import('./admin/DashboardView.vue') },
|
||||
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
|
||||
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
|
||||
{ path: 'comments', name: 'admin-comments', component: () => import('./admin/CommentsView.vue') },
|
||||
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
+21
-6
@@ -1,10 +1,11 @@
|
||||
import { reactive, watch } from 'vue'
|
||||
import { publicApi } from './api'
|
||||
import { setThumbBase } from './utils'
|
||||
|
||||
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
|
||||
const DARK_SKIN = 'ink'
|
||||
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
|
||||
const LIGHT_SKINS = ['paper', 'sage', 'rose']
|
||||
const LIGHT_SKINS = ['paper']
|
||||
|
||||
// 客户端显示模式 —— 持久化在 localStorage
|
||||
const MODE_KEY = 'one.themeMode'
|
||||
@@ -52,7 +53,7 @@ function systemPrefersDark() {
|
||||
|
||||
// 模式 → 实际 data-theme 值
|
||||
//
|
||||
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
|
||||
// light / auto 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
|
||||
// dark → ink(强制暗色)
|
||||
//
|
||||
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
|
||||
@@ -83,6 +84,9 @@ export const site = reactive({
|
||||
author_bio: '写点长的,也写点短的。',
|
||||
footer_note: '© ONE · 一个博客',
|
||||
icp: '',
|
||||
// 社交 / 源码链接(后台「高级 → 社交链接」填写),[{ label, url }],
|
||||
// 图标由前端按 url 域名推导(socialIcons.js)
|
||||
social_links: [],
|
||||
posts_per_page: 10,
|
||||
light_skin_id: 'paper',
|
||||
// theme_id 是老字段,保留读取兼容用;新代码不要再写。
|
||||
@@ -91,11 +95,18 @@ export const site = reactive({
|
||||
ui_id: readUICache(),
|
||||
// 按分区存放的站主自定义 CSS(仅 vivid 生效,且不在 /admin 注入)
|
||||
custom_css: {},
|
||||
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
|
||||
custom_js: '',
|
||||
// 上传文件的公开域名(R2 直链前缀)。前端靠它判断哪些图片可以转本站缩略图,
|
||||
// 也用它解析头像 URL。
|
||||
uploads_public_base: '',
|
||||
// 站主头像的解析结果(后端按 owner_avatar_key + 存储端算出来;空=未设置)
|
||||
author_avatar_url: '',
|
||||
themeMode: readMode(),
|
||||
loaded: false
|
||||
})
|
||||
|
||||
// data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark
|
||||
// data-theme → 前台皮肤。classic: paper/ink(亮端固定纸感);vivid: vivid/vivid-dark
|
||||
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
|
||||
// data-ui → 整站 UI 版本(classic / vivid)
|
||||
//
|
||||
@@ -123,7 +134,7 @@ function applyTheme(animate = false) {
|
||||
setTimeout(() => el.classList.remove('theme-anim'), 300)
|
||||
}
|
||||
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
|
||||
// vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。
|
||||
// vivid 自带亮/暗两套调色板,不复用 classic 的纸感/墨色 —— 否则两套 token 会互相打架。
|
||||
const vivid = !adminScope && site.ui_id === 'vivid'
|
||||
const theme = vivid
|
||||
? (isDark ? 'vivid-dark' : 'vivid')
|
||||
@@ -147,8 +158,12 @@ if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
|
||||
export async function loadSite() {
|
||||
try {
|
||||
const data = await publicApi.site()
|
||||
Object.assign(site, data)
|
||||
const resp = await publicApi.site()
|
||||
// 响应结构:{ settings: {…}, uploads_public_base }
|
||||
const { uploads_public_base, settings } = resp || {}
|
||||
setThumbBase(uploads_public_base)
|
||||
site.uploads_public_base = uploads_public_base || ''
|
||||
Object.assign(site, settings)
|
||||
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
|
||||
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
|
||||
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 社交链接的图标映射。后台只填 label + url,图标由 url 域名 / 协议推导:
|
||||
// 已知域名给 Simple Icons 的单色 24×24 路径(currentColor 上色),
|
||||
// 认不出的给空串,调用方回落成文字(label)。
|
||||
import { BRANDS } from './components/comments/brands'
|
||||
|
||||
// 邮箱信封(Material Icons email)与 X(Simple Icons),brands.js 还没有的两种
|
||||
const EMAIL =
|
||||
'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4v-6l8 5 8-5v6zm0-8-8 5-8-5V6l8 5 8-5v2z'
|
||||
const X = 'M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z'
|
||||
|
||||
export function socialIcon(url) {
|
||||
const u = (url || '').toLowerCase()
|
||||
if (u.startsWith('mailto:')) return EMAIL
|
||||
if (u.includes('github.com')) return BRANDS.github
|
||||
if (u.includes('t.me') || u.includes('telegram')) return BRANDS.telegram
|
||||
if (u.includes('x.com') || u.includes('twitter.com')) return X
|
||||
// 自建 git 服务(git.gopher.ink 之类)也算 git 家族,套 octocat
|
||||
if (u.includes('git')) return BRANDS.github
|
||||
return ''
|
||||
}
|
||||
+356
-150
@@ -31,56 +31,34 @@
|
||||
--col-main: 640px;
|
||||
--col-right: 264px;
|
||||
--gutter: 28px;}
|
||||
:root[data-theme='ink'] {--paper: #1f1d1a;
|
||||
--card: #25221e;
|
||||
--paper-sunken: #2b2722;
|
||||
--ink: #ece5d2;
|
||||
--ink-soft: #c3b58f;
|
||||
--muted: #8c8270;
|
||||
--faint: #6a6155;
|
||||
--line: #3a352e;
|
||||
--line-soft: #2f2b25;
|
||||
--accent: #c3a972;
|
||||
--accent-soft: rgba(195, 169, 114, 0.14);
|
||||
--accent-line: rgba(195, 169, 114, 0.4);}
|
||||
:root[data-theme='sage'] {--paper: #eef0e6;
|
||||
--card: #f6f7ef;
|
||||
--paper-sunken: #e2e6d6;
|
||||
--ink: #2a3127;
|
||||
--ink-soft: #475042;
|
||||
--muted: #6f7a68;
|
||||
--faint: #94a08d;
|
||||
--line: #d6dcc9;
|
||||
--line-soft: #e6e9d9;
|
||||
--accent: #5f7b5c;
|
||||
--accent-soft: rgba(95, 123, 92, 0.12);
|
||||
--accent-line: rgba(95, 123, 92, 0.38);}
|
||||
:root[data-theme='rose'] {--paper: #f7eee6;
|
||||
--card: #fdf5ee;
|
||||
--paper-sunken: #ecdfd3;
|
||||
--ink: #3a2922;
|
||||
--ink-soft: #5e463d;
|
||||
--muted: #9a7c6f;
|
||||
--faint: #b89c91;
|
||||
--line: #ead9cb;
|
||||
--line-soft: #f0e2d5;
|
||||
--accent: #a55a4a;
|
||||
--accent-soft: rgba(165, 90, 74, 0.1);
|
||||
--accent-line: rgba(165, 90, 74, 0.38);}
|
||||
:root[data-admin-theme='dark'] {--admin-bg: #1a1815;
|
||||
:root[data-theme='ink'] {/* 墨:暖炭底、暖白字、鎏金点缀。比旧版更中性(褪掉黄褐相),
|
||||
层次靠三档灰阶拉开;强调金提亮提纯,暗底上对比更足 */
|
||||
--paper: #191817;
|
||||
--card: #201e1c;
|
||||
--paper-sunken: #262420;
|
||||
--ink: #e7e3da;
|
||||
--ink-soft: #b8b2a6;
|
||||
--muted: #867f73;
|
||||
--faint: #5f594f;
|
||||
--line: #34302b;
|
||||
--line-soft: #292623;
|
||||
--accent: #d9b97c;
|
||||
--accent-soft: rgba(217, 185, 124, 0.12);
|
||||
--accent-line: rgba(217, 185, 124, 0.4);}
|
||||
:root[data-admin-theme='dark'] {--admin-bg: #191817;
|
||||
--admin-paper: var(--admin-bg);
|
||||
--admin-card: #25221e;
|
||||
--admin-paper-sunken: #2f2b25;
|
||||
--admin-ink: #ece5d2;
|
||||
--admin-ink-soft: #c3b58f;
|
||||
--admin-muted: #9a8f7e;
|
||||
--admin-faint: #6e6557;
|
||||
--admin-line: #3a352e;
|
||||
--admin-line-soft: #2f2b25;
|
||||
--admin-accent: #c3a972;
|
||||
--admin-accent-soft: rgba(195, 169, 114, 0.16);
|
||||
--admin-accent-line: rgba(195, 169, 114, 0.42);
|
||||
--admin-on-accent: #1a1815;
|
||||
--admin-card: #201e1c;
|
||||
--admin-paper-sunken: #262420;
|
||||
--admin-ink: #e7e3da;
|
||||
--admin-ink-soft: #b8b2a6;
|
||||
--admin-muted: #948c7f;
|
||||
--admin-faint: #635c51;
|
||||
--admin-line: #34302b;
|
||||
--admin-line-soft: #292623;
|
||||
--admin-accent: #d9b97c;
|
||||
--admin-accent-soft: rgba(217, 185, 124, 0.16);
|
||||
--admin-accent-line: rgba(217, 185, 124, 0.42);
|
||||
--admin-on-accent: #191817;
|
||||
--admin-danger: #d68d75;
|
||||
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||
--admin-overlay: rgba(0, 0, 0, 0.6);}
|
||||
@@ -96,8 +74,10 @@ body {background: var(--paper);
|
||||
line-height: var(--lh);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* 移动端不留横向滚动 */
|
||||
overflow-x: hidden;}
|
||||
/* 移动端不留横向滚动。用 clip 不用 hidden:hidden 会把 body 变成滚动容器
|
||||
(overflow-y 坍缩成 auto),页内所有 position:sticky 就此失明——
|
||||
首页筛选条吸顶失效就是它干的。clip 只裁剪、不建滚动容器。 */
|
||||
overflow-x: clip;}
|
||||
a {color: inherit;
|
||||
text-decoration: none;}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@@ -129,6 +109,19 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
.layout {grid-template-columns: minmax(0, 1fr);}
|
||||
.layout > .rail-left {display: none;}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* 手机页边距收到 12px:与评论区(.cm-sec 手机断点)和余白主题的文章列对齐,
|
||||
正文和评论共用同一条左缘(文章 .wrap 的侧边距在 PostView.vue 里归零) */
|
||||
.shell {padding: 0 12px;}
|
||||
}
|
||||
|
||||
/* 顶栏实际高度(9+9 内边距 + 汉堡 40/44 + 1px 边框),首页 .sticky-head 的
|
||||
吸顶偏移用它;TopBar 隐藏时把 html.topbar-hidden 翻上来,偏移归 0 跟着滑走 */
|
||||
:root {--topbar-h: 59px;}
|
||||
@media (pointer: coarse) {
|
||||
:root {--topbar-h: 63px;}
|
||||
}
|
||||
html.topbar-hidden {--topbar-h: 0px;}
|
||||
.eyebrow {font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
@@ -207,9 +200,18 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
}
|
||||
.btn:disabled {opacity: 0.5;
|
||||
cursor: not-allowed;}
|
||||
/* 编辑器封面:URL 输入框 + 上传按钮同行 */
|
||||
.cover-row {display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;}
|
||||
.cover-row > input {flex: 1;
|
||||
min-width: 0;}
|
||||
.prose {font-size: 16.5px;
|
||||
line-height: var(--lh);
|
||||
color: var(--ink);}
|
||||
color: var(--ink);
|
||||
/* anywhere 而非 break-word:长 URL/长 token 要参与 min-content 计算,
|
||||
否则时间线正文里的裸链接会把列撑破(真机上浏览器缩放适配后整页缩小+右侧留白) */
|
||||
overflow-wrap: anywhere;}
|
||||
.prose > :first-child {margin-top: 22px;}
|
||||
.prose p {margin: 0 0 1.1em;}
|
||||
.prose > p:first-of-type::first-letter {float: left;
|
||||
@@ -233,6 +235,17 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
margin-top: 8px;
|
||||
background: var(--accent);}
|
||||
.prose h4 {font-size: 16.5px;}
|
||||
/* 悬停浮现的标题锚点(enhanceProse 注入,指向 heading id) */
|
||||
.prose .h-anchor {opacity: 0;
|
||||
margin-left: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72em;
|
||||
color: var(--faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 0;
|
||||
transition: opacity .2s;}
|
||||
.prose h2:hover .h-anchor, .prose h3:hover .h-anchor, .prose h4:hover .h-anchor {opacity: 1;}
|
||||
.prose .h-anchor:hover {color: var(--accent);}
|
||||
.prose blockquote {margin: 1.4em 0;
|
||||
padding: 2px 0 2px 18px;
|
||||
border-left: 2px solid var(--accent-line);
|
||||
@@ -252,12 +265,32 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
background: var(--paper-sunken);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;}
|
||||
.prose .pre-wrap {position: relative;}
|
||||
.pre-copy {position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
padding: 3px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--ink) 14%, transparent);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s, transform .08s;}
|
||||
.pre-copy:hover {opacity: 1;}
|
||||
.pre-wrap:hover .pre-copy {opacity: 0.85;}
|
||||
.pre-copy:active {transform: translateY(2px);}
|
||||
.pre-copy.done {opacity: 1;
|
||||
color: var(--accent);}
|
||||
.prose pre {background: var(--paper-sunken);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.7;}
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
line-height: 1.7;
|
||||
max-height: 30.5em;}
|
||||
.prose pre code {background: none;
|
||||
padding: 0;
|
||||
font-size: 13.5px;}
|
||||
@@ -268,14 +301,22 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
background: var(--line);
|
||||
margin: 2em 0;}
|
||||
.prose table {width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.4em 0;
|
||||
font-size: 15px;}
|
||||
.prose th, .prose td {border: 1px solid var(--line);
|
||||
padding: 7px 10px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 1.6em 0;
|
||||
font-size: 14.5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;}
|
||||
.prose th, .prose td {border: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
padding: 11px 16px;
|
||||
text-align: left;}
|
||||
.prose tr > * + * {border-left: 1px solid var(--line-soft);}
|
||||
.prose tbody tr:last-child td {border-bottom: 0;}
|
||||
.prose th {background: var(--paper-sunken);
|
||||
font-weight: 600;}
|
||||
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
|
||||
.prose-short {font-size: 20px;
|
||||
line-height: 1.9;}
|
||||
.prose-short > p:first-of-type::first-letter {float: none;
|
||||
@@ -283,6 +324,32 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
color: inherit;}
|
||||
/* 时间线里的短文同样不放大首字:一句话的纸条压个大字像截断
|
||||
(详情页 .prose-short 同此;长文详情的首字下沉保留) */
|
||||
.prose.short-body > p:first-of-type::first-letter {float: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
color: inherit;}
|
||||
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
|
||||
.post-imgs {display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 18px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;}
|
||||
.post-imgs img {display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;}
|
||||
.post-imgs.n2, .post-imgs.n4 {grid-template-columns: 1fr 1fr;}
|
||||
.post-imgs.n3 {grid-template-columns: 1fr 1fr;}
|
||||
.post-imgs.n2 img, .post-imgs.n4 img {aspect-ratio: 1;}
|
||||
.post-imgs.n3 img {aspect-ratio: 1;}
|
||||
.post-imgs.n3 img:first-child {grid-row: span 2;
|
||||
aspect-ratio: auto;
|
||||
height: 100%;}
|
||||
.post-imgs.n1 img {max-height: 560px;}
|
||||
.empty {padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
@@ -788,17 +855,37 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
0%, 100% {opacity: 1;}
|
||||
50% {opacity: 0.3;}
|
||||
}
|
||||
/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */
|
||||
.md-pane {width: 100%;
|
||||
min-height: 460px;
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 3px;
|
||||
background: var(--admin-card);
|
||||
padding: 18px 22px;
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
padding: 6px 14px;
|
||||
color: var(--admin-ink);
|
||||
resize: vertical;
|
||||
overflow: hidden;}
|
||||
.md-pane:focus-within {border-color: var(--admin-accent);}
|
||||
/* 轻提示栈:上传进度 / 转存结果 / 校验失败 */
|
||||
.toast-stack {position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;}
|
||||
.toast {padding: 9px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--admin-ink);
|
||||
background: var(--admin-card);
|
||||
border: 1px solid var(--admin-line);
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.12);
|
||||
overflow-wrap: anywhere;}
|
||||
.toast.ok {border-color: color-mix(in srgb, var(--admin-accent) 55%, transparent);}
|
||||
.toast.err {border-color: #c26a55;
|
||||
color: #b4553f;
|
||||
outline: none;}
|
||||
.md-pane:focus {border-color: var(--admin-accent);}
|
||||
.toolbar {display: flex;
|
||||
@@ -821,12 +908,13 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
color: var(--admin-muted);
|
||||
margin-right: 4px;
|
||||
text-transform: uppercase;}
|
||||
.tb-btn {width: 28px;
|
||||
height: 28px;
|
||||
/* xLog 式工具栏按钮:方形圆角、线性图标、悬停出底框 */
|
||||
.tb-btn {width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--admin-ink-soft);
|
||||
color: var(--admin-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
display: inline-flex;
|
||||
@@ -839,9 +927,12 @@ h1, h2, h3, h4 {font-family: var(--serif);
|
||||
border-color: var(--admin-line);
|
||||
color: var(--admin-ink);}
|
||||
}
|
||||
.tb-btn.italic {font-style: italic;}
|
||||
.tb-btn.mono {font-family: var(--mono);
|
||||
font-size: 11.5px;}
|
||||
.tb-btn svg {width: 17px;
|
||||
height: 17px;}
|
||||
.tb-text {font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: inherit;}
|
||||
.tb-popover {display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
@@ -1097,108 +1188,156 @@ a,
|
||||
评论区
|
||||
--------------------------------------------------------------------------
|
||||
classic 和 vivid 共用同一份 DOM(components/comments/)。
|
||||
视觉语言与去卡片后的文章页一致:评论正文直接落在纸面上,
|
||||
不再套带边框的气泡盒 —— 层级靠头像 / 名字行 / 留白撑起来。
|
||||
这里只写「两边都要」的基础,用经典那套 token —— vivid 把那批名字
|
||||
(--paper/--card/--ink/--accent/--line/--paper-sunken…)都别名到了 --v-*,
|
||||
所以同一份规则在两套 UI 下都成立。
|
||||
vivid 的差异(缺角气泡、渐变底、头像 ring、触屏尺寸)在
|
||||
vivid 的差异(头像配色、代码块、弹簧手感、触屏尺寸)在
|
||||
ui/yohaku/comments.css,那边特指度更高,会盖住这里。
|
||||
========================================================================== */
|
||||
|
||||
/* 头部只放计数 + 排序。刻意不写「评论」二字标题:参考站也是这样,
|
||||
靠计数和排序控件就能认出这是评论区,多一行标题只是噪音。 */
|
||||
/* 头部只放计数 + 排序。刻意不写「评论」二字标题:靠计数和排序控件
|
||||
就能认出这是评论区,多一行标题只是噪音。 */
|
||||
.cm-sec {margin-top: 28px;padding: 22px 26px 0;border-top: 1px solid var(--line);}
|
||||
.cm-head {display: flex;align-items: center;justify-content: space-between;
|
||||
gap: 12px;flex-wrap: wrap;margin-bottom: 20px;}
|
||||
gap: 12px;flex-wrap: wrap;margin-bottom: 24px;}
|
||||
.cm-count {font-size: 13px;font-weight: 600;color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;}
|
||||
/* classic 下是一排文字钮;vivid 里这个元素还带 .seg,会被 pill 化 */
|
||||
.cm-sort {display: inline-flex;align-items: center;gap: 2px;}
|
||||
.cm-sort button {font-size: 13px;font-weight: 600;color: var(--muted);
|
||||
padding: 5px 10px;border: 0;border-radius: 999px;background: none;cursor: pointer;}
|
||||
padding: 5px 10px;border: 0;border-radius: 999px;background: none;cursor: pointer;
|
||||
transition: color .2s,background-color .2s;}
|
||||
.cm-sort button.on {color: var(--accent);background: var(--accent-soft);}
|
||||
|
||||
/* 未登录:登录引导卡 */
|
||||
.cm-gate {padding: 22px;border: 1px solid var(--line);border-radius: 12px;
|
||||
background: var(--card);text-align: center;}
|
||||
.cm-gate-skel {height: 104px;background: var(--paper-sunken);border-style: dashed;}
|
||||
.cm-gate-t {margin: 0 0 14px;font-size: 13px;color: var(--muted);}
|
||||
/* 未登录:不画卡片,一行引导 + 一排登录方式,直接落在纸面上 */
|
||||
.cm-gate {padding: 26px 0 4px;text-align: center;}
|
||||
.cm-gate-skel {height: 96px;background: var(--paper-sunken);border-radius: 12px;}
|
||||
.cm-gate-t {margin: 0 0 16px;font-size: 13px;color: var(--muted);}
|
||||
.cm-gate-hint {margin: 0;font-size: 13px;color: var(--muted);}
|
||||
.cm-providers {display: flex;justify-content: center;gap: 12px;
|
||||
.cm-providers {display: flex;justify-content: center;gap: 14px;
|
||||
margin: 0;padding: 0;list-style: none;}
|
||||
.cm-provider {display: grid;place-items: center;width: 40px;height: 40px;
|
||||
border: 1px solid var(--line);border-radius: 50%;background: var(--card);color: var(--ink);
|
||||
.cm-provider {display: grid;place-items: center;width: 42px;height: 42px;
|
||||
border: 1px solid var(--line);border-radius: 50%;background: none;color: var(--muted);
|
||||
cursor: pointer;transition: border-color .2s,color .2s;}
|
||||
.cm-provider:hover {border-color: var(--accent);color: var(--accent);}
|
||||
.cm-brand {width: 18px;height: 18px;fill: currentColor;}
|
||||
.cm-brand-t {font-size: 15px;font-weight: 700;}
|
||||
.cm-tg {margin-top: 12px;}
|
||||
.cm-tg {margin-top: 14px;}
|
||||
|
||||
/* 输入区 */
|
||||
/* 输入区:没有盒子,只有 textarea 底下的一条「书写线」,聚焦时变强调色。
|
||||
名字 / 计数 / 发送排在线下方,像在纸上落款。 */
|
||||
.cm-compose {display: flex;gap: 12px;align-items: flex-start;}
|
||||
.cm-av-me {width: 36px;height: 36px;}
|
||||
.cm-field {flex: 1;min-width: 0;overflow: hidden;
|
||||
border: 1px solid var(--line);border-radius: 12px;background: var(--card);}
|
||||
.cm-field:focus-within {border-color: var(--accent);}
|
||||
.cm-field-inline {margin-top: 10px;}
|
||||
.cm-field-tip {margin: 0;padding: 10px 12px 0;font-size: 13px;color: var(--muted);}
|
||||
.cm-input {display: block;width: 100%;min-height: 72px;padding: 10px 12px;
|
||||
border: 0;background: none;resize: vertical;
|
||||
font: inherit;font-size: 14px;line-height: 1.7;color: var(--ink);}
|
||||
.cm-field {flex: 1;min-width: 0;}
|
||||
.cm-field-inline {margin-top: 12px;}
|
||||
.cm-input {display: block;width: 100%;min-height: 68px;padding: 4px 0 10px;
|
||||
border: 0;border-bottom: 1px solid var(--line);background: none;resize: vertical;
|
||||
font: inherit;font-size: 14.5px;line-height: 1.75;color: var(--ink);
|
||||
transition: border-color .2s;}
|
||||
.cm-input:focus {outline: none;}
|
||||
.cm-bar {display: flex;align-items: center;justify-content: space-between;gap: 10px;
|
||||
padding: 6px 10px;border-top: 1px solid var(--line-soft);
|
||||
font-size: 13px;color: var(--muted);}
|
||||
.cm-bar-l {min-width: 0;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
|
||||
.cm-bar-r {display: flex;flex: none;align-items: center;gap: 10px;}
|
||||
.cm-len {font-variant-numeric: tabular-nums;}
|
||||
.cm-field:focus-within .cm-input {border-bottom-color: var(--accent);}
|
||||
.cm-bar {position: relative;display: flex;align-items: center;justify-content: space-between;gap: 10px;
|
||||
padding: 8px 0 0;font-size: 12.5px;color: var(--muted);}
|
||||
.cm-bar-l {display: flex;align-items: center;gap: 8px;min-width: 0;
|
||||
overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
|
||||
.cm-bar-l b {font-weight: 600;color: var(--ink-soft);}
|
||||
.cm-bar-r {display: flex;flex: none;align-items: center;gap: 12px;}
|
||||
.cm-len {font-size: 12px;font-variant-numeric: tabular-nums;}
|
||||
.cm-len.over {color: #a64953;}
|
||||
.cm-link {padding: 0;border: 0;background: none;color: var(--muted);
|
||||
font-size: 13px;cursor: pointer;}
|
||||
.cm-link {padding: 4px 2px;border: 0;background: none;color: var(--muted);
|
||||
font-size: 12.5px;cursor: pointer;transition: color .2s;}
|
||||
.cm-link:hover {color: var(--accent);}
|
||||
.cm-send {padding: 5px 14px;border: 0;border-radius: 999px;cursor: pointer;
|
||||
background: var(--accent);color: #fff;font-size: 13px;font-weight: 600;}
|
||||
.cm-send:disabled {opacity: .45;cursor: default;}
|
||||
.cm-send {padding: 6px 18px;border: 0;border-radius: 999px;cursor: pointer;
|
||||
background: var(--accent);color: #fff;font-size: 13px;font-weight: 600;
|
||||
transition: opacity .2s;}
|
||||
.cm-send:disabled {opacity: .4;cursor: default;}
|
||||
.cm-err {margin: 14px 0 0;font-size: 13px;color: #a64953;}
|
||||
|
||||
/* 列表 */
|
||||
.cm-list {display: flex;flex-direction: column;gap: 18px;
|
||||
margin: 22px 0 0;padding: 0;list-style: none;}
|
||||
/* 表情 / 颜文字:触发钮在工具条上,面板从条上方弹出、盖住输入框。
|
||||
右锚点 —— 工具条右缘离屏边最近,面板往左伸才不出屏(手机上也成立) */
|
||||
.cm-emoji-btn {display: grid;place-items: center;width: 30px;height: 30px;
|
||||
border: 0;border-radius: 8px;background: none;font-size: 17px;line-height: 1;
|
||||
cursor: pointer;opacity: .72;transition: opacity .2s,background-color .2s;}
|
||||
.cm-emoji-btn:hover, .cm-emoji-btn.on {opacity: 1;background: var(--accent-soft);}
|
||||
.cm-emoji {position: absolute;right: 0;bottom: calc(100% + 8px);z-index: 30;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
padding: 10px;border: 1px solid var(--line);border-radius: 12px;
|
||||
background: var(--card);color: var(--ink);
|
||||
box-shadow: 0 12px 40px -16px rgba(28, 28, 30, .3);
|
||||
animation: cmEmojiIn .18s cubic-bezier(.22,.61,.36,1) both;}
|
||||
/* 上方放不下(打开时量过)翻到工具条下方 */
|
||||
.cm-emoji.is-below {bottom: auto;top: calc(100% + 8px);}
|
||||
@keyframes cmEmojiIn {from {opacity: 0;transform: translateY(4px)}}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cm-emoji {animation: none;}
|
||||
}
|
||||
.cm-emoji-tabs {display: flex;gap: 2px;margin-bottom: 8px;}
|
||||
.cm-emoji-tabs button {padding: 3px 10px;border: 0;border-radius: 999px;background: none;
|
||||
color: var(--muted);font-size: 12px;font-weight: 600;cursor: pointer;}
|
||||
.cm-emoji-tabs button.on {color: var(--accent);background: var(--accent-soft);}
|
||||
/* 网格自动填列:面板宽度随屏变化时列数自己跟 */
|
||||
.cm-emoji-grid, .cm-emoji-list {max-height: 224px;overflow-y: auto;
|
||||
scrollbar-width: thin;scrollbar-color: var(--line) transparent;}
|
||||
.cm-emoji-grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));gap: 2px;}
|
||||
.cm-emoji-cat {grid-column: 1 / -1;padding: 5px 2px 1px;font-size: 11px;color: var(--faint);}
|
||||
.cm-emoji-item {display: grid;place-items: center;border: 0;border-radius: 8px;
|
||||
background: none;color: var(--ink);font-size: 19px;line-height: 1;cursor: pointer;
|
||||
transition: background-color .15s,transform .3s;}
|
||||
.cm-emoji-item:hover {background: var(--accent-soft);}
|
||||
.cm-emoji-item:active {transform: scale(.88);}
|
||||
/* 颜文字是文本:不进方块格子,一行一个胶囊 */
|
||||
.cm-emoji-list {display: flex;flex-wrap: wrap;gap: 4px;align-content: flex-start;}
|
||||
.cm-kao {width: auto;aspect-ratio: auto;padding: 5px 9px;font-size: 13px;font-weight: 600;}
|
||||
|
||||
/* 列表:条与条之间靠留白分隔,不画线不画盒 */
|
||||
.cm-list {display: flex;flex-direction: column;gap: 28px;
|
||||
margin: 26px 0 0;padding: 0;list-style: none;}
|
||||
.cm-item {display: flex;gap: 12px;align-items: flex-start;}
|
||||
.cm-av {position: relative;display: grid;place-items: center;flex: none;
|
||||
width: 36px;height: 36px;border-radius: 50%;
|
||||
background: var(--paper-sunken);color: var(--muted);
|
||||
font-size: 14px;font-weight: 700;
|
||||
box-shadow: 0 0 0 2px var(--paper);}
|
||||
font-size: 14px;font-weight: 700;}
|
||||
.cm-av img {display: block;width: 100%;height: 100%;border-radius: 50%;object-fit: cover;}
|
||||
.cm-av.is-owner {box-shadow: 0 0 0 2px var(--accent-soft);}
|
||||
/* 头像角上的登录方式小标 */
|
||||
.cm-pv {position: absolute;right: -2px;bottom: -2px;
|
||||
display: grid;place-items: center;width: 14px;height: 14px;border-radius: 50%;
|
||||
/* 头像角上的登录方式小标:回复的 26px 头像戴不下,只在顶层显示 */
|
||||
.cm-pv {position: absolute;right: -3px;bottom: -3px;
|
||||
display: grid;place-items: center;width: 15px;height: 15px;border-radius: 50%;
|
||||
background: var(--card);color: var(--muted);font-style: normal;
|
||||
box-shadow: 0 0 0 1.5px var(--line);}
|
||||
.cm-pv svg {width: 8px;height: 8px;fill: currentColor;}
|
||||
.cm-replies .cm-pv {display: none;}
|
||||
.cm-main {flex: 1;min-width: 0;}
|
||||
.cm-name {display: flex;align-items: center;gap: 8px;flex-wrap: wrap;margin-bottom: 6px;}
|
||||
.cm-name-a {font-size: 13.5px;font-weight: 600;color: var(--ink);}
|
||||
|
||||
/* 名字行:名字、徽标、时间共用一条基线 */
|
||||
.cm-name {display: flex;align-items: baseline;gap: 8px;flex-wrap: wrap;
|
||||
row-gap: 2px;margin-bottom: 6px;}
|
||||
.cm-name-a {font-size: 14px;font-weight: 600;color: var(--ink);}
|
||||
a.cm-name-a:hover {color: var(--accent);}
|
||||
.cm-owner {padding: 1px 6px;border-radius: 999px;background: var(--accent-soft);
|
||||
.cm-owner {padding: 1px 7px;border-radius: 999px;background: var(--accent-soft);
|
||||
color: var(--accent);font-size: 11px;font-weight: 600;}
|
||||
.cm-time {font-size: 11.5px;color: var(--muted);font-variant-numeric: tabular-nums;}
|
||||
.cm-pending {padding: 1px 6px;border-radius: 999px;background: var(--paper-sunken);
|
||||
.cm-time {font-size: 12px;color: var(--faint);font-variant-numeric: tabular-nums;}
|
||||
.cm-pending {padding: 1px 7px;border-radius: 999px;background: var(--paper-sunken);
|
||||
color: var(--muted);font-size: 11px;font-weight: 600;}
|
||||
.cm-edited-tip {font-size: 11px;color: var(--muted);opacity: .75;}
|
||||
.cm-edited-tip {font-size: 11.5px;color: var(--faint);}
|
||||
|
||||
/* 气泡:左上角不收圆(缺口朝头像),是这套评论的形状特征 */
|
||||
.cm-bubble {position: relative;display: inline-block;
|
||||
max-width: calc(100% - 3rem);padding: 8px 12px;
|
||||
border: 1px solid var(--line);border-radius: 12px;border-top-left-radius: 3px;
|
||||
background: var(--card);color: var(--ink);}
|
||||
.cm-item.is-reply .cm-bubble {border-top-left-radius: 12px;}
|
||||
.cm-item.is-deleted .cm-bubble {background: none;border-style: dashed;}
|
||||
.cm-gone {font-size: 13px;color: var(--muted);font-style: italic;}
|
||||
/* 正文:类名保留 .cm-bubble(DOM 不动),盒子拆掉 —— 和文章正文同一种「纸」。
|
||||
删除态不给盒子,靠变灰变斜认。 */
|
||||
.cm-bubble {display: block;padding: 0;border: 0;background: none;color: var(--ink);}
|
||||
.cm-item.is-deleted .cm-bubble {color: var(--muted);}
|
||||
.cm-gone {font-size: 13.5px;color: var(--muted);font-style: italic;}
|
||||
|
||||
/* 正文(走 CommentMarkdown,后端已渲染 + 前端 DOMPurify 再洗一遍) */
|
||||
.cm-md {font-size: 14px;line-height: 1.65;}
|
||||
/* 正文(走 CommentMarkdown,后端已渲染 + 前端 DOMPurify 再洗一遍)。
|
||||
@提及 和 .cm-md 共用字号行高:float 贴首行时两者行盒必须一样高,
|
||||
三处成对存在(基础 / 手机断点 / vivid),改字号要一起改。 */
|
||||
.cm-md, .cm-at {font-size: 14.5px;line-height: 1.75;}
|
||||
.cm-md {
|
||||
/* 评论是用户生成内容,长链接/长代码串必须在行宽内断行 */
|
||||
overflow-wrap: break-word;}
|
||||
/* 回复回复时的 @对方:浮在正文首行行首(和首字下沉同一个技巧),
|
||||
不占一整行;正文以代码块/列表开头时由 .cm-at-block 退成独立一行 */
|
||||
.cm-at {float: left;margin: 0 .45em 0 0;color: var(--accent);font-weight: 600;}
|
||||
.cm-at-block {float: none;margin: 0 0 4px;}
|
||||
.cm-md > :first-child {margin-top: 0;}
|
||||
.cm-md > :last-child {margin-bottom: 0;}
|
||||
.cm-md p {margin: 0 0 .6em;}
|
||||
@@ -1209,37 +1348,104 @@ a.cm-name-a:hover {color: var(--accent);}
|
||||
background: var(--paper-sunken);}
|
||||
.cm-md pre code {padding: 0;background: none;}
|
||||
.cm-md ul, .cm-md ol {margin: .5em 0;padding-left: 1.4em;}
|
||||
.cm-md blockquote {margin: .6em 0;padding-left: 10px;border-left: 2px solid var(--line);
|
||||
.cm-md blockquote {margin: .6em 0;padding-left: 12px;border-left: 2px solid var(--line);
|
||||
color: var(--muted);}
|
||||
.cm-md img {max-width: 100%;border-radius: 6px;}
|
||||
|
||||
/* 编辑 / 删除 / 回复:hover 或键盘聚焦才出现,触屏直接常显 */
|
||||
.cm-acts {position: absolute;right: 0;bottom: -10px;display: flex;gap: 4px;
|
||||
/* 操作:一行安静的文字钮(回复 / 编辑 / 删除)。桌面 hover 或键盘聚焦
|
||||
才浮现,位置预留、不跳版;触屏常显、加高可点面积。删除悬停给红。 */
|
||||
.cm-acts {display: flex;gap: 2px;margin-top: 2px;
|
||||
opacity: 0;transition: opacity .2s;}
|
||||
.cm-item:hover .cm-acts, .cm-item:focus-within .cm-acts {opacity: 1;}
|
||||
@media (hover: none) {
|
||||
.cm-acts {opacity: 1;}
|
||||
}
|
||||
.cm-act {display: grid;place-items: center;width: 24px;height: 24px;
|
||||
border: 1px solid var(--line);border-radius: 50%;background: var(--card);
|
||||
color: var(--muted);font-size: 12px;cursor: pointer;}
|
||||
.cm-act:hover {border-color: var(--accent);color: var(--accent);}
|
||||
.cm-act {padding: 5px 8px;border: 0;background: none;color: var(--muted);
|
||||
font-size: 12.5px;font-weight: 600;cursor: pointer;transition: color .2s;}
|
||||
.cm-act:first-child {margin-left: -8px;}
|
||||
.cm-act:hover {color: var(--accent);}
|
||||
.cm-act.is-danger:hover {color: #a64953;}
|
||||
/* 键盘可达:文字钮没有边框,聚焦环自己画(鼠标点击不触发 focus-visible) */
|
||||
.cm-act:focus-visible, .cm-link:focus-visible, .cm-more-r:focus-visible,
|
||||
.cm-sort button:focus-visible, .cm-provider:focus-visible {
|
||||
outline: 2px solid var(--accent-line);outline-offset: 2px;border-radius: 6px;}
|
||||
.cm-send:focus-visible {outline: 2px solid var(--accent-line);outline-offset: 2px;}
|
||||
|
||||
/* 回复只有一层,缩进 36px */
|
||||
.cm-replies {display: flex;flex-direction: column;gap: 14px;
|
||||
margin: 14px 0 0;padding: 0 0 0 36px;list-style: none;}
|
||||
.cm-replies .cm-av {width: 28px;height: 28px;font-size: 12px;}
|
||||
.cm-more-r {margin: 10px 0 0 36px;padding: 4px 12px;border: 1px solid var(--line);
|
||||
border-radius: 999px;background: none;color: var(--muted);font-size: 12px;cursor: pointer;}
|
||||
.cm-more-r:hover {border-color: var(--accent);color: var(--accent);}
|
||||
/* 回复:只有一层。左侧一条发丝线把回复串起来(和左栏时间线同一语言),
|
||||
线从第一条回复头顶到末条脚下,比空白缩进更有「同属一条」的指向。 */
|
||||
.cm-replies {display: flex;flex-direction: column;gap: 18px;
|
||||
margin: 14px 0 0;padding: 0 0 2px 18px;list-style: none;
|
||||
border-left: 1px solid var(--line);}
|
||||
.cm-replies .cm-av {width: 26px;height: 26px;font-size: 11.5px;}
|
||||
.cm-more-r {margin: 12px 0 0;padding: 5px 0;border: 0;background: none;
|
||||
color: var(--muted);font-size: 12.5px;font-weight: 600;cursor: pointer;
|
||||
transition: color .2s;}
|
||||
.cm-more-r:hover {color: var(--accent);}
|
||||
|
||||
/* 分页 / 空态 */
|
||||
.cm-more {display: flex;justify-content: center;margin-top: 22px;}
|
||||
.cm-empty {margin-top: 22px;}
|
||||
.cm-more {display: flex;justify-content: center;margin-top: 26px;}
|
||||
.cm-empty {margin-top: 26px;}
|
||||
|
||||
/* 骨架 */
|
||||
/* 骨架:和真实列表同一副骨架(头像 + 两行),落位一致才不闪 */
|
||||
.cm-skel-row {display: flex;gap: 12px;align-items: flex-start;}
|
||||
.cm-skel-av {flex: none;width: 36px;height: 36px;border-radius: 50%;background: var(--paper-sunken);}
|
||||
.cm-skel-lines {display: flex;flex: 1;flex-direction: column;gap: 8px;}
|
||||
.cm-skel-lines {display: flex;flex: 1;flex-direction: column;gap: 8px;padding-top: 3px;}
|
||||
.cm-skel-lines i {display: block;height: 12px;border-radius: 6px;background: var(--paper-sunken);}
|
||||
.cm-skel-lines i:last-child {width: 55%;}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
手机(≤640):评论是纯阅读 + 触控场景 —— 缩头像、收紧缩进、
|
||||
可点目标加高、输入 16px 防 iOS 聚焦缩放。
|
||||
页边距与 .article-layout 的手机页边距(12px,见 mobile.css)对齐,
|
||||
正文和评论共用同一条左缘,头像离屏幕边 12px。
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (max-width: 640px) {
|
||||
.cm-sec {padding: 20px 0 0;}
|
||||
.cm-head {margin-bottom: 18px;}
|
||||
.cm-list {gap: 22px;margin-top: 20px;}
|
||||
.cm-item {gap: 10px;}
|
||||
.cm-av, .cm-av-me {width: 28px;height: 28px;font-size: 12px;}
|
||||
.cm-name-a {font-size: 13.5px;}
|
||||
.cm-md, .cm-at {font-size: 14px;line-height: 1.7;}
|
||||
.cm-replies {padding-left: 14px;gap: 14px;}
|
||||
.cm-replies .cm-av {width: 24px;height: 24px;font-size: 10.5px;}
|
||||
.cm-act {padding: 8px 10px;}
|
||||
.cm-act:first-child {margin-left: -10px;}
|
||||
.cm-emoji-btn {width: 36px;height: 36px;}
|
||||
.cm-sort button {min-height: 44px;}
|
||||
.cm-provider {width: 44px;height: 44px;}
|
||||
.cm-compose {gap: 10px;}
|
||||
.cm-input {font-size: 16px;}
|
||||
.cm-send {padding: 9px 18px;}
|
||||
.cm-link {padding: 8px 2px;}
|
||||
}
|
||||
/* 触屏(可能是平板/桌面触屏,不限于窄屏):可点目标给足 */
|
||||
@media (pointer: coarse) {
|
||||
.cm-provider {width: 44px;height: 44px;}
|
||||
.cm-act {padding: 8px 10px;}
|
||||
.cm-emoji-btn {width: 36px;height: 36px;}
|
||||
.cm-link {padding: 8px 2px;}
|
||||
.cm-more-r {padding: 10px 0;}
|
||||
.cm-input {font-size: 16px;}
|
||||
}
|
||||
/* 编辑器图片缩放把手:Milkdown 图片块 hover 底部出现拖拽条(上下拖调高度,
|
||||
比例存进 markdown 的 alt)。默认半透明让访客知道它在那儿。 */
|
||||
.admin-shell .image-resize-handle {opacity: 0.45;}
|
||||
.admin-shell .image-wrapper:hover .image-resize-handle {opacity: 1;}
|
||||
|
||||
/* lightbox 打开时藏掉吸顶/固定栏:一是全屏观感,二是绕开真机上
|
||||
backdrop-filter 元素合成层盖过遮罩的 Chromium 绘制问题 */
|
||||
body.lb-open .sticky-head,
|
||||
body.lb-open .topbar,
|
||||
body.lb-open .reading-bar {visibility: hidden;}
|
||||
|
||||
/* classic 文章页阅读进度条:视口顶部细线,vivid 的同款能力 */
|
||||
.reading-bar {position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
z-index: 60;
|
||||
transition: width .1s linear;
|
||||
pointer-events: none;}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { sanitizeHtml } from '../../utils'
|
||||
import { applyImageRatio, sanitizeHtml } from '../../utils'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import CommentSection from '../../components/comments/CommentSection.vue'
|
||||
import LinkCard from '../../components/LinkCard.vue'
|
||||
|
||||
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
|
||||
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
|
||||
@@ -166,7 +168,27 @@ function onTocClick(e) {
|
||||
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
|
||||
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
|
||||
|
||||
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
|
||||
<div class="prose" :class="{ 'prose-note': isShort }" v-html="applyImageRatio(sanitizeHtml(props.contentHtml))" />
|
||||
|
||||
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
|
||||
<div
|
||||
v-if="isShort && props.post.images && props.post.images.length"
|
||||
class="post-imgs"
|
||||
:class="'n' + Math.min(props.post.images.length, 4)"
|
||||
>
|
||||
<img
|
||||
v-for="(u, i) in props.post.images.slice(0, 4)"
|
||||
:key="i"
|
||||
:src="u"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@click="openLightbox(props.post.images, i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
|
||||
|
||||
<div class="end">
|
||||
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import { thumbURL, thumbifyHtml } from '../../utils'
|
||||
import LinkCard from '../../components/LinkCard.vue'
|
||||
|
||||
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
|
||||
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
|
||||
@@ -34,7 +37,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
<!-- 长文:卡片 -->
|
||||
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
|
||||
<div v-if="p.cover_url" class="cov">
|
||||
<img :src="p.cover_url" alt="" loading="lazy">
|
||||
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ p.title }}</h2>
|
||||
@@ -53,7 +56,26 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
|
||||
<!-- 短文:纸条,正文直接铺开 -->
|
||||
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
|
||||
<div class="body" v-html="p.content_html" />
|
||||
<div class="body" v-html="thumbifyHtml(p.content_html)" />
|
||||
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
|
||||
<div
|
||||
v-if="p.images && p.images.length"
|
||||
class="imgs"
|
||||
:class="'n' + Math.min(p.images.length, 4)"
|
||||
>
|
||||
<img
|
||||
v-for="(u, i) in p.images.slice(0, 4)"
|
||||
:key="i"
|
||||
:src="thumbURL(u, p.images.length === 1 ? 720 : 480)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@click.prevent.stop="openLightbox(p.images, i)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="p.link_card" :card="p.link_card" />
|
||||
|
||||
<div class="meta">
|
||||
<span class="badge short">SHORT</span>
|
||||
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { site } from '../../site'
|
||||
import { socialIcon } from '../../socialIcons'
|
||||
|
||||
// 社交排只放站主自己的账号;源码仓库是「项目信息」,和 classic 页脚一样单独
|
||||
// 成行挂在版权旁边(RightRail 的 .foot-meta)。混在一起会让人以为这是本站的
|
||||
// 社交身份之一,而它其实是代码仓库入口。
|
||||
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
||||
const social = (site.social_links || []).filter((s) => s && s.url && s.url !== PROJECT_REPO)
|
||||
|
||||
// 余白页脚。对应设计稿的 <footer class="foot">。
|
||||
//
|
||||
@@ -21,7 +28,20 @@ const links = [
|
||||
<template>
|
||||
<footer class="foot">
|
||||
<div class="quote">What’s left unsaid holds the most weight.</div>
|
||||
<div>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
|
||||
<div v-if="social.length" class="foot-social">
|
||||
<a
|
||||
v-for="s in social"
|
||||
:key="s.label + s.url"
|
||||
:href="s.url"
|
||||
:title="s.label"
|
||||
:aria-label="s.label"
|
||||
:target="s.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
|
||||
>
|
||||
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
|
||||
<span v-else>{{ s.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="foot-nav">
|
||||
<template v-for="(l, i) in links" :key="l.to">
|
||||
<span v-if="i" class="sep">·</span>
|
||||
@@ -30,5 +50,13 @@ const links = [
|
||||
<span class="sep">·</span>
|
||||
<a href="/rss.xml">RSS</a>
|
||||
</div>
|
||||
<!-- 项目信息:固定的源码入口 + 版权,压在最后。和上面那排社交账号分开——
|
||||
它是代码仓库入口,不是站主的社交身份(顺序同 classic 的 RightRail) -->
|
||||
<div class="foot-meta">
|
||||
<a :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(PROJECT_REPO)" /></svg>
|
||||
</a>
|
||||
<span>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
|
||||
import YohakuToolbar from './YohakuToolbar.vue'
|
||||
import YohakuFeed from './YohakuFeed.vue'
|
||||
import YohakuPager from './YohakuPager.vue'
|
||||
import { thumbURL } from '../../utils'
|
||||
|
||||
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
|
||||
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
|
||||
@@ -50,6 +51,9 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
|
||||
@update:query="emit('update:query', $event)"
|
||||
/>
|
||||
|
||||
<!-- 短文快发盒(HomeView 传入;只有管理员会话在场才渲染) -->
|
||||
<slot name="compose" />
|
||||
|
||||
<YohakuFeed :items="items" />
|
||||
|
||||
<div v-if="!items.length" class="empty">
|
||||
@@ -78,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
|
||||
to="/projects"
|
||||
>
|
||||
<div v-if="p.cover_url" class="cov">
|
||||
<img :src="p.cover_url" alt="" loading="lazy">
|
||||
<img :src="thumbURL(p.cover_url, 480)" alt="" loading="lazy">
|
||||
<span v-if="p.status" class="st">{{ p.status }}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { site } from '../../site'
|
||||
import NavBurger from '../../components/NavBurger.vue'
|
||||
import AuthorAvatar from '../../components/AuthorAvatar.vue'
|
||||
|
||||
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
|
||||
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
|
||||
// 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。
|
||||
//
|
||||
// 窄屏例外:设计稿在窄屏把导航整个 display:none 掉了,手机上会没有任何导航入口。
|
||||
// 这里补一个 ☰ + 全屏菜单(chrome.css 的 .menu / .menu-btn)。
|
||||
// 这里补一个汉堡钮 + 全屏菜单(chrome.css 的 .menu)。菜单 z-index 压在顶栏
|
||||
// 之下:顶栏(品牌 + 汉堡)浮在菜单上不消失,汉堡原位并成 ✕,不再需要一个
|
||||
// 重复的菜单头。
|
||||
|
||||
const route = useRoute()
|
||||
const scrolled = ref(false)
|
||||
@@ -24,7 +28,6 @@ const links = [
|
||||
{ to: '/about', label: '关于', en: 'About' }
|
||||
]
|
||||
|
||||
const initial = () => (site.author_name || site.site_title || 'O').trim().slice(0, 1).toUpperCase()
|
||||
const title = () => site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE'
|
||||
|
||||
function isOn(l) {
|
||||
@@ -35,6 +38,9 @@ function onScroll() {
|
||||
scrolled.value = (window.scrollY || 0) > 8
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value ? closeMenu() : openMenu()
|
||||
}
|
||||
function openMenu() {
|
||||
menuOpen.value = true
|
||||
// 焦点交给对话框本身(aria-modal 承诺过),Tab 第一下才落到「时间线」。
|
||||
@@ -44,7 +50,8 @@ function openMenu() {
|
||||
}
|
||||
function closeMenu() {
|
||||
menuOpen.value = false
|
||||
nextTick(() => burger.value?.focus({ preventScroll: true }))
|
||||
const b = burger.value?.$el || burger.value
|
||||
nextTick(() => b?.focus?.({ preventScroll: true }))
|
||||
}
|
||||
|
||||
// 菜单铺满全屏时锁住底层滚动(iOS 上不锁背景会跟着手指滑)
|
||||
@@ -87,7 +94,7 @@ function toggleTheme() {
|
||||
<header class="top" :class="{ scrolled }">
|
||||
<div class="top-in">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span class="av">{{ initial() }}</span>
|
||||
<span class="av"><AuthorAvatar /></span>
|
||||
<span>{{ title() }}</span>
|
||||
</RouterLink>
|
||||
<nav class="nav" aria-label="站点导航">
|
||||
@@ -100,53 +107,57 @@ function toggleTheme() {
|
||||
</nav>
|
||||
<div class="top-acts">
|
||||
<button class="icon-btn theme-btn" type="button" title="切换明暗" aria-label="切换明暗" @click="toggleTheme">◐</button>
|
||||
<button
|
||||
ref="burger"
|
||||
class="icon-btn menu-btn"
|
||||
type="button"
|
||||
aria-label="打开菜单"
|
||||
aria-controls="v-menu"
|
||||
:aria-expanded="menuOpen"
|
||||
@click="openMenu"
|
||||
>☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 窄屏全屏菜单。必须待在 .top 外面:.top 有 backdrop-filter,
|
||||
会给 position:fixed 的后代当包含块,铺不满屏。 -->
|
||||
<!-- 窄屏:底部悬浮 dock(品牌 + 汉堡),常驻拇指区。桌面 display:none。
|
||||
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 -->
|
||||
<div class="dock">
|
||||
<RouterLink class="dock-brand" to="/">
|
||||
<span class="av"><AuthorAvatar /></span>
|
||||
<span>{{ title() }}</span>
|
||||
</RouterLink>
|
||||
<NavBurger
|
||||
ref="burger"
|
||||
:open="menuOpen"
|
||||
aria-controls="v-menu"
|
||||
:aria-label="menuOpen ? '关闭菜单' : '打开菜单'"
|
||||
@toggle="toggleMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 窄屏上拉菜单:scrim + 圆角毛玻璃 sheet,从 dock 上方长出来 -->
|
||||
<Transition name="v-menu">
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
id="v-menu"
|
||||
ref="menuEl"
|
||||
class="menu"
|
||||
class="menu-root"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="站点导航"
|
||||
tabindex="-1"
|
||||
@click.self="closeMenu"
|
||||
>
|
||||
<div class="menu-head">
|
||||
<span class="m-brand">
|
||||
<span class="av">{{ initial() }}</span>
|
||||
<span>{{ title() }}</span>
|
||||
</span>
|
||||
<button class="icon-btn" type="button" aria-label="关闭菜单" @click="closeMenu">✕</button>
|
||||
</div>
|
||||
<nav class="menu-in">
|
||||
<RouterLink
|
||||
v-for="l in links"
|
||||
:key="l.to"
|
||||
:to="l.to"
|
||||
:class="{ on: isOn(l) }"
|
||||
>
|
||||
<span>{{ l.label }}</span>
|
||||
<em>{{ l.en }}</em>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="menu-foot">
|
||||
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
|
||||
<div class="scrim" @click="closeMenu" />
|
||||
<div class="menu">
|
||||
<nav class="menu-in" aria-label="站点导航">
|
||||
<RouterLink
|
||||
v-for="(l, i) in links"
|
||||
:key="l.to"
|
||||
:to="l.to"
|
||||
:class="{ on: isOn(l) }"
|
||||
:style="{ '--i': i }"
|
||||
>
|
||||
<span class="idx">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<span class="zh">{{ l.label }}</span>
|
||||
<em>{{ l.en }}</em>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="menu-foot">
|
||||
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
|
||||
<p v-if="site.site_desc" class="m-desc">{{ site.site_desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -3,7 +3,10 @@ html[data-ui='vivid'] .article-layout {max-width:1100px;margin:0 auto;padding:24
|
||||
grid-template-columns:140px minmax(0,1fr) 180px;
|
||||
gap:24px;align-items:start;}
|
||||
@media (max-width:960px) {
|
||||
html[data-ui='vivid'] .article-layout {grid-template-columns:1fr;gap:0}
|
||||
/* 1fr 的自动最小尺寸是 min-content:正文里出现不可断行的长 token
|
||||
(长内联代码/链接)时轨道会被顶到 592px+,整页横向溢出。
|
||||
minmax(0,1fr) 和 classic 的 .layout 同一写法,把最小尺寸钉死为 0。 */
|
||||
html[data-ui='vivid'] .article-layout {grid-template-columns:minmax(0,1fr);gap:0}
|
||||
html[data-ui='vivid'] .side-l, html[data-ui='vivid'] .side-r {display:none}
|
||||
html[data-ui='vivid'] .progress-pct {display:none}
|
||||
}
|
||||
@@ -61,7 +64,9 @@ html[data-ui='vivid'] .paper > .dek {margin:0 0 32px;font-size:17px;line-height:
|
||||
font-family:var(--serif);}
|
||||
html[data-ui='vivid'] .prose {font-size:16.5px;line-height:1.95;color:var(--text);max-width:var(--measure);
|
||||
/* 中英混排自动补一点字距(Chrome 120+ 的 text-autospace)。不支持就退回现在的样子。 */
|
||||
text-autospace:normal;}
|
||||
text-autospace:normal;
|
||||
/* 长 token(URL/内联代码)超出行宽时在任意字符处断行,别把列撑破 */
|
||||
overflow-wrap:break-word;}
|
||||
/* 段距上下各 1.5em(参考 .v10dui0 .rich-paragraph),首末段把外边距吃掉,
|
||||
否则标题/分隔线上方会多出一段空白 */
|
||||
html[data-ui='vivid'] .prose p {margin:1.5em 0}
|
||||
@@ -91,8 +96,16 @@ html[data-ui='vivid'] .prose code {font-family:var(--mono);font-size:.88em;
|
||||
padding:2px 7px;border-radius:6px;}
|
||||
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
|
||||
color:#e8e8f0;padding:18px 20px;border-radius:12px;
|
||||
overflow-x:auto;font-size:13.5px;line-height:1.7;
|
||||
box-shadow:var(--shadow-lg);}
|
||||
overflow:auto;font-size:13.5px;line-height:1.7;
|
||||
box-shadow:var(--shadow-lg);
|
||||
max-height:30.5em;}
|
||||
/* 复制按钮(vivid 配色:浅字浮在深底上) */
|
||||
html[data-ui='vivid'] .pre-copy {color:#e8e8f0;
|
||||
background:rgba(255,255,255,.14);}
|
||||
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
|
||||
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
|
||||
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
|
||||
border:1px solid var(--line);box-shadow:none;}
|
||||
html[data-ui='vivid'] .prose pre code {background:none;padding:0;color:inherit;font-size:inherit}
|
||||
html[data-ui='vivid'] .prose blockquote {margin:1.8em 0;padding:16px 20px;
|
||||
background:var(--accent-soft);
|
||||
@@ -121,6 +134,11 @@ html[data-ui='vivid'] .paper > .end {margin-top:40px;padding-top:24px;border-top
|
||||
display:flex;gap:14px;flex-wrap:wrap;align-items:center;}
|
||||
html[data-ui='vivid'] .paper > .end .tg {padding:6px 0}
|
||||
|
||||
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;跟 prose 同宽,圆角跟 vivid 走) */
|
||||
html[data-ui='vivid'] .post-imgs {margin-top: 6px;
|
||||
border-radius: var(--radius);}
|
||||
html[data-ui='vivid'] .post-imgs.n1 img {max-height: 520px;}
|
||||
|
||||
/* ==========================================================================
|
||||
把 classic 的 .prose 挡回去
|
||||
--------------------------------------------------------------------------
|
||||
@@ -201,3 +219,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
|
||||
white-space: nowrap;
|
||||
border: 0;}
|
||||
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
|
||||
|
||||
/* ---- 表格(xLog 式:圆角容器 + 斑马纹,替代满格线) ---- */
|
||||
html[data-ui='vivid'] .prose table {width:100%;
|
||||
border-collapse:separate;border-spacing:0;
|
||||
margin:1.8em 0;font-size:14px;
|
||||
border:1px solid var(--line);border-radius:12px;overflow:hidden;}
|
||||
html[data-ui='vivid'] .prose th, html[data-ui='vivid'] .prose td {border:0;
|
||||
border-bottom:1px solid var(--line-soft);
|
||||
padding:12px 18px;text-align:left;}
|
||||
html[data-ui='vivid'] .prose tr > * + * {border-left:1px solid var(--line-soft);}
|
||||
html[data-ui='vivid'] .prose tbody tr:last-child td {border-bottom:0;}
|
||||
html[data-ui='vivid'] .prose th {background:var(--accent-soft);font-weight:600;}
|
||||
html[data-ui='vivid'] .prose tbody tr:nth-child(even) {background:var(--card);}
|
||||
/* 标题锚点 + 复制按钮沿用 classic 的 .h-anchor/.pre-copy 基础样式 */
|
||||
@@ -15,15 +15,8 @@ html[data-ui='vivid'] .brand {display:flex;align-items:center;gap:10px;
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .brand:hover {transform:scale(1.04)}
|
||||
}
|
||||
/* .m-brand 是全屏菜单里那个同款标头(菜单铺满屏后顶栏被盖住,得有个续接) */
|
||||
html[data-ui='vivid'] :is(.brand, .m-brand) .av {width:30px;height:30px;border-radius:50%;
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-deep));
|
||||
display:grid;place-items:center;color:#fff;font-size:13px;font-weight:800;
|
||||
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
|
||||
position:relative;}
|
||||
html[data-ui='vivid'] :is(.brand, .m-brand) .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
|
||||
width:9px;height:9px;border-radius:50%;
|
||||
background:#34c759;border:2px solid var(--paper);}
|
||||
/* .av 品牌标记:原来这里是一条 .brand .av 规则,但它被下面 :is(.brand,.dock-brand)
|
||||
那条同特指度、且位置更靠后的规则完整覆盖了——等于死规则。合并成一条,避免两处漂移。 */
|
||||
html[data-ui='vivid'] .nav {display:flex;gap:2px;flex:1;justify-content:center}
|
||||
html[data-ui='vivid'] .nav a {position:relative;font-size:13.5px;font-weight:600;color:var(--soft);
|
||||
padding:8px 14px;border-radius:999px;
|
||||
@@ -50,56 +43,109 @@ html[data-ui='vivid'] .icon-btn {width:36px;height:36px;border-radius:50%;
|
||||
写在后面会把 .menu-btn {display:grid} 压掉。 */
|
||||
html[data-ui='vivid'] .menu-btn {display:none}
|
||||
@media (max-width:720px) {
|
||||
/* 设计稿在窄屏把导航整个藏了 / 早先的做法是品牌行下方再摊一条横向滚动行,
|
||||
5 个中文项挤成一条平铺,读起来很碎。改成收进 ☰ 全屏菜单。 */
|
||||
html[data-ui='vivid'] .nav {display:none}
|
||||
html[data-ui='vivid'] .menu-btn {display:grid}
|
||||
/* 主题切换收进 ☰(菜单底部那枚)—— 顶栏和菜单各一个就是重复。
|
||||
桌面没有 ☰,所以只在窄屏藏:那枚 ◐ 是设计稿顶栏自带的,留着。 */
|
||||
html[data-ui='vivid'] .theme-btn {display:none}
|
||||
/* 浏览器标题栏已经在显示站点名,品牌文字在手机上是重复的 —— 只留头像标记 */
|
||||
html[data-ui='vivid'] .brand > span:last-child {display:none}
|
||||
/* .nav 原来是 flex:1(把品牌顶左、操作顶右),它一藏起来就得自己撑开 */
|
||||
html[data-ui='vivid'] .top-in {justify-content:space-between;gap:12px;padding:0 16px}
|
||||
/* 顶栏整条在手机上退场:品牌和导航都挪到底部悬浮 dock(参考 innei.in 的
|
||||
底部胶囊),内容区一寸顶栏都不占。桌面照旧走内联导航。
|
||||
dock 的 display:flex 在下面 .dock 基准规则之后 —— 同特指度后导入者赢,
|
||||
写在前面会被 display:none 压掉(chrome.css 老坑)。 */
|
||||
html[data-ui='vivid'] .top {display:none}
|
||||
}
|
||||
|
||||
/* ---- 窄屏全屏菜单 ---- */
|
||||
html[data-ui='vivid'] .m-brand {display:flex;align-items:center;gap:10px;
|
||||
font-weight:700;font-size:15px;letter-spacing:.02em;}
|
||||
html[data-ui='vivid'] .menu {position:fixed;inset:0;z-index:150;
|
||||
background:var(--paper);
|
||||
/* ---- 窄屏:底部悬浮 dock + 上拉菜单 ----
|
||||
dock 常驻拇指区(品牌 + 汉堡),点开一片从底部长出来的圆角毛玻璃菜单。 */
|
||||
html[data-ui='vivid'] .dock {position:fixed;z-index:90;
|
||||
left:50%;bottom:max(14px, env(safe-area-inset-bottom));transform:translateX(-50%);
|
||||
display:none;align-items:center;gap:10px;
|
||||
padding:6px 8px 6px 14px;border-radius:999px;
|
||||
border:1px solid var(--line);
|
||||
background:color-mix(in srgb,var(--card) 82%,transparent);
|
||||
backdrop-filter:blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.5);
|
||||
box-shadow:var(--shadow-lg);}
|
||||
html[data-ui='vivid'] .dock-brand {display:flex;align-items:center;gap:9px;
|
||||
font-weight:700;font-size:14px;letter-spacing:.02em;color:var(--ink);}
|
||||
/* 窄屏才把 dock 摆出来;这条必须压在基准规则(display:none)之后 */
|
||||
@media (max-width:720px) {
|
||||
html[data-ui='vivid'] .dock {display:flex}
|
||||
}
|
||||
/* 品牌标记换成站标「一横」:它自身就是圆,所以去掉渐变圆盘与首字母字号;
|
||||
右下角那颗绿色在线点保留(它是「有人在写」的信号,不属于被替换的部分)。 */
|
||||
html[data-ui='vivid'] :is(.brand, .dock-brand) .av {width:30px;height:30px;flex:none;
|
||||
color:var(--ink);
|
||||
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
|
||||
position:relative;}
|
||||
html[data-ui='vivid'] :is(.brand, .dock-brand) .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
|
||||
width:9px;height:9px;border-radius:50%;
|
||||
background:#34c759;border:2px solid var(--paper);}
|
||||
|
||||
/* 上拉菜单:圆角毛玻璃贴着 dock 上方长出来,最高 70vh,内容多就内部滚 */
|
||||
html[data-ui='vivid'] .menu-root {position:fixed;inset:0;z-index:110;}
|
||||
html[data-ui='vivid'] .menu-root .scrim {position:absolute;inset:0;
|
||||
background:color-mix(in srgb,var(--ink) 32%,transparent);}
|
||||
html[data-ui='vivid'] .menu {position:absolute;left:10px;right:10px;
|
||||
bottom:max(10px, env(safe-area-inset-bottom));
|
||||
border-radius:26px;
|
||||
background:color-mix(in srgb,var(--card) 92%,transparent);
|
||||
backdrop-filter:blur(28px) saturate(1.6);
|
||||
-webkit-backdrop-filter:blur(28px) saturate(1.6);
|
||||
border:1px solid var(--line);
|
||||
box-shadow:var(--shadow-lg);
|
||||
display:flex;flex-direction:column;
|
||||
/* 上下留出刘海 / 小横条的安全区 */
|
||||
padding:0 max(18px, env(safe-area-inset-left)) max(20px, env(safe-area-inset-bottom));
|
||||
overscroll-behavior:contain;}
|
||||
padding:10px 22px max(14px, env(safe-area-inset-bottom));
|
||||
overscroll-behavior:contain;
|
||||
max-height:min(70vh, 560px);}
|
||||
html[data-ui='vivid'] .menu:focus {outline:none}
|
||||
html[data-ui='vivid'] .menu-head {height:56px;flex:none; /* 对齐顶栏高度,关掉时视觉不跳 */
|
||||
display:flex;align-items:center;justify-content:space-between;}
|
||||
html[data-ui='vivid'] .menu-in {flex:1;display:flex;flex-direction:column;
|
||||
padding-top:10px;overflow-y:auto;}
|
||||
html[data-ui='vivid'] .menu-in a {display:flex;align-items:baseline;gap:12px;
|
||||
padding:16px 2px;font-size:26px;font-weight:800;letter-spacing:-.02em;
|
||||
justify-content:flex-start;
|
||||
padding-top:6px;overflow-y:auto;
|
||||
scrollbar-width:none;}
|
||||
html[data-ui='vivid'] .menu-in a {position:relative;display:flex;align-items:baseline;gap:14px;
|
||||
padding:15px 2px;font-size:25px;font-weight:800;letter-spacing:-.02em;
|
||||
line-height:1.25;color:var(--soft);
|
||||
border-bottom:1px solid var(--line);
|
||||
transition:color .3s var(--ease);
|
||||
/* 逐项入场:--i 由模板按序号写进 style,错峰 45ms */
|
||||
animation:mItem .55s var(--spring) both;
|
||||
animation-delay:calc(var(--i, 0) * 45ms);}
|
||||
@keyframes mItem {from {opacity:0;transform:translateY(14px)}}
|
||||
html[data-ui='vivid'] .menu-in a:last-child {border-bottom:0}
|
||||
html[data-ui='vivid'] .menu-in a .idx {font-family:var(--mono);font-size:11px;font-weight:700;
|
||||
color:var(--muted);letter-spacing:.06em;
|
||||
transition:color .3s var(--ease);}
|
||||
html[data-ui='vivid'] .menu-in a em {font-style:normal;font-size:11px;font-weight:700;
|
||||
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
|
||||
html[data-ui='vivid'] .menu-in a.on,
|
||||
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-left:auto;
|
||||
transition:color .3s var(--ease);}
|
||||
html[data-ui='vivid'] .menu-in a.on {color:var(--accent)}
|
||||
html[data-ui='vivid'] .menu-in a.on .idx,
|
||||
html[data-ui='vivid'] .menu-in a.on em {color:var(--accent)}
|
||||
/* 当前项的左缘短杠:菜单内边距 22px,杠落在离屏边 10px 处 */
|
||||
html[data-ui='vivid'] .menu-in a.on::before {content:"";position:absolute;left:-14px;top:50%;
|
||||
width:3px;height:18px;border-radius:2px;background:var(--accent);
|
||||
transform:translateY(-50%);}
|
||||
html[data-ui='vivid'] .menu-in a:active {color:var(--accent)}
|
||||
html[data-ui='vivid'] .menu-foot {flex:none;padding-top:18px}
|
||||
/* 进:整块淡入 + 菜单项轻推上来;出:只淡出(避免收起时还要等位移) */
|
||||
html[data-ui='vivid'] .v-menu-enter-active {transition:opacity .26s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-enter-active .menu-in {transition:transform .5s var(--spring),opacity .4s var(--ease)}
|
||||
html[data-ui='vivid'] .menu-foot {flex:none;padding-top:16px;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:12px;}
|
||||
html[data-ui='vivid'] .menu-foot .m-desc {margin:0;font-size:12px;line-height:1.7;
|
||||
color:var(--muted);text-align:right;}
|
||||
/* 进:scrim 淡入 + 菜单从底部长出来 + 项逐项推上来;出:整块沉回去 */
|
||||
html[data-ui='vivid'] .v-menu-enter-active {transition:opacity .3s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-enter-active .menu {transition:transform .55s var(--spring),opacity .35s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-enter-from {opacity:0}
|
||||
html[data-ui='vivid'] .v-menu-enter-from .menu-in {transform:translateY(12px);opacity:0}
|
||||
html[data-ui='vivid'] .v-menu-leave-active {transition:opacity .18s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-enter-from .menu {transform:translateY(56px);opacity:0}
|
||||
html[data-ui='vivid'] .v-menu-leave-active {transition:opacity .22s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-leave-active .menu {transition:transform .22s var(--ease)}
|
||||
html[data-ui='vivid'] .v-menu-leave-to {opacity:0}
|
||||
html[data-ui='vivid'] .v-menu-leave-to .menu {transform:translateY(48px)}
|
||||
/* 全局 reduce-motion 只把 duration 压扁,delay 会留下来 → 单独清掉 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-ui='vivid'] .v-menu-enter-active,
|
||||
html[data-ui='vivid'] .v-menu-enter-active .menu-in {transition-delay:0s !important}
|
||||
html[data-ui='vivid'] .v-menu-enter-active .menu {transition-delay:0s !important}
|
||||
html[data-ui='vivid'] .menu-in a {animation:none}
|
||||
}
|
||||
/* 短文快发盒(QuickShort)的 vivid 差异:圆角跟自己的半径档走,
|
||||
FAB 不用给经典版的主题浮钮让位(vivid 的主题键在菜单里),贴到安全区 */
|
||||
html[data-ui='vivid'] .qs-card {border-radius:14px;}
|
||||
html[data-ui='vivid'] .qs-fab {bottom:max(20px, env(safe-area-inset-bottom));}
|
||||
|
||||
html[data-ui='vivid'] .progress {position:fixed;top:0;left:0;right:0;height:2px;z-index:200;
|
||||
background:transparent;pointer-events:none;}
|
||||
html[data-ui='vivid'] .progress i {display:block;height:100%;width:0%;
|
||||
@@ -117,6 +163,25 @@ html[data-ui='vivid'] .foot {max-width:1100px;margin:40px auto 0;padding:28px 28
|
||||
border-top:1px solid var(--line);
|
||||
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;}
|
||||
html[data-ui='vivid'] .foot a {color:var(--accent);font-weight:600;transition:opacity .25s}
|
||||
/* 页脚社交排:图标居中一行,与 classic 页脚同步 */
|
||||
html[data-ui='vivid'] .foot-social {display:flex;justify-content:center;align-items:center;
|
||||
gap:18px;margin:14px 0 18px;}
|
||||
html[data-ui='vivid'] .foot-social a {color:var(--soft);font-weight:400;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
width:30px;height:30px;border-radius:8px;transition:color .25s,background .25s;}
|
||||
html[data-ui='vivid'] .foot-social a:hover {color:var(--accent);background:var(--accent-soft);}
|
||||
html[data-ui='vivid'] .foot-social svg {width:17px;height:17px;fill:currentColor;}
|
||||
html[data-ui='vivid'] .foot-social span {font-size:12px;}
|
||||
/* 项目信息行:源码入口 + 版权。刻意不并进 .foot-social——那一排是站主的社交
|
||||
身份,源码是仓库入口,混在一起会让人以为它也是一个社交账号。
|
||||
放在 .foot a 规则之后:两者特指度相同,靠后者才能压掉默认的强调色。 */
|
||||
html[data-ui='vivid'] .foot-meta {display:flex;justify-content:center;align-items:center;
|
||||
gap:8px;margin:16px 0 0;color:var(--muted);}
|
||||
html[data-ui='vivid'] .foot-meta a {display:inline-flex;align-items:center;justify-content:center;
|
||||
width:26px;height:26px;border-radius:7px;color:var(--soft);font-weight:400;
|
||||
transition:color .25s,background .25s;}
|
||||
html[data-ui='vivid'] .foot-meta a:hover {color:var(--accent);background:var(--accent-soft);}
|
||||
html[data-ui='vivid'] .foot-meta svg {width:15px;height:15px;fill:currentColor;}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .foot a:hover {opacity:.75}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
评论区:vivid(余白)的差异部分
|
||||
--------------------------------------------------------------------------
|
||||
基础样式在 styles.css 的 .cm-*(classic 和 vivid 共用同一份 DOM)。
|
||||
这里只写 vivid 和 classic 长得不一样的地方 —— 全部带 html[data-ui='vivid']
|
||||
前缀,特指度比基础版高,天然盖住它。
|
||||
基础版已经是「去气泡」的编辑式排版:正文落在纸面上、回复串在一条
|
||||
发丝线上、操作是一行文字钮。这里只写 vivid 和 classic 长得不一样的地方
|
||||
—— 全部带 html[data-ui='vivid'] 前缀,特指度比基础版高,天然盖住它。
|
||||
自己的移动端覆盖也写在下面(不要挪进 mobile.css:那是「靠最后导入赢」,
|
||||
语义上不该承担某个具体组件的窄屏规则)。
|
||||
========================================================================== */
|
||||
@@ -15,42 +16,42 @@ html[data-ui='vivid'] .cm-sec {margin-top: 8px;padding: 24px 24px 0;border-top:
|
||||
html[data-ui='vivid'] .cm-count {font-size: 13px;color: var(--muted);letter-spacing: .01em;}
|
||||
/* .cm-sort 在 vivid 里同时带 .seg 类,pill 外观由 home.css 的 .seg 规则给 */
|
||||
|
||||
/* 未登录:一张安静的纸片,用和输入框同一套「场地」观感 */
|
||||
html[data-ui='vivid'] .cm-gate {padding: 26px 22px;border-radius: var(--radius-lg);
|
||||
border-color: var(--line);background: var(--card);box-shadow: var(--shadow);}
|
||||
html[data-ui='vivid'] .cm-gate-skel {box-shadow: none;border-radius: var(--radius);}
|
||||
html[data-ui='vivid'] .cm-provider {width: 40px;height: 40px;border-color: var(--line);
|
||||
background: var(--card);color: var(--soft);
|
||||
/* 未登录:基础版已经不画卡片,这里只给登录钮弹簧手感 */
|
||||
html[data-ui='vivid'] .cm-provider {border-color: var(--line);color: var(--soft);
|
||||
transition: border-color .3s var(--spring),color .3s var(--spring),transform .5s var(--spring);}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .cm-provider:hover {border-color: var(--accent);color: var(--accent);
|
||||
transform: translateY(-2px);}
|
||||
}
|
||||
|
||||
/* 输入框:vivid 的 .search / .seg 是同一套「无边框 + 聚焦才亮」的思路 */
|
||||
html[data-ui='vivid'] .cm-field {border-radius: var(--radius);background: var(--card);
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color .3s var(--ease),box-shadow .3s var(--ease);}
|
||||
html[data-ui='vivid'] .cm-field:focus-within {border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);}
|
||||
/* 输入区:基础版的「书写线」在 vivid 下聚焦时除了变强调色,再给一点同色光,
|
||||
和 .search 聚焦时的 ring 是同一种「亮起来」的语言 */
|
||||
html[data-ui='vivid'] .cm-input {font-size: 14.5px;line-height: 1.8;}
|
||||
html[data-ui='vivid'] .cm-bar {border-top-color: var(--line);}
|
||||
html[data-ui='vivid'] .cm-field:focus-within .cm-input {border-bottom-color: var(--accent);
|
||||
box-shadow: 0 10px 24px -18px var(--accent-glow);}
|
||||
html[data-ui='vivid'] .cm-bar-l b {color: var(--soft);}
|
||||
html[data-ui='vivid'] .cm-send {background: var(--accent);color: #fff;
|
||||
transition: transform .5s var(--spring),box-shadow .3s;}
|
||||
transition: transform .5s var(--spring),box-shadow .3s,opacity .2s;}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .cm-send:not(:disabled):hover {transform: scale(1.04);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);}
|
||||
}
|
||||
html[data-ui='vivid'] .cm-send:active {transform: scale(.97);}
|
||||
|
||||
/* 表情面板:基础版的暗色靠 token 别名天然成立,vivid 只把阴影换成自己的档位 */
|
||||
html[data-ui='vivid'] .cm-emoji {border-color: var(--line);box-shadow: var(--shadow-lg);}
|
||||
html[data-ui='vivid'] .cm-emoji-btn {border-radius: 8px;transition: opacity .3s var(--spring),background-color .3s,transform .5s var(--spring);}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .cm-emoji-btn:hover {transform: scale(1.08);}
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
html[data-ui='vivid'] .cm-list {gap: 20px;}
|
||||
html[data-ui='vivid'] .cm-av {width: 36px;height: 36px;background: var(--accent-soft);color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--paper);}
|
||||
html[data-ui='vivid'] .cm-list {gap: 24px;}
|
||||
html[data-ui='vivid'] .cm-av {width: 36px;height: 36px;background: var(--accent-soft);color: var(--accent);}
|
||||
/* 站长的头像 ring 用强调色,一眼能认出来 */
|
||||
html[data-ui='vivid'] .cm-av.is-owner {box-shadow: 0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);}
|
||||
html[data-ui='vivid'] .cm-pv {box-shadow: 0 0 0 1.5px var(--paper);color: var(--muted);}
|
||||
html[data-ui='vivid'] .cm-name-a {font-size: 13.5px;color: var(--text);letter-spacing: .01em;}
|
||||
html[data-ui='vivid'] .cm-name-a {font-size: 14px;color: var(--text);letter-spacing: .01em;}
|
||||
/* tokens.css 的 a 重置(html[data-ui=vivid] a,特指度 0,1,2)会把链接名拉回
|
||||
inherit —— 继承到的是 body 的颜色,暗色下正好是深底深字。得在这里压回去。 */
|
||||
html[data-ui='vivid'] a.cm-name-a {color: var(--text);}
|
||||
@@ -61,13 +62,10 @@ html[data-ui='vivid'] .cm-owner {background: var(--accent-soft);color: var(--acc
|
||||
html[data-ui='vivid'] .cm-time {letter-spacing: .02em;}
|
||||
html[data-ui='vivid'] .cm-pending {background: color-mix(in srgb,var(--ink) 6%,transparent);}
|
||||
|
||||
/* 气泡:vivid 里用「场地」那套(细边 + 顶上一点渐变),缺角保留 */
|
||||
html[data-ui='vivid'] .cm-bubble {padding: 9px 14px;border-radius: var(--radius-lg);
|
||||
border-top-left-radius: 3px;border-color: var(--line);background: var(--card);
|
||||
color: var(--text);
|
||||
background-image: linear-gradient(180deg,var(--accent-soft) 0,transparent 26%);}
|
||||
html[data-ui='vivid'] .cm-item.is-reply .cm-bubble {border-top-left-radius: var(--radius-lg);}
|
||||
html[data-ui='vivid'] .cm-md {font-size: 14.5px;line-height: 1.75;}
|
||||
/* 正文落在纸面上;vivid 只接管 Markdown 内的元素配色 */
|
||||
html[data-ui='vivid'] .cm-bubble {color: var(--text);}
|
||||
/* cm-at 的字号行高跟 .cm-md 成对改(见 styles.css 里那条注释) */
|
||||
html[data-ui='vivid'] .cm-md, html[data-ui='vivid'] .cm-at {font-size: 14.5px;line-height: 1.8;}
|
||||
html[data-ui='vivid'] .cm-md a {text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb,var(--accent) 40%,transparent);
|
||||
text-underline-offset: 2px;}
|
||||
@@ -78,41 +76,37 @@ html[data-ui='vivid'] .cm-md code {background: var(--accent-soft);color: var(--a
|
||||
html[data-ui='vivid'] .cm-md pre {background: color-mix(in srgb,var(--ink) 92%,var(--accent));
|
||||
color: #e8e8f0;box-shadow: var(--shadow-lg);}
|
||||
html[data-ui='vivid'] .cm-md pre code {background: none;color: inherit;}
|
||||
/* 暗色下上面的「92% ink」混出的是浅底,配上浅字就没法读了;
|
||||
暗端改成深底微混强调色,字色不变 */
|
||||
html[data-ui='vivid'][data-theme='vivid-dark'] .cm-md pre {
|
||||
background: color-mix(in srgb,var(--paper) 86%,var(--accent));
|
||||
border: 1px solid var(--line);box-shadow: none;}
|
||||
html[data-ui='vivid'] .cm-md blockquote {border-left-color: var(--accent);
|
||||
font-family: var(--serif);font-style: italic;}
|
||||
|
||||
/* 操作钮 */
|
||||
html[data-ui='vivid'] .cm-act {border-color: var(--line);background: var(--card);color: var(--muted);
|
||||
transition: border-color .3s var(--spring),color .3s var(--spring),transform .5s var(--spring);}
|
||||
/* 操作钮:文字钮继承基础版,vivid 给弹簧手感;删除悬停同样给红 */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html[data-ui='vivid'] .cm-act:hover {border-color: var(--accent);color: var(--accent);
|
||||
transform: translateY(-1px);}
|
||||
html[data-ui='vivid'] .cm-act {transition: color .3s var(--spring);}
|
||||
html[data-ui='vivid'] .cm-act:hover {color: var(--accent);}
|
||||
}
|
||||
|
||||
/* 回复 */
|
||||
html[data-ui='vivid'] .cm-more-r {border-color: var(--line);color: var(--muted);}
|
||||
/* 回复线与「加载更多回复」 */
|
||||
html[data-ui='vivid'] .cm-replies {border-left-color: var(--line);}
|
||||
html[data-ui='vivid'] .cm-more-r {color: var(--muted);}
|
||||
html[data-ui='vivid'] .cm-empty .chip {font-size: 13px;padding: 9px 22px;}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
手机:头像 24px(桌面 36px),触屏的可点目标 44px、输入框 16px(防聚焦缩放)
|
||||
手机:基础版已经把头像/缩进/可点目标收过一轮,这里只补 vivid 自己的
|
||||
页边距对齐(.cm-sec 桌面是 24px,手机跟 .article-layout 的 20px 走)
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (max-width:640px) {
|
||||
html[data-ui='vivid'] .cm-sec {margin-top: 4px;padding: 20px 20px 0;}
|
||||
html[data-ui='vivid'] .cm-sec {margin-top: 4px;padding: 20px 0 0;}
|
||||
html[data-ui='vivid'] .cm-list {gap: 20px;}
|
||||
html[data-ui='vivid'] .cm-av,
|
||||
html[data-ui='vivid'] .cm-av-me {width: 24px;height: 24px;font-size: 11px;}
|
||||
html[data-ui='vivid'] .cm-replies {padding-left: 20px;}
|
||||
html[data-ui='vivid'] .cm-more-r {margin-left: 20px;}
|
||||
html[data-ui='vivid'] .cm-bubble {max-width: calc(100% - 1rem);}
|
||||
html[data-ui='vivid'] .cm-provider {width: 44px;height: 44px;}
|
||||
html[data-ui='vivid'] .cm-act {width: 32px;height: 32px;font-size: 13px;}
|
||||
html[data-ui='vivid'] .cm-sort button {min-height: 44px;}
|
||||
html[data-ui='vivid'] .cm-input {font-size: 16px;}
|
||||
html[data-ui='vivid'] .cm-av-me {width: 28px;height: 28px;font-size: 12px;}
|
||||
html[data-ui='vivid'] .cm-replies .cm-av {width: 24px;height: 24px;font-size: 10.5px;}
|
||||
}
|
||||
/* 触屏(可能是平板/桌面触屏,不限于窄屏):可点目标给足 */
|
||||
/* 触屏(可能是平板/桌面触屏,不限于窄屏) */
|
||||
@media (pointer: coarse) {
|
||||
html[data-ui='vivid'] .cm-provider {width: 44px;height: 44px;}
|
||||
html[data-ui='vivid'] .cm-act {width: 32px;height: 32px;}
|
||||
html[data-ui='vivid'] .cm-more-r {padding: 10px 16px;}
|
||||
html[data-ui='vivid'] .cm-link {padding: 8px 2px;}
|
||||
html[data-ui='vivid'] .cm-input {font-size: 16px;}
|
||||
}
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user