From a68a4ef4111107510488204c711e741c68ae028e Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 7 Oct 2026 09:28:43 +0800 Subject: [PATCH] init: initial commit --- .idea/.gitignore | 10 + .idea/go.imports.xml | 10 + .idea/modules.xml | 8 + .idea/past-go.iml | 9 + design/previews/couple-journal-preview.html | 2306 +++++ design/previews/couple-journal.html | 52 + past-go/.gitignore | 5 + past-go/Makefile | 44 + past-go/README.md | 57 + past-go/cmd/server/main.go | 146 + past-go/cmd/server/service_other.go | 11 + past-go/cmd/server/service_windows.go | 120 + past-go/config.example.yaml | 18 + past-go/deploy/README.md | 36 + past-go/deploy/nginx-api.conf | 28 + past-go/go.mod | 42 + past-go/go.sum | 101 + past-go/internal/config/config.go | 90 + past-go/internal/config/config_test.go | 27 + past-go/internal/geo/geo.go | 152 + past-go/internal/handler/admin.go | 816 ++ past-go/internal/handler/album.go | 346 + past-go/internal/handler/checkin.go | 436 + past-go/internal/handler/common.go | 431 + past-go/internal/handler/couple.go | 590 ++ past-go/internal/handler/date_test.go | 30 + past-go/internal/handler/diary.go | 325 + past-go/internal/handler/diary_test.go | 24 + past-go/internal/handler/jsonenc_test.go | 15 + past-go/internal/handler/misc.go | 294 + past-go/internal/handler/period.go | 558 ++ past-go/internal/handler/period_source.go | 147 + past-go/internal/handler/pet.go | 522 ++ past-go/internal/handler/songs.go | 99 + past-go/internal/handler/timeline.go | 145 + past-go/internal/handler/user.go | 639 ++ past-go/internal/httpapi/auth.go | 182 + past-go/internal/httpapi/ctx.go | 316 + past-go/internal/httpapi/naming.go | 48 + past-go/internal/httpapi/router.go | 243 + past-go/internal/httpapi/router_test.go | 127 + past-go/internal/notify/notify.go | 162 + past-go/internal/store/db.go | 374 + past-go/internal/store/schema.go | 165 + past-go/internal/store/schema_sql.go | 421 + past-go/internal/upload/upload.go | 194 + past-go/internal/wechat/wechat.go | 317 + past-go/web/admin/.gitignore | 24 + past-go/web/admin/.oxlintrc.json | 8 + past-go/web/admin/README.md | 32 + past-go/web/admin/components.json | 25 + past-go/web/admin/index.html | 13 + past-go/web/admin/package-lock.json | 7630 +++++++++++++++++ past-go/web/admin/package.json | 39 + past-go/web/admin/public/favicon.svg | 3 + past-go/web/admin/public/icons.svg | 24 + past-go/web/admin/src/App.tsx | 54 + past-go/web/admin/src/assets/hero.png | Bin 0 -> 13057 bytes past-go/web/admin/src/assets/vite.svg | 1 + .../src/components/CoupleDetailDialog.tsx | 176 + .../web/admin/src/components/ImagePreview.tsx | 75 + past-go/web/admin/src/components/Layout.tsx | 44 + .../web/admin/src/components/Pagination.tsx | 43 + .../web/admin/src/components/UserDialogs.tsx | 286 + past-go/web/admin/src/components/common.tsx | 73 + .../admin/src/components/ui/alert-dialog.tsx | 197 + past-go/web/admin/src/components/ui/badge.tsx | 48 + .../web/admin/src/components/ui/button.tsx | 66 + past-go/web/admin/src/components/ui/card.tsx | 102 + .../web/admin/src/components/ui/dialog.tsx | 166 + .../admin/src/components/ui/dropdown-menu.tsx | 268 + past-go/web/admin/src/components/ui/input.tsx | 18 + past-go/web/admin/src/components/ui/label.tsx | 21 + .../admin/src/components/ui/scroll-area.tsx | 54 + .../web/admin/src/components/ui/select.tsx | 191 + .../web/admin/src/components/ui/separator.tsx | 25 + past-go/web/admin/src/components/ui/sheet.tsx | 147 + .../web/admin/src/components/ui/skeleton.tsx | 13 + past-go/web/admin/src/components/ui/table.tsx | 113 + past-go/web/admin/src/components/ui/tabs.tsx | 89 + .../web/admin/src/components/ui/textarea.tsx | 17 + .../web/admin/src/components/ui/tooltip.tsx | 54 + past-go/web/admin/src/index.css | 152 + past-go/web/admin/src/lib/api.ts | 96 + past-go/web/admin/src/lib/utils.ts | 1 + past-go/web/admin/src/main.tsx | 25 + past-go/web/admin/src/pages/Backgrounds.tsx | 98 + past-go/web/admin/src/pages/Contents.tsx | 131 + past-go/web/admin/src/pages/Couples.tsx | 52 + past-go/web/admin/src/pages/Dashboard.tsx | 131 + past-go/web/admin/src/pages/Login.tsx | 62 + past-go/web/admin/src/pages/Pets.tsx | 66 + past-go/web/admin/src/pages/Settings.tsx | 78 + past-go/web/admin/src/pages/Unbind.tsx | 120 + past-go/web/admin/src/pages/Users.tsx | 125 + past-go/web/admin/tests/api.test.cjs | 29 + past-go/web/admin/tsconfig.app.json | 27 + past-go/web/admin/tsconfig.json | 18 + past-go/web/admin/tsconfig.node.json | 23 + past-go/web/admin/vite.config.ts | 17 + past-go/web/embed.go | 19 + weapp/app.js | 138 + weapp/app.json | 47 + weapp/app.wxss | 297 + weapp/components/app-nav/app-nav.js | 12 + weapp/components/app-nav/app-nav.json | 1 + weapp/components/app-nav/app-nav.wxml | 6 + weapp/components/app-nav/app-nav.wxss | 1 + weapp/components/app-tabbar/app-tabbar.js | 20 + weapp/components/app-tabbar/app-tabbar.json | 1 + weapp/components/app-tabbar/app-tabbar.wxml | 11 + weapp/components/app-tabbar/app-tabbar.wxss | 41 + .../app-tabbar/icons/journal-active.svg | 1 + weapp/components/app-tabbar/icons/journal.svg | 1 + .../components/app-tabbar/icons/me-active.svg | 1 + weapp/components/app-tabbar/icons/me.svg | 1 + .../components/app-tabbar/icons/us-active.svg | 1 + weapp/components/app-tabbar/icons/us.svg | 1 + .../launch-transition/launch-transition.js | 41 + .../launch-transition/launch-transition.json | 1 + .../launch-transition/launch-transition.wxml | 13 + .../launch-transition/launch-transition.wxss | 14 + weapp/components/page-state/page-state.js | 1 + weapp/components/page-state/page-state.json | 1 + weapp/components/page-state/page-state.wxml | 1 + weapp/components/page-state/page-state.wxss | 4 + weapp/images/album.svg | 5 + weapp/images/back.svg | 3 + weapp/images/book.svg | 4 + weapp/images/check.svg | 4 + weapp/images/close.svg | 4 + weapp/images/comment.svg | 3 + weapp/images/default-avatar.svg | 1 + weapp/images/delete.svg | 3 + weapp/images/edit.svg | 3 + weapp/images/heart.svg | 15 + weapp/images/home.svg | 4 + weapp/images/j-activity.svg | 1 + weapp/images/j-droplet.svg | 1 + weapp/images/j-flower.svg | 1 + weapp/images/j-scale.svg | 1 + weapp/images/j-smile.svg | 1 + weapp/images/j-thermometer.svg | 1 + weapp/images/more.svg | 1 + weapp/images/music.svg | 5 + weapp/images/pet.svg | 1 + weapp/images/plus.svg | 4 + weapp/images/profile.svg | 4 + weapp/images/settings.svg | 4 + weapp/images/share.png | Bin 0 -> 91218 bytes weapp/images/timeline.svg | 4 + weapp/pages/album/album.js | 469 + weapp/pages/album/album.json | 8 + weapp/pages/album/album.wxml | 140 + weapp/pages/album/album.wxss | 331 + weapp/pages/albums/albums.js | 176 + weapp/pages/albums/albums.json | 7 + weapp/pages/albums/albums.wxml | 48 + weapp/pages/albums/albums.wxss | 128 + weapp/pages/checkin-detail/checkin-detail.js | 193 + .../pages/checkin-detail/checkin-detail.json | 7 + .../pages/checkin-detail/checkin-detail.wxml | 145 + .../pages/checkin-detail/checkin-detail.wxss | 20 + weapp/pages/diary/diary.js | 201 + weapp/pages/diary/diary.json | 8 + weapp/pages/diary/diary.wxml | 8 + weapp/pages/diary/diary.wxss | 3 + weapp/pages/diary_detail/diary_detail.js | 230 + weapp/pages/diary_detail/diary_detail.json | 8 + weapp/pages/diary_detail/diary_detail.wxml | 101 + weapp/pages/diary_detail/diary_detail.wxss | 17 + weapp/pages/diary_edit/diary_edit.js | 183 + weapp/pages/diary_edit/diary_edit.json | 8 + weapp/pages/diary_edit/diary_edit.wxml | 46 + weapp/pages/diary_edit/diary_edit.wxss | 142 + weapp/pages/discover/discover.js | 102 + weapp/pages/discover/discover.json | 8 + weapp/pages/discover/discover.wxml | 17 + weapp/pages/discover/discover.wxss | 11 + weapp/pages/history/history.js | 106 + weapp/pages/history/history.json | 7 + weapp/pages/history/history.wxml | 51 + weapp/pages/history/history.wxss | 77 + weapp/pages/index/index.js | 342 + weapp/pages/index/index.json | 8 + weapp/pages/index/index.wxml | 25 + weapp/pages/index/index.wxss | 13 + weapp/pages/lovewords/lovewords.js | 126 + weapp/pages/lovewords/lovewords.json | 8 + weapp/pages/lovewords/lovewords.wxml | 52 + weapp/pages/lovewords/lovewords.wxss | 13 + weapp/pages/period/period.js | 1681 ++++ weapp/pages/period/period.json | 8 + weapp/pages/period/period.wxml | 447 + weapp/pages/period/period.wxss | 1085 +++ weapp/pages/pet/achievements.js | 63 + weapp/pages/pet/achievements.json | 7 + weapp/pages/pet/achievements.wxml | 43 + weapp/pages/pet/achievements.wxss | 183 + weapp/pages/pet/pet.js | 309 + weapp/pages/pet/pet.json | 7 + weapp/pages/pet/pet.wxml | 225 + weapp/pages/pet/pet.wxss | 19 + weapp/pages/profile/profile.js | 932 ++ weapp/pages/profile/profile.json | 8 + weapp/pages/profile/profile.wxml | 160 + weapp/pages/profile/profile.wxss | 308 + weapp/pages/publish/publish.js | 530 ++ weapp/pages/publish/publish.json | 8 + weapp/pages/publish/publish.wxml | 150 + weapp/pages/publish/publish.wxss | 208 + weapp/pages/punch/punch.js | 117 + weapp/pages/punch/punch.json | 8 + weapp/pages/punch/punch.wxml | 97 + weapp/pages/punch/punch.wxss | 11 + weapp/pages/settings/settings.js | 370 + weapp/pages/settings/settings.json | 7 + weapp/pages/settings/settings.wxml | 227 + weapp/pages/settings/settings.wxss | 346 + .../pages/timeline-detail/timeline-detail.js | 195 + .../timeline-detail/timeline-detail.json | 7 + .../timeline-detail/timeline-detail.wxml | 49 + .../timeline-detail/timeline-detail.wxss | 165 + weapp/pages/timeline/timeline.js | 194 + weapp/pages/timeline/timeline.json | 8 + weapp/pages/timeline/timeline.wxml | 55 + weapp/pages/timeline/timeline.wxss | 153 + weapp/pages/unbind/unbind.js | 209 + weapp/pages/unbind/unbind.json | 8 + weapp/pages/unbind/unbind.wxml | 103 + weapp/pages/unbind/unbind.wxss | 87 + weapp/project.config.json | 68 + weapp/project.private.config.json | 24 + weapp/sitemap.json | 7 + weapp/styles/common.wxss | 1004 +++ weapp/styles/companion.wxss | 20 + weapp/styles/design.wxss | 361 + weapp/styles/journal.wxss | 78 + weapp/templates/journal.wxml | 8 + weapp/tests/auth.test.cjs | 71 + weapp/tests/journal.test.cjs | 47 + weapp/tests/navigation.test.cjs | 33 + weapp/tests/request.test.cjs | 49 + weapp/utils/api-naming.js | 8 + weapp/utils/journal.js | 14 + weapp/utils/layout.js | 16 + weapp/utils/navigation.js | 7 + weapp/utils/request.js | 477 ++ weapp/utils/scroll-pos.js | 42 + weapp/utils/subscribe.js | 142 + weapp/utils/util.js | 31 + 251 files changed, 38279 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/past-go.iml create mode 100644 design/previews/couple-journal-preview.html create mode 100644 design/previews/couple-journal.html create mode 100644 past-go/.gitignore create mode 100644 past-go/Makefile create mode 100644 past-go/README.md create mode 100644 past-go/cmd/server/main.go create mode 100644 past-go/cmd/server/service_other.go create mode 100644 past-go/cmd/server/service_windows.go create mode 100644 past-go/config.example.yaml create mode 100644 past-go/deploy/README.md create mode 100644 past-go/deploy/nginx-api.conf create mode 100644 past-go/go.mod create mode 100644 past-go/go.sum create mode 100644 past-go/internal/config/config.go create mode 100644 past-go/internal/config/config_test.go create mode 100644 past-go/internal/geo/geo.go create mode 100644 past-go/internal/handler/admin.go create mode 100644 past-go/internal/handler/album.go create mode 100644 past-go/internal/handler/checkin.go create mode 100644 past-go/internal/handler/common.go create mode 100644 past-go/internal/handler/couple.go create mode 100644 past-go/internal/handler/date_test.go create mode 100644 past-go/internal/handler/diary.go create mode 100644 past-go/internal/handler/diary_test.go create mode 100644 past-go/internal/handler/jsonenc_test.go create mode 100644 past-go/internal/handler/misc.go create mode 100644 past-go/internal/handler/period.go create mode 100644 past-go/internal/handler/period_source.go create mode 100644 past-go/internal/handler/pet.go create mode 100644 past-go/internal/handler/songs.go create mode 100644 past-go/internal/handler/timeline.go create mode 100644 past-go/internal/handler/user.go create mode 100644 past-go/internal/httpapi/auth.go create mode 100644 past-go/internal/httpapi/ctx.go create mode 100644 past-go/internal/httpapi/naming.go create mode 100644 past-go/internal/httpapi/router.go create mode 100644 past-go/internal/httpapi/router_test.go create mode 100644 past-go/internal/notify/notify.go create mode 100644 past-go/internal/store/db.go create mode 100644 past-go/internal/store/schema.go create mode 100644 past-go/internal/store/schema_sql.go create mode 100644 past-go/internal/upload/upload.go create mode 100644 past-go/internal/wechat/wechat.go create mode 100644 past-go/web/admin/.gitignore create mode 100644 past-go/web/admin/.oxlintrc.json create mode 100644 past-go/web/admin/README.md create mode 100644 past-go/web/admin/components.json create mode 100644 past-go/web/admin/index.html create mode 100644 past-go/web/admin/package-lock.json create mode 100644 past-go/web/admin/package.json create mode 100644 past-go/web/admin/public/favicon.svg create mode 100644 past-go/web/admin/public/icons.svg create mode 100644 past-go/web/admin/src/App.tsx create mode 100644 past-go/web/admin/src/assets/hero.png create mode 100644 past-go/web/admin/src/assets/vite.svg create mode 100644 past-go/web/admin/src/components/CoupleDetailDialog.tsx create mode 100644 past-go/web/admin/src/components/ImagePreview.tsx create mode 100644 past-go/web/admin/src/components/Layout.tsx create mode 100644 past-go/web/admin/src/components/Pagination.tsx create mode 100644 past-go/web/admin/src/components/UserDialogs.tsx create mode 100644 past-go/web/admin/src/components/common.tsx create mode 100644 past-go/web/admin/src/components/ui/alert-dialog.tsx create mode 100644 past-go/web/admin/src/components/ui/badge.tsx create mode 100644 past-go/web/admin/src/components/ui/button.tsx create mode 100644 past-go/web/admin/src/components/ui/card.tsx create mode 100644 past-go/web/admin/src/components/ui/dialog.tsx create mode 100644 past-go/web/admin/src/components/ui/dropdown-menu.tsx create mode 100644 past-go/web/admin/src/components/ui/input.tsx create mode 100644 past-go/web/admin/src/components/ui/label.tsx create mode 100644 past-go/web/admin/src/components/ui/scroll-area.tsx create mode 100644 past-go/web/admin/src/components/ui/select.tsx create mode 100644 past-go/web/admin/src/components/ui/separator.tsx create mode 100644 past-go/web/admin/src/components/ui/sheet.tsx create mode 100644 past-go/web/admin/src/components/ui/skeleton.tsx create mode 100644 past-go/web/admin/src/components/ui/table.tsx create mode 100644 past-go/web/admin/src/components/ui/tabs.tsx create mode 100644 past-go/web/admin/src/components/ui/textarea.tsx create mode 100644 past-go/web/admin/src/components/ui/tooltip.tsx create mode 100644 past-go/web/admin/src/index.css create mode 100644 past-go/web/admin/src/lib/api.ts create mode 100644 past-go/web/admin/src/lib/utils.ts create mode 100644 past-go/web/admin/src/main.tsx create mode 100644 past-go/web/admin/src/pages/Backgrounds.tsx create mode 100644 past-go/web/admin/src/pages/Contents.tsx create mode 100644 past-go/web/admin/src/pages/Couples.tsx create mode 100644 past-go/web/admin/src/pages/Dashboard.tsx create mode 100644 past-go/web/admin/src/pages/Login.tsx create mode 100644 past-go/web/admin/src/pages/Pets.tsx create mode 100644 past-go/web/admin/src/pages/Settings.tsx create mode 100644 past-go/web/admin/src/pages/Unbind.tsx create mode 100644 past-go/web/admin/src/pages/Users.tsx create mode 100644 past-go/web/admin/tests/api.test.cjs create mode 100644 past-go/web/admin/tsconfig.app.json create mode 100644 past-go/web/admin/tsconfig.json create mode 100644 past-go/web/admin/tsconfig.node.json create mode 100644 past-go/web/admin/vite.config.ts create mode 100644 past-go/web/embed.go create mode 100644 weapp/app.js create mode 100644 weapp/app.json create mode 100644 weapp/app.wxss create mode 100644 weapp/components/app-nav/app-nav.js create mode 100644 weapp/components/app-nav/app-nav.json create mode 100644 weapp/components/app-nav/app-nav.wxml create mode 100644 weapp/components/app-nav/app-nav.wxss create mode 100644 weapp/components/app-tabbar/app-tabbar.js create mode 100644 weapp/components/app-tabbar/app-tabbar.json create mode 100644 weapp/components/app-tabbar/app-tabbar.wxml create mode 100644 weapp/components/app-tabbar/app-tabbar.wxss create mode 100644 weapp/components/app-tabbar/icons/journal-active.svg create mode 100644 weapp/components/app-tabbar/icons/journal.svg create mode 100644 weapp/components/app-tabbar/icons/me-active.svg create mode 100644 weapp/components/app-tabbar/icons/me.svg create mode 100644 weapp/components/app-tabbar/icons/us-active.svg create mode 100644 weapp/components/app-tabbar/icons/us.svg create mode 100644 weapp/components/launch-transition/launch-transition.js create mode 100644 weapp/components/launch-transition/launch-transition.json create mode 100644 weapp/components/launch-transition/launch-transition.wxml create mode 100644 weapp/components/launch-transition/launch-transition.wxss create mode 100644 weapp/components/page-state/page-state.js create mode 100644 weapp/components/page-state/page-state.json create mode 100644 weapp/components/page-state/page-state.wxml create mode 100644 weapp/components/page-state/page-state.wxss create mode 100644 weapp/images/album.svg create mode 100644 weapp/images/back.svg create mode 100644 weapp/images/book.svg create mode 100644 weapp/images/check.svg create mode 100644 weapp/images/close.svg create mode 100644 weapp/images/comment.svg create mode 100644 weapp/images/default-avatar.svg create mode 100644 weapp/images/delete.svg create mode 100644 weapp/images/edit.svg create mode 100644 weapp/images/heart.svg create mode 100644 weapp/images/home.svg create mode 100644 weapp/images/j-activity.svg create mode 100644 weapp/images/j-droplet.svg create mode 100644 weapp/images/j-flower.svg create mode 100644 weapp/images/j-scale.svg create mode 100644 weapp/images/j-smile.svg create mode 100644 weapp/images/j-thermometer.svg create mode 100644 weapp/images/more.svg create mode 100644 weapp/images/music.svg create mode 100644 weapp/images/pet.svg create mode 100644 weapp/images/plus.svg create mode 100644 weapp/images/profile.svg create mode 100644 weapp/images/settings.svg create mode 100644 weapp/images/share.png create mode 100644 weapp/images/timeline.svg create mode 100644 weapp/pages/album/album.js create mode 100644 weapp/pages/album/album.json create mode 100644 weapp/pages/album/album.wxml create mode 100644 weapp/pages/album/album.wxss create mode 100644 weapp/pages/albums/albums.js create mode 100644 weapp/pages/albums/albums.json create mode 100644 weapp/pages/albums/albums.wxml create mode 100644 weapp/pages/albums/albums.wxss create mode 100644 weapp/pages/checkin-detail/checkin-detail.js create mode 100644 weapp/pages/checkin-detail/checkin-detail.json create mode 100644 weapp/pages/checkin-detail/checkin-detail.wxml create mode 100644 weapp/pages/checkin-detail/checkin-detail.wxss create mode 100644 weapp/pages/diary/diary.js create mode 100644 weapp/pages/diary/diary.json create mode 100644 weapp/pages/diary/diary.wxml create mode 100644 weapp/pages/diary/diary.wxss create mode 100644 weapp/pages/diary_detail/diary_detail.js create mode 100644 weapp/pages/diary_detail/diary_detail.json create mode 100644 weapp/pages/diary_detail/diary_detail.wxml create mode 100644 weapp/pages/diary_detail/diary_detail.wxss create mode 100644 weapp/pages/diary_edit/diary_edit.js create mode 100644 weapp/pages/diary_edit/diary_edit.json create mode 100644 weapp/pages/diary_edit/diary_edit.wxml create mode 100644 weapp/pages/diary_edit/diary_edit.wxss create mode 100644 weapp/pages/discover/discover.js create mode 100644 weapp/pages/discover/discover.json create mode 100644 weapp/pages/discover/discover.wxml create mode 100644 weapp/pages/discover/discover.wxss create mode 100644 weapp/pages/history/history.js create mode 100644 weapp/pages/history/history.json create mode 100644 weapp/pages/history/history.wxml create mode 100644 weapp/pages/history/history.wxss create mode 100644 weapp/pages/index/index.js create mode 100644 weapp/pages/index/index.json create mode 100644 weapp/pages/index/index.wxml create mode 100644 weapp/pages/index/index.wxss create mode 100644 weapp/pages/lovewords/lovewords.js create mode 100644 weapp/pages/lovewords/lovewords.json create mode 100644 weapp/pages/lovewords/lovewords.wxml create mode 100644 weapp/pages/lovewords/lovewords.wxss create mode 100644 weapp/pages/period/period.js create mode 100644 weapp/pages/period/period.json create mode 100644 weapp/pages/period/period.wxml create mode 100644 weapp/pages/period/period.wxss create mode 100644 weapp/pages/pet/achievements.js create mode 100644 weapp/pages/pet/achievements.json create mode 100644 weapp/pages/pet/achievements.wxml create mode 100644 weapp/pages/pet/achievements.wxss create mode 100644 weapp/pages/pet/pet.js create mode 100644 weapp/pages/pet/pet.json create mode 100644 weapp/pages/pet/pet.wxml create mode 100644 weapp/pages/pet/pet.wxss create mode 100644 weapp/pages/profile/profile.js create mode 100644 weapp/pages/profile/profile.json create mode 100644 weapp/pages/profile/profile.wxml create mode 100644 weapp/pages/profile/profile.wxss create mode 100644 weapp/pages/publish/publish.js create mode 100644 weapp/pages/publish/publish.json create mode 100644 weapp/pages/publish/publish.wxml create mode 100644 weapp/pages/publish/publish.wxss create mode 100644 weapp/pages/punch/punch.js create mode 100644 weapp/pages/punch/punch.json create mode 100644 weapp/pages/punch/punch.wxml create mode 100644 weapp/pages/punch/punch.wxss create mode 100644 weapp/pages/settings/settings.js create mode 100644 weapp/pages/settings/settings.json create mode 100644 weapp/pages/settings/settings.wxml create mode 100644 weapp/pages/settings/settings.wxss create mode 100644 weapp/pages/timeline-detail/timeline-detail.js create mode 100644 weapp/pages/timeline-detail/timeline-detail.json create mode 100644 weapp/pages/timeline-detail/timeline-detail.wxml create mode 100644 weapp/pages/timeline-detail/timeline-detail.wxss create mode 100644 weapp/pages/timeline/timeline.js create mode 100644 weapp/pages/timeline/timeline.json create mode 100644 weapp/pages/timeline/timeline.wxml create mode 100644 weapp/pages/timeline/timeline.wxss create mode 100644 weapp/pages/unbind/unbind.js create mode 100644 weapp/pages/unbind/unbind.json create mode 100644 weapp/pages/unbind/unbind.wxml create mode 100644 weapp/pages/unbind/unbind.wxss create mode 100644 weapp/project.config.json create mode 100644 weapp/project.private.config.json create mode 100644 weapp/sitemap.json create mode 100644 weapp/styles/common.wxss create mode 100644 weapp/styles/companion.wxss create mode 100644 weapp/styles/design.wxss create mode 100644 weapp/styles/journal.wxss create mode 100644 weapp/templates/journal.wxml create mode 100644 weapp/tests/auth.test.cjs create mode 100644 weapp/tests/journal.test.cjs create mode 100644 weapp/tests/navigation.test.cjs create mode 100644 weapp/tests/request.test.cjs create mode 100644 weapp/utils/api-naming.js create mode 100644 weapp/utils/journal.js create mode 100644 weapp/utils/layout.js create mode 100644 weapp/utils/navigation.js create mode 100644 weapp/utils/request.js create mode 100644 weapp/utils/scroll-pos.js create mode 100644 weapp/utils/subscribe.js create mode 100644 weapp/utils/util.js diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5b25b73 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/past-go.iml b/.idea/past-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/past-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/design/previews/couple-journal-preview.html b/design/previews/couple-journal-preview.html new file mode 100644 index 0000000..a748d06 --- /dev/null +++ b/design/previews/couple-journal-preview.html @@ -0,0 +1,2306 @@ + + + + + + + +Couple Journal + + + + + + + diff --git a/design/previews/couple-journal.html b/design/previews/couple-journal.html new file mode 100644 index 0000000..cfcbc40 --- /dev/null +++ b/design/previews/couple-journal.html @@ -0,0 +1,52 @@ +
+
+ + 设计示例 +
+
+
09:41••• ▰
+
我们••• ◉
+
+ +
+ +
+
+ + diff --git a/past-go/.gitignore b/past-go/.gitignore new file mode 100644 index 0000000..006ed19 --- /dev/null +++ b/past-go/.gitignore @@ -0,0 +1,5 @@ +run/ +config.yaml +web/admin/node_modules/ +# web/admin/dist 会被 embed,构建后需存在;发布时用 make all 生成 +*.log diff --git a/past-go/Makefile b/past-go/Makefile new file mode 100644 index 0000000..42aeb4c --- /dev/null +++ b/past-go/Makefile @@ -0,0 +1,44 @@ +# 我们past Go 服务构建 +BIN := past-server +GOOS ?= linux +GOARCH ?= amd64 +VERSION ?= $(shell date +%Y%m%d-%H%M) + +.PHONY: all web build build-windows build-local run test diff clean + +all: web build + +## 构建 React 管理后台(产物 web/admin/dist 会被 embed) +web: + cd web/admin && npm ci --silent && npm run build + +## 交叉编译 Linux 二进制(默认 linux/amd64;树莓派等用 GOARCH=arm64) +build: + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/$(BIN)-$(GOOS)-$(GOARCH) ./cmd/server + @ls -lh dist/ + +## Windows 部署包:past-server.exe + 配置模板 + 部署脚本 → dist/windows/ +build-windows: + rm -rf dist/windows && mkdir -p dist/windows + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/windows/past-server.exe ./cmd/server + cp config.example.yaml dist/windows/config.yaml + cp deploy/windows/*.ps1 deploy/windows/README.md dist/windows/ + cd dist && rm -f past-windows-deploy.zip && zip -qr past-windows-deploy.zip windows + @ls -lh dist/windows/ dist/past-windows-deploy.zip + +## 本机构建(开发用) +build-local: + go build -o run/$(BIN) ./cmd/server + +run: build-local + ./run/$(BIN) -config config.yaml + +test: + go vet ./... && go test ./... + +## PHP vs Go 响应对比(需先启动 dev 环境,见 ../dev/reset_dbs.sh) +diff: + cd ../dev && for s in scenarios/*.json; do ./reset_dbs.sh >/dev/null; python3 apidiff.py $$s | tail -1; done + +clean: + rm -rf dist run/$(BIN) web/admin/dist diff --git a/past-go/README.md b/past-go/README.md new file mode 100644 index 0000000..5098d21 --- /dev/null +++ b/past-go/README.md @@ -0,0 +1,57 @@ +# 我们past后端(Go 版) + +`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。 +小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。 + +本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。 + +## 目录 + +``` +cmd/server/ 入口 +internal/config/ config.yaml 读取 +internal/store/ MySQL 访问(行类型对齐 PDO)、schema.go = install.php 的移植 +internal/httpapi/ gin 路由、Ctx($_GET/$_POST/$input/$_FILES 等价物)、鉴权 +internal/handler/ 121 个 action,按模块拆分(user/couple/timeline/album/diary/songs/checkin/period/pet/misc/admin) +internal/upload/ handleUpload 等价物;文件落在 /uploads/ +internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session +internal/geo/ 腾讯位置服务 +internal/notify/ cron_notify.php 的进程内定时版 +web/admin/ React 管理后台(dist/ 被 embed) +``` + +## 运行 + +```bash +cp config.example.yaml config.yaml # 填写数据库 / 微信配置 +go build -o past-server ./cmd/server +./past-server -config config.yaml +``` + +运行目录布局(`data_dir`,默认为二进制所在目录): + +``` +data_dir/ +├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移) +└── data/ access_token.cache 等 +``` + +URL 映射: + +| URL | 说明 | +|---|---| +| `/api/接口名` | 小程序、管理后台、上传统一入口 | +| `/backend/api/index.php?action=…` | 旧客户端兼容入口 | +| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) | +| `/backend/admin/` | 管理后台 SPA | +| `/healthz` | 健康检查 | + +## 与 PHP 版的差异(有意为之) + +见 `../dev/API_CONTRACT.md` 与对比测试 `../dev/apidiff.py`。已知的 PHP 原有 bug 处理: + +- `*_add` 接口返回的 `id` 改为真实记录 id(PHP 返回的是 activity_log 的 id)。 +- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。 +- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。 +- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。 +- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。 diff --git a/past-go/cmd/server/main.go b/past-go/cmd/server/main.go new file mode 100644 index 0000000..e0e0366 --- /dev/null +++ b/past-go/cmd/server/main.go @@ -0,0 +1,146 @@ +// 我们past后端(Go 版):API 与 PHP 版 backend/api/index.php 完全兼容。 +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + _ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用 + + "past/internal/config" + "past/internal/geo" + "past/internal/handler" + "past/internal/httpapi" + "past/internal/notify" + "past/internal/store" + "past/internal/upload" + "past/internal/wechat" + "past/web" +) + +// version 由构建时 -ldflags 注入 +var version = "dev" + +func main() { + cfgPath := flag.String("config", "config.yaml", "配置文件路径") + showVer := flag.Bool("version", false, "显示版本") + svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop") + flag.Parse() + if *showVer { + fmt.Println("past-server", version) + return + } + if *svcCmd != "" { + if err := serviceControl(*svcCmd, *cfgPath); err != nil { + fmt.Fprintln(os.Stderr, "错误:", err) + os.Exit(1) + } + return + } + + // 作为 Windows 服务运行时,工作目录是 System32:配置文件相对路径按可执行文件所在目录解析 + if !filepath.IsAbs(*cfgPath) { + if exe, err := os.Executable(); err == nil { + if _, err := os.Stat(*cfgPath); err != nil { + *cfgPath = filepath.Join(filepath.Dir(exe), *cfgPath) + } + } + } + + if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) { + return + } + // 控制台模式:Ctrl+C / SIGTERM 触发优雅退出 + stop := make(chan struct{}) + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + close(stop) + }() + serve(*cfgPath, stop) +} + +// serve 启动全部组件并阻塞到 stop 关闭 +func serve(cfgPath string, stop <-chan struct{}) { + // 与 PHP date_default_timezone_set('Asia/Shanghai') 一致 + if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { + time.Local = loc + } + + cfg, err := config.Load(cfgPath) + if err != nil { + slog.Error("配置加载失败", "err", err) + os.Exit(1) + } + // 确保必要目录存在(对应 config.php 中的 $dirs) + for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} { + os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755) + } + // 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径) + logOut := io.Writer(os.Stdout) + if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + logOut = io.MultiWriter(os.Stdout, f) + defer f.Close() + } + slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + db, err := store.Open(cfg.DSN()) + if err != nil { + slog.Error("数据库连接失败", "err", err) + os.Exit(1) + } + db.NumericAsString = cfg.PHPCompat < "8.1" + if err := db.InitSchema(); err != nil { + slog.Error("数据库初始化失败", "err", err) + os.Exit(1) + } + + wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db) + app := &httpapi.App{ + Cfg: cfg, + DB: db, + Wechat: wx, + Geo: geo.New(cfg.TencentMapKey), + Admin: httpapi.NewAdminSessions(), + } + uploader := &upload.Uploader{Dir: cfg.UploadDir(), MaxSize: cfg.MaxUploadMB * 1024 * 1024, Checker: wx} + + router := httpapi.NewRouter(app, web.AdminFS()) + handler.Register(router, uploader) + slog.Info("已注册 action", "count", len(router.Actions())) + + if cfg.NotifyIntervalMin > 0 { + go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute) + } + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: router.Engine(cfg.Debug), + ReadHeaderTimeout: 30 * time.Second, + ReadTimeout: 180 * time.Second, // 大文件上传 + WriteTimeout: 180 * time.Second, + IdleTimeout: 120 * time.Second, + } + go func() { + slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("服务异常退出", "err", err) + os.Exit(1) + } + }() + + <-stop + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + srv.Shutdown(ctx) + slog.Info("服务已停止") +} diff --git a/past-go/cmd/server/service_other.go b/past-go/cmd/server/service_other.go new file mode 100644 index 0000000..feaf4e0 --- /dev/null +++ b/past-go/cmd/server/service_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +import "fmt" + +func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool { return false } + +func serviceControl(cmd, cfgPath string) error { + return fmt.Errorf("-service 仅在 Windows 下可用(Linux 请使用 systemd)") +} diff --git a/past-go/cmd/server/service_windows.go b/past-go/cmd/server/service_windows.go new file mode 100644 index 0000000..005a306 --- /dev/null +++ b/past-go/cmd/server/service_windows.go @@ -0,0 +1,120 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const svcName = "past-server" + +// runAsServiceIfNeeded 在被 Windows 服务管理器启动时接管生命周期;返回 true 表示已作为服务运行完毕。 +func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool { + isSvc, err := svc.IsWindowsService() + if err != nil || !isSvc { + return false + } + _ = svc.Run(svcName, &svcHandler{run: run}) + return true +} + +type svcHandler struct{ run func(stop <-chan struct{}) } + +func (h *svcHandler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) { + s <- svc.Status{State: svc.StartPending} + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { h.run(stop); close(done) }() + s <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + s <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + s <- svc.Status{State: svc.StopPending} + close(stop) + select { + case <-done: + case <-time.After(15 * time.Second): + } + return false, 0 + } + case <-done: + return false, 0 + } + } +} + +// serviceControl 处理 -service install|uninstall|start|stop(需要管理员权限) +func serviceControl(cmd, cfgPath string) error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("连接服务管理器失败(请用管理员身份运行): %w", err) + } + defer m.Disconnect() + switch cmd { + case "install": + exe, err := os.Executable() + if err != nil { + return err + } + abs, _ := filepath.Abs(cfgPath) + if s, err := m.OpenService(svcName); err == nil { + s.Close() + return fmt.Errorf("服务 %s 已存在(先执行 -service uninstall)", svcName) + } + s, err := m.CreateService(svcName, exe, mgr.Config{ + DisplayName: "我们past Go 服务 (past-server)", + Description: "past.sundynix.cn 后端 API 与管理后台", + StartType: mgr.StartAutomatic, + }, "-config", abs) + if err != nil { + return err + } + defer s.Close() + // 异常退出自动重启 + _ = s.SetRecoveryActions([]mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 3 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 3 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 10 * time.Second}, + }, 86400) + if err := s.Start(); err != nil { + return fmt.Errorf("已安装但启动失败: %w", err) + } + fmt.Println("服务已安装并启动:", svcName, "配置:", abs) + case "uninstall": + s, err := m.OpenService(svcName) + if err != nil { + return fmt.Errorf("服务不存在: %w", err) + } + defer s.Close() + _, _ = s.Control(svc.Stop) + time.Sleep(2 * time.Second) + if err := s.Delete(); err != nil { + return err + } + fmt.Println("服务已卸载:", svcName) + case "start", "stop": + s, err := m.OpenService(svcName) + if err != nil { + return fmt.Errorf("服务不存在: %w", err) + } + defer s.Close() + if cmd == "start" { + return s.Start() + } + _, err = s.Control(svc.Stop) + return err + default: + return fmt.Errorf("未知 -service 命令: %s(install|uninstall|start|stop)", cmd) + } + return nil +} diff --git a/past-go/config.example.yaml b/past-go/config.example.yaml new file mode 100644 index 0000000..1cfc2d4 --- /dev/null +++ b/past-go/config.example.yaml @@ -0,0 +1,18 @@ +# 我们past Go 服务配置(复制为 config.yaml 后填写真实值) +listen: ":8080" +# 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录 +data_dir: "" +db: + host: 127.0.0.1 + port: 3306 + name: past + user: past + pass: your_db_password +wechat: + appid: your_appid + secret: your_secret + notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8 +tencent_map_key: "" +max_upload_mb: 50 +# 定时补发通知间隔(分钟),0 关闭 +notify_interval_min: 5 diff --git a/past-go/deploy/README.md b/past-go/deploy/README.md new file mode 100644 index 0000000..0e1385b --- /dev/null +++ b/past-go/deploy/README.md @@ -0,0 +1,36 @@ +# 20261005 UI 精修与驼峰 API 更新 + +## 部署顺序 + +1. 停止现有服务,备份原程序;用新 Windows AMD64 `past-server.exe` 替换程序。保留当前 `config.yaml`、`uploads`、`data` 和数据库。示例配置仅供参考,不要覆盖现有配置。 +2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。 +3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。 +4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。 +5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。 +6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。 +7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。 + +## 接口规则 + +- 请求地址:`/api/接口名`,例如 `/api/periodList`、`/api/diaryList?page=1&pageSize=20`。 +- 小程序、管理后台、上传统一使用新路径;不再附加 GET `_t`。 +- 例:`POST /api/periodUpdate`,JSON `{ "id": "314", "cycleLength": 25, "periodLength": 5, "startDate": "2026-09-27", "endDate": "2026-10-01" }`。 +- 路径、查询参数、JSON 请求与响应字段统一 lowerCamelCase,例如 `cycleLength`、`periodVisible`、`canAccess`。Multipart 表单字段同样为驼峰。字段值、请求方法和鉴权延续原业务约定。 +- 后端及两个客户端在网络边界转换字段名,内部数据库和页面数据模型保持既有结构。旧 PHP 入口与带下划线的旧路径继续兼容。API 响应 `Cache-Control: no-store`;后台请求使用 `cache: no-store`。 +- Bearer、上传 token 与后台同源 Cookie 仍有效。上传继续携带兼容 Android 的 token/action 表单项。 +- 原 PHP 风格入口继续兼容已发布旧小程序。媒体与后台静态地址仍在 `/backend/` 下。 + +## 小程序验证 + +- Android 与 iPhone:标题与微信胶囊不重叠,首屏和底部内容不被遮挡。 +- 首页:未绑定显示注册天数和邀请入口,绑定后显示在一起天数;解绑后首页、我的立即按接口更新。 +- 三个底部入口:首页、记录、我的;详情返回与发布、编辑流程正常。 +- 日记和相册:首次加载、空列表、网络失败分别展示;创建相册与上传照片正常。 +- 姨姨:对方可见单独操作;关闭自己的开关不能把自己锁住,权限由 API 判断。双方都有绑定前数据时仍选择唯一记录。 +- 改周期、持续天数、开始和结束日期保存后,重新进入展示服务端最新值。 + +本地已检查 WXML/WXSS 编译、JS 语法、请求/上传 URL 与导航尺寸、Go 测试与管理后台构建;未替代真实微信设备和线上数据库联调。 + +## 本次 UI 精修 + +小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。 diff --git a/past-go/deploy/nginx-api.conf b/past-go/deploy/nginx-api.conf new file mode 100644 index 0000000..bed3799 --- /dev/null +++ b/past-go/deploy/nginx-api.conf @@ -0,0 +1,28 @@ +# 放在 past.sundynix.cn 现有 HTTPS server 块里。 +# proxy_pass 末尾不要加 /,需要保留 /api/接口名。 +location = /api { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +location ^~ /api/ { + client_max_body_size 50m; + proxy_read_timeout 120s; + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +# 如果原来没有把 /backend/ 转给 Go,再加入下面这一段。 +# 已有同名 location 时修改原配置,不要重复添加。 +# location ^~ /backend/ { +# client_max_body_size 50m; +# proxy_read_timeout 120s; +# proxy_pass http://127.0.0.1:8081; +# proxy_set_header Host $host; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } diff --git a/past-go/go.mod b/past-go/go.mod new file mode 100644 index 0000000..c2b02cc --- /dev/null +++ b/past-go/go.mod @@ -0,0 +1,42 @@ +module past + +go 1.26.4 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/go-sql-driver/mysql v1.10.1 + golang.org/x/crypto v0.57.0 + golang.org/x/sys v0.48.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/past-go/go.sum b/past-go/go.sum new file mode 100644 index 0000000..9823e18 --- /dev/null +++ b/past-go/go.sum @@ -0,0 +1,101 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/past-go/internal/config/config.go b/past-go/internal/config/config.go new file mode 100644 index 0000000..ee99e4f --- /dev/null +++ b/past-go/internal/config/config.go @@ -0,0 +1,90 @@ +// Package config 读取服务配置(对应 PHP 的 config.php + config.credentials.php)。 +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Listen string `yaml:"listen"` // 监听地址,如 ":8080" + Debug bool `yaml:"debug"` // gin debug 模式(打印路由等) + + // DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。 + // 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。 + DataDir string `yaml:"data_dir"` + + DB struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Name string `yaml:"name"` + User string `yaml:"user"` + Pass string `yaml:"pass"` + } `yaml:"db"` + + Wechat struct { + AppID string `yaml:"appid"` + Secret string `yaml:"secret"` + // 订阅消息模板 ID(对应 WECHAT_SUBSCRIBE_TEMPLATES['notify']) + NotifyTemplateID string `yaml:"notify_template_id"` + } `yaml:"wechat"` + + TencentMapKey string `yaml:"tencent_map_key"` + + // PHPCompat 对齐的 PHP 版本:"8.0"(默认,数据库数值按字符串返回)或 "8.1"(原生类型) + PHPCompat string `yaml:"php_compat"` + + // MaxUploadMB 单文件上传上限(PHP: MAX_UPLOAD_SIZE = 50MB) + MaxUploadMB int64 `yaml:"max_upload_mb"` + + // Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭 + NotifyIntervalMin int `yaml:"notify_interval_min"` +} + +func Load(path string) (*Config, error) { + c := &Config{} + c.Listen = ":8080" + c.DB.Host = "127.0.0.1" + c.DB.Port = 3306 + c.DB.Name = "past" + c.DB.User = "past" + c.MaxUploadMB = 50 + c.NotifyIntervalMin = 5 + c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8" + c.PHPCompat = "8.0" + + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取配置文件 %s: %w", path, err) + } + if err := yaml.Unmarshal(b, c); err != nil { + return nil, fmt.Errorf("解析配置文件 %s: %w", path, err) + } + if c.DataDir == "" { + exe, err := os.Executable() + if err != nil { + return nil, err + } + c.DataDir = filepath.Dir(exe) + } + abs, err := filepath.Abs(c.DataDir) + if err != nil { + return nil, err + } + c.DataDir = abs + return c, nil +} + +func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") } +func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "data") } + +func (c *Config) DSN() string { + // interpolateParams=true:统一走文本协议,所有列以字节返回,由 store 按列类型转换(对齐 PDO 行为) + // time_zone 会在驱动建立的每个 MySQL 连接上设置会话时区,保证 SQL NOW()/CURRENT_TIMESTAMP + // 与 Go 进程的 Asia/Shanghai 一致。使用固定 +08:00 不依赖 MySQL 时区表是否安装。 + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=false&interpolateParams=true&loc=Local&time_zone=%%27%%2B08%%3A00%%27&multiStatements=false", + c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name) +} diff --git a/past-go/internal/config/config_test.go b/past-go/internal/config/config_test.go new file mode 100644 index 0000000..1ba21eb --- /dev/null +++ b/past-go/internal/config/config_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "strings" + "testing" + + "github.com/go-sql-driver/mysql" +) + +func TestDSNSetsShanghaiOffsetForEveryConnection(t *testing.T) { + c := &Config{} + c.DB.Host = "127.0.0.1" + c.DB.Port = 3306 + c.DB.Name = "past" + c.DB.User = "past" + got := c.DSN() + if !strings.Contains(got, "time_zone=%27%2B08%3A00%27") { + t.Fatalf("DSN does not set MySQL session time zone: %s", got) + } + parsed, err := mysql.ParseDSN(got) + if err != nil { + t.Fatalf("invalid DSN: %v", err) + } + if parsed.Params["time_zone"] != "'+08:00'" { + t.Fatalf("unexpected decoded MySQL time_zone: %q", parsed.Params["time_zone"]) + } +} diff --git a/past-go/internal/geo/geo.go b/past-go/internal/geo/geo.go new file mode 100644 index 0000000..44bbef4 --- /dev/null +++ b/past-go/internal/geo/geo.go @@ -0,0 +1,152 @@ +// Package geo 对应 index.php 顶部的腾讯位置服务函数:逆地理编码、IP 定位、球面距离。 +package geo + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strings" + "time" + + "past/internal/store" +) + +type Client struct { + Key string + http *http.Client +} + +func New(key string) *Client { + return &Client{Key: key, http: &http.Client{Timeout: 5 * time.Second}} +} + +func (c *Client) get(u string) map[string]any { + resp, err := c.http.Get(u) + if err != nil { + return nil + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if len(b) == 0 { + return nil + } + var data map[string]any + if json.Unmarshal(b, &data) != nil { + return nil + } + return data +} + +func statusOK(data map[string]any) bool { + if data == nil { + return false + } + st, ok := data["status"] + // PHP 用 === 0 判断:JSON 数字 0 解码为 float 0 + return ok && store.ToFloat(st) == 0 && st != nil +} + +func sub(m map[string]any, key string) map[string]any { + if m == nil { + return nil + } + v, _ := m[key].(map[string]any) + return v +} + +// ReverseGeocode 经纬度(gcj02)→ "省 市 区县";未配置 key 或失败返回空串。 +func (c *Client) ReverseGeocode(lat, lng float64) string { + if c.Key == "" || lat == 0 || lng == 0 { + return "" + } + u := fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5", + store.ToStr(lat), store.ToStr(lng), url.QueryEscape(c.Key)) + data := c.get(u) + if !statusOK(data) { + return "" + } + result := sub(data, "result") + comp := sub(result, "address_component") + if comp == nil { + return "" + } + var parts []string + prov, city, dist := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"]) + if prov != "" { + parts = append(parts, prov) + } + if city != "" && city != prov { + parts = append(parts, city) + } + if dist != "" { + parts = append(parts, dist) + } + if len(parts) > 0 { + return strings.Join(parts, " ") + } + return store.ToStr(result["address"]) +} + +// IPGeolocate IP → "市 区县 街道"(不含省份);失败返回空串。 +func (c *Client) IPGeolocate(ip string) string { + if c.Key == "" || ip == "" || ip == "127.0.0.1" || ip == "::1" { + return "" + } + data := c.get("https://apis.map.qq.com/ws/location/v1/ip?ip=" + url.QueryEscape(ip) + "&key=" + url.QueryEscape(c.Key)) + if !statusOK(data) { + return "" + } + result := sub(data, "result") + ad := sub(result, "ad_info") + if ad == nil { + return "" + } + if loc := sub(result, "location"); loc != nil && store.Truthy(loc["lat"]) && store.Truthy(loc["lng"]) { + geo := c.get(fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5", + store.ToStr(loc["lat"]), store.ToStr(loc["lng"]), url.QueryEscape(c.Key))) + if statusOK(geo) { + if comp := sub(sub(geo, "result"), "address_component"); comp != nil { + var parts []string + prov, city, dist, street := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"]), store.ToStr(comp["street"]) + if city != "" && city != prov { + parts = append(parts, city) + } + if dist != "" { + parts = append(parts, dist) + } + if street != "" { + parts = append(parts, street) + } + if len(parts) > 0 { + return strings.Join(parts, " ") + } + } + } + } + var parts []string + prov, city, dist := store.ToStr(ad["province"]), store.ToStr(ad["city"]), store.ToStr(ad["district"]) + if city != "" && city != prov { + parts = append(parts, city) + } + if dist != "" { + parts = append(parts, dist) + } + if len(parts) > 0 { + return strings.Join(parts, " ") + } + return store.ToStr(sub(result, "location")["city"]) +} + +// Haversine 两点球面距离(公里,保留 1 位小数) +func Haversine(lat1, lng1, lat2, lng2 float64) float64 { + const r = 6371.0 + dLat := (lat2 - lat1) * math.Pi / 180 + dLng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return math.Round(r*c*10) / 10 +} diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go new file mode 100644 index 0000000..dde6f5b --- /dev/null +++ b/past-go/internal/handler/admin.go @@ -0,0 +1,816 @@ +package handler + +import ( + "fmt" + "math" + "regexp" + "strings" + + "past/internal/httpapi" + "past/internal/store" +) + +func registerAdmin(r *httpapi.Router) { + r.Handle("admin_login", adminLogin) + r.Handle("admin_check_auth", adminCheckAuth) + r.Handle("admin_logout", adminLogout) + r.Handle("admin_stats", adminStats) + r.Handle("admin_stats_detail", adminStatsDetail) + r.Handle("admin_couples", adminCouples) + r.Handle("admin_couple_detail", adminCoupleDetail) + r.Handle("admin_pet_detail", adminPetDetail) + r.Handle("admin_pets", adminPets) + r.Handle("admin_users", adminUsers) + r.Handle("admin_fix_last_login", adminFixLastLogin) + r.Handle("admin_clean_locations", adminCleanLocations) + r.Handle("admin_user_detail", adminUserDetail) + r.Handle("admin_change_password", adminChangePassword) + r.Handle("admin_change_username", adminChangeUsername) + r.Handle("admin_update_user", adminUpdateUser) + r.Handle("admin_delete_user", adminDeleteUser) + r.Handle("admin_unbind_requests", adminUnbindRequests) + r.Handle("admin_force_unbind", adminForceUnbind) + r.Handle("admin_restore_couple", adminRestoreCouple) + r.Handle("admin_delete_couple_data", adminDeleteCoupleData) + r.Handle("admin_delete_content", adminDeleteContent) + r.Handle("admin_contents", adminContents) + r.Handle("admin_reset_user_password", adminResetUserPassword) + r.Handle("admin_activity_log", adminActivityLog) + r.Handle("admin_get_global_bg", adminGetGlobalBg) + r.Handle("admin_set_global_bg", adminSetGlobalBg) + r.Handle("admin_clear_global_bg", adminClearGlobalBg) + r.Handle("admin_couple_bg_list", adminCoupleBgList) + r.Handle("admin_clear_couple_bg", adminClearCoupleBg) +} + +func adminLogin(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + admin, err := c.DB.QueryOne("SELECT * FROM admins WHERE username = ?", c.InTrim("username")) + if err != nil { + return err + } + if admin != nil && verifyPassword(c.InStr("password"), admin.Str("password")) { + c.AdminLogin(admin.Int("id"), admin.Str("username")) + return c.OK(M{"success": true, "username": admin["username"]}) + } + return c.Err(401, "用户名或密码错误") +} + +func adminCheckAuth(c *httpapi.Ctx) error { + return c.OK(M{"logged_in": c.AdminSession() != nil}) +} + +func adminLogout(c *httpapi.Ctx) error { + c.AdminLogout() + return c.OK(M{"success": true}) +} + +// tableCount 对应 $db->query("SELECT COUNT(*) as c FROM t")->fetch()['c'](原样,未 intval) +func tableCount(c *httpapi.Ctx, table string) any { + return countRaw(c, "c", "SELECT COUNT(*) as c FROM "+table) +} + +func adminStats(c *httpapi.Ctx) error { + return c.OK(M{ + "total_users": tableCount(c, "users"), "total_couples": tableCount(c, "couples"), + "total_timelines": tableCount(c, "timeline"), "total_diaries": tableCount(c, "diaries"), + "total_albums": tableCount(c, "albums"), "total_photos": tableCount(c, "photos"), + "total_songs": tableCount(c, "songs"), "total_checkins": tableCount(c, "checkins"), + "total_pets": tableCount(c, "pets"), "total_pet_achievements": tableCount(c, "pet_achievements"), + }) +} + +var ymdRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +func adminStatsDetail(c *httpapi.Ctx) error { + date := c.Q("date") + if !c.QHas("date") { + date = today() + } + if !ymdRe.MatchString(date) { + return c.Err(400, "日期格式错误,请使用 YYYY-MM-DD") + } + month := date[:7] + dateCount := func(table string) int64 { + return countC(c, "SELECT COUNT(*) as c FROM `"+table+"` WHERE DATE(created_at) = ?", date) + } + contents := M{} + var total int64 + for _, t := range []string{"timeline", "diaries", "albums", "photos", "songs", "checkins", "checkin_logs"} { + n := dateCount(t) + contents[t] = n + total += n + } + return c.OK(M{ + "date": date, + "today_registers": dateCount("users"), + "month_registers": countC(c, "SELECT COUNT(*) as c FROM users WHERE DATE_FORMAT(created_at, '%Y-%m') = ?", month), + "date_registers": dateCount("users"), + "date_couples": dateCount("couples"), + "date_contents": contents, + "date_contents_total": total, + "total_users": tableCount(c, "users"), + "total_couples": tableCount(c, "couples"), + }) +} + +// paged 后台列表分页包装:query 参数 page / page_size(默认 10,最大 100),返回 {items,total,page,page_size,total_pages} +func paged(c *httpapi.Ctx, all []store.Row) M { + page, pageSize, offset := pageParams(c, 10, 100) + total := int64(len(all)) + end := offset + pageSize + if offset > total { + offset = total + } + if end > total { + end = total + } + return M{ + "items": rows(all[offset:end]), "total": total, "page": page, "page_size": pageSize, + "total_pages": int64(math.Ceil(float64(total) / float64(pageSize))), + } +} + +func adminCouples(c *httpapi.Ctx) error { + rs, err := c.DB.Query("SELECT c.*, c.couple_space_id, u1.nickname as user1_nick, u1.username as user1_name, u1.gender as user1_gender, u1.age as user1_age, u1.birthday as user1_birthday, u2.nickname as user2_nick, u2.username as user2_name, u2.gender as user2_gender, u2.age as user2_age, u2.birthday as user2_birthday, (SELECT COUNT(*) FROM timeline WHERE couple_id = c.id) as timeline_count, (SELECT COUNT(*) FROM diaries WHERE couple_id = c.id) as diary_count, (SELECT COUNT(*) FROM albums WHERE couple_id = c.id) as album_count, (SELECT COUNT(*) FROM songs WHERE couple_id = c.id) as songs_count, (SELECT COUNT(*) FROM checkins WHERE couple_id = c.id) as checkin_count FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.created_at DESC") + if err != nil { + return err + } + return c.OK(paged(c, rs)) +} + +// petFullDetail 宠物详情各子表(admin_couple_detail / admin_pet_detail 共用) +func petFullDetail(c *httpapi.Ctx, cid int64) (dailies, achievements, dynamics, items []store.Row, err error) { + if dailies, err = c.DB.Query("SELECT * FROM pet_dailies WHERE couple_id = ? ORDER BY date DESC LIMIT 60", cid); err != nil { + return + } + if achievements, err = c.DB.Query("SELECT pa.* FROM pet_achievements pa WHERE pa.couple_id = ? ORDER BY pa.unlocked_at DESC", cid); err != nil { + return + } + if dynamics, err = c.DB.Query("SELECT * FROM pet_dynamics WHERE couple_id = ? ORDER BY created_at DESC LIMIT 30", cid); err != nil { + return + } + items, err = c.DB.Query("SELECT * FROM pet_items WHERE couple_id = ? ORDER BY created_at DESC", cid) + return +} + +// rowOrFalse 对应 PDO fetch() 无结果返回 false +func rowOrFalse(r store.Row) any { + if r == nil { + return false + } + return r +} + +func adminCoupleDetail(c *httpapi.Ctx) error { + var cid int64 + if c.Method == "GET" { + cid = c.QInt("couple_id") + } else { + cid = c.InInt("couple_id") + } + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + data := M{} + users, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar FROM users WHERE couple_id = ?", cid) + if err != nil { + return err + } + data["users"] = rows(users) + data["user1_id"], data["user2_id"] = int64(0), int64(0) + if len(users) > 0 { + data["user1_id"] = users[0]["id"] + } + if len(users) > 1 { + data["user2_id"] = users[1]["id"] + } + q := func(key, sql string) error { + rs, err := c.DB.Query(sql, cid) + if err != nil { + return err + } + data[key] = rows(rs) + return nil + } + if err := q("timeline", "SELECT * FROM timeline WHERE couple_id = ? ORDER BY event_date DESC"); err != nil { + return err + } + if err := q("diaries", "SELECT * FROM diaries WHERE couple_id = ? ORDER BY diary_date DESC"); err != nil { + return err + } + if err := q("albums", "SELECT * FROM albums WHERE couple_id = ?"); err != nil { + return err + } + data["photos"] = []store.Row{} + if albums := data["albums"].([]store.Row); len(albums) > 0 { + ids := make([]any, 0, len(albums)) + for _, a := range albums { + ids = append(ids, a["id"]) + } + photos, err := c.DB.Query("SELECT * FROM photos WHERE album_id IN ("+placeholders(len(ids))+") ORDER BY album_id, sort_order ASC, created_at DESC", ids...) + if err != nil { + return err + } + data["photos"] = rows(photos) + } + if err := q("songs", "SELECT * FROM songs WHERE couple_id = ? ORDER BY created_at DESC"); err != nil { + return err + } + if err := q("checkins", "SELECT * FROM checkins WHERE couple_id = ? ORDER BY created_at DESC"); err != nil { + return err + } + pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid) + if err != nil { + return err + } + if pet != nil { + pet["achievement_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_achievements WHERE couple_id = ?", cid) + pet["daily_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_dailies WHERE couple_id = ?", cid) + dailies, achs, dyns, items, err := petFullDetail(c, cid) + if err != nil { + return err + } + pet["dailies"], pet["achievements"], pet["dynamics"], pet["items"] = rows(dailies), rows(achs), rows(dyns), rows(items) + } + data["pet"] = rowOrFalse(pet) + return c.OK(data) +} + +func adminPetDetail(c *httpapi.Ctx) error { + cid := c.QInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid) + if err != nil { + return c.Err(500, "服务器错误: "+err.Error()) + } + dailies, achs, dyns, items, err := petFullDetail(c, cid) + if err != nil { + return c.Err(500, "服务器错误: "+err.Error()) + } + return c.OK(M{"pet": rowOrFalse(pet), "dailies": rows(dailies), "achievements": rows(achs), "dynamics": rows(dyns), "items": rows(items)}) +} + +func adminPets(c *httpapi.Ctx) error { + var where []string + var params []any + if store.Truthy(c.Q("breed")) { + where = append(where, "p.breed = ?") + params = append(params, c.Q("breed")) + } + if store.Truthy(c.Q("stage")) { + where = append(where, "p.stage = ?") + params = append(params, c.QInt("stage")) + } + whereSQL := "" + if len(where) > 0 { + whereSQL = "WHERE " + strings.Join(where, " AND ") + } + rs, err := c.DB.Query(`SELECT p.*, u1.nickname as user1_name, u2.nickname as user2_name FROM pets p + LEFT JOIN users u1 ON p.created_by = u1.id + LEFT JOIN couples c ON p.couple_id = c.id + LEFT JOIN users u2 ON (CASE WHEN c.user1_id = p.created_by THEN c.user2_id ELSE c.user1_id END) = u2.id + `+whereSQL+` ORDER BY p.level DESC, p.updated_at DESC`, params...) + if err != nil { + return err + } + return c.OK(paged(c, rs)) +} + +func adminUsers(c *httpapi.Ctx) error { + search := strings.TrimSpace(c.Q("search")) + bind := strings.TrimSpace(c.Q("bind")) + if !c.QHas("bind") { + bind = "all" + } + sort := strings.TrimSpace(c.Q("sort")) + if !c.QHas("sort") { + sort = "newest" + } + sql := "SELECT u.id, u.user_id, u.openid, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.couple_id, u.created_at, COALESCE(u.last_login_at, u.created_at) as last_login_at, u.location, u.last_login_location, u.last_ip, CASE WHEN u.couple_id IS NOT NULL THEN '已绑定' ELSE '未绑定' END as status FROM users u" + var where []string + var params []any + if bind == "bound" { + where = append(where, "u.couple_id IS NOT NULL") + } else if bind == "unbound" { + where = append(where, "u.couple_id IS NULL") + } + if search != "" { + like := "%" + search + "%" + if isNumeric(search) { + where = append(where, "(u.id = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)") + params = append(params, store.ToInt(search), store.ToInt(search), like, like) + } else { + where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)") + params = append(params, like, like) + } + } + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + if sort == "last_login" { + sql += " ORDER BY COALESCE(u.last_login_at, u.created_at) DESC" + } else { + sql += " ORDER BY u.created_at DESC" + } + rs, err := c.DB.Query(sql, params...) + if err != nil { + return err + } + return c.OK(paged(c, rs)) +} + +func adminFixLastLogin(c *httpapi.Ctx) error { + res, err := c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL") + if err != nil { + return err + } + n, _ := res.RowsAffected() + return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已补全 %d 个用户的最后登录时间", n)}) +} + +func adminCleanLocations(c *httpapi.Ctx) error { + res, err := c.DB.Exec(`UPDATE users SET location = '' WHERE location REGEXP '^-?[0-9]+(\\.[0-9]+)?,-?[0-9]+(\\.[0-9]+)?$'`) + if err != nil { + return err + } + n, _ := res.RowsAffected() + return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已清理 %d 个经纬度格式的位置数据", n)}) +} + +func adminUserDetail(c *httpapi.Ctx) error { + uid := c.QInt("user_id") + if uid == 0 { + return c.Err(400, "缺少用户ID") + } + user, err := c.DB.QueryOne("SELECT id, user_id, openid, username, nickname, avatar, gender, age, birthday, couple_id, created_at, last_login_at, region, location, last_login_location FROM users WHERE id = ?", uid) + if err != nil { + return err + } + if user == nil { + return c.Err(404, "用户不存在") + } + if user.Int("couple_id") != 0 { + partner, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, location, last_login_location FROM users WHERE couple_id = ? AND id != ?", user.Int("couple_id"), uid) + user["partner"] = rowOrFalse(partner) + } + logs, err := c.DB.Query("SELECT cl.*, c.title as checkin_title, c.icon as checkin_icon, u2.nickname as creator_name FROM checkin_logs cl LEFT JOIN checkins c ON cl.checkin_id = c.id LEFT JOIN users u2 ON c.created_by = u2.id WHERE cl.user_id = ? ORDER BY cl.created_at DESC LIMIT 100", uid) + if err != nil { + return err + } + user["checkin_logs"] = rows(logs) + checkins, err := c.DB.Query("SELECT * FROM checkins WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid) + if err != nil { + return err + } + for _, ck := range checkins { + l, _ := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC LIMIT 30", ck.Int("id")) + ck["logs"] = rows(l) + } + user["checkins"] = rows(checkins) + diaries, err := c.DB.Query("SELECT * FROM diaries WHERE created_by = ? ORDER BY diary_date DESC LIMIT 50", uid) + if err != nil { + return err + } + user["diaries"] = rows(diaries) + albums, err := c.DB.Query("SELECT * FROM albums WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid) + if err != nil { + return err + } + user["albums"] = rows(albums) + return c.OK(user) +} + +func adminChangePassword(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + if len(c.InStr("new_password")) < 6 { + return c.Err(400, "新密码至少6位") + } + sess := c.AdminSession() + admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID) + if admin != nil && verifyPassword(c.InStr("old_password"), admin.Str("password")) { + if _, err := c.DB.Exec("UPDATE admins SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", hashPassword(c.InStr("new_password")), sess.AdminID); err != nil { + return err + } + return c.OK(M{"success": true}) + } + return c.Err(400, "原密码错误") +} + +func adminChangeUsername(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + name := c.InTrim("new_username") + if !store.Truthy(name) { + return c.Err(400, "用户名不能为空") + } + sess := c.AdminSession() + if _, err := c.DB.Exec("UPDATE admins SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", name, sess.AdminID); err != nil { + return err + } + sess.Username = name + return c.OK(M{"success": true}) +} + +func adminUpdateUser(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + userID := c.InInt("user_id") + if userID <= 0 { + return c.Err(400, "用户ID无效") + } + var fields []string + var params []any + if nick := c.InTrim("nickname"); store.Truthy(nick) { + fields = append(fields, "nickname = ?") + params = append(params, nick) + } + if g := c.InStr("gender"); store.Truthy(g) { + fields = append(fields, "gender = ?") + params = append(params, g) + } + if c.InHas("age") { + fields = append(fields, "age = ?") + params = append(params, c.InInt("age")) + } + if b := c.InTrim("birthday"); b != "" { + fields = append(fields, "birthday = ?") + params = append(params, b) + } + if len(fields) > 0 { + fields = append(fields, "updated_at = CURRENT_TIMESTAMP") + params = append(params, userID) + if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil { + return err + } + } + return c.OK(M{"success": true}) +} + +func adminDeleteUser(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + target := c.InInt("user_id") + if target <= 0 { + return c.Err(400, "用户ID无效") + } + tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target) + if tu == nil { + return c.Err(404, "用户不存在") + } + deleteIDs := []any{target} + if cid := tu.Int("couple_id"); cid != 0 { + partners, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? AND id != ?", cid, target) + for _, p := range partners { + deleteIDs = append(deleteIDs, p.Int("id")) + } + for _, t := range []string{"timeline", "diaries", "songs", "checkin_logs", "checkins"} { + c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid) + } + albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid) + for _, a := range albums { + c.DB.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id")) + } + c.DB.Exec("DELETE FROM albums WHERE couple_id = ?", cid) + c.DB.Exec("DELETE FROM couples WHERE id = ?", cid) + } + ph := placeholders(len(deleteIDs)) + c.DB.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", append(append([]any{}, deleteIDs...), deleteIDs...)...) + c.DB.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", deleteIDs...) + if _, err := c.DB.Exec("DELETE FROM users WHERE id IN ("+ph+")", deleteIDs...); err != nil { + return err + } + msg := "已删除用户" + if len(deleteIDs) > 1 { + msg = fmt.Sprintf("已删除用户及伴侣(共%d个账号)及相关情侣数据", len(deleteIDs)) + } + return c.OK(M{"success": true, "msg": msg, "deleted_count": len(deleteIDs)}) +} + +var unbindStatusLabels = map[string]string{ + "pending": "待回应", "agreed": "已同意(已解绑)", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期", +} + +func adminUnbindRequests(c *httpapi.Ctx) error { + if err := c.RequireGET(); err != nil { + return err + } + rs, err := c.DB.Query(` + SELECT + ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, + r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar, + c.couple_space_id, c.data_deleted, c.status as couple_status, + u1.nickname as user1_name, u1.user_id as user1_uid, + u2.nickname as user2_name, u2.user_id as user2_uid + FROM unbind_requests ur + JOIN users r ON ur.requester_id = r.id + JOIN couples c ON ur.couple_id = c.id + LEFT JOIN users u1 ON c.user1_id = u1.id + LEFT JOIN users u2 ON c.user2_id = u2.id + WHERE NOT (c.status = 'active' AND ur.status = 'agreed') + UNION + SELECT + NULL as id, c.id as couple_id, 'agreed' as status, NULL as created_at, NULL as responded_at, + NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar, + c.couple_space_id, c.data_deleted, c.status as couple_status, + u1.nickname as user1_name, u1.user_id as user1_uid, + u2.nickname as user2_name, u2.user_id as user2_uid + FROM couples c + LEFT JOIN users u1 ON c.user1_id = u1.id + LEFT JOIN users u2 ON c.user2_id = u2.id + WHERE c.status = 'unbound' + AND c.id NOT IN (SELECT DISTINCT couple_id FROM unbind_requests) + ORDER BY created_at DESC`) + if err != nil { + return err + } + filter := c.Q("status") + out := make([]store.Row, 0, len(rs)) + for _, r := range rs { + st := r.Str("status") + if filter != "" && st != filter { + continue + } + if l, ok := unbindStatusLabels[st]; ok { + r["status_label"] = l + } else { + r["status_label"] = r["status"] + } + out = append(out, r) + } + return c.OK(paged(c, out)) +} + +// coupleUserNames 对应 array_map(fn($u) => $u['nickname'] ?: '#' . $u['user_id'], $users) 后 implode(' & ') +func coupleUserNames(users []store.Row) string { + names := make([]string, 0, len(users)) + for _, u := range users { + n := u.Str("nickname") + if !store.Truthy(n) { + n = "#" + u.Str("user_id") + } + names = append(names, n) + } + return strings.Join(names, " & ") +} + +func adminForceUnbind(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := c.InInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid) + if couple == nil { + return c.Err(404, "情侣不存在") + } + if req, _ := c.DB.QueryOne("SELECT id, requester_id, responded_at FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); req == nil { + return c.Err(400, "双方尚未同意解绑,无法执行操作。请先让双方在微信小程序中完成解绑确认") + } + users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE couple_id = ?", cid) + softUnbind(c.DB, cid) + return c.OK(M{"success": true, "msg": "已执行解绑操作(双方已同意),情侣「" + coupleUserNames(users) + "」的关系已解除,所有数据已保留"}) +} + +func adminRestoreCouple(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := c.InInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid) + if couple == nil { + return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复") + } + u1, u2 := couple.Int("user1_id"), couple.Int("user2_id") + users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", u1, u2) + if len(users) < 2 { + return c.Err(400, "用户数据异常,无法恢复情侣关系") + } + c.DB.Exec("UPDATE couples SET status = 'active' WHERE id = ?", cid) + c.DB.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", cid, u1, u2) + c.DB.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid) + return c.OK(M{"success": true, "msg": "已恢复情侣关系「" + coupleUserNames(users) + "」,所有数据已恢复"}) +} + +func adminDeleteCoupleData(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := c.InInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid) + if couple == nil { + return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据") + } + users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id")) + c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid) + executeUnbind(c.DB, cid) + return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"}) +} + +func adminDeleteContent(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + typ := c.InStr("type") + id, coupleID := c.InInt("id"), c.InInt("couple_id") + if id == 0 || coupleID == 0 { + return c.Err(400, "参数不完整") + } + switch typ { + case "timeline": + c.DB.Exec("DELETE FROM timeline WHERE id = ? AND couple_id = ?", id, coupleID) + case "diary": + c.DB.Exec("DELETE FROM diaries WHERE id = ? AND couple_id = ?", id, coupleID) + case "album": + c.DB.Exec("DELETE FROM photos WHERE album_id = ?", id) + c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID) + case "photo": + if p, _ := c.DB.QueryOne("SELECT path FROM photos WHERE id = ?", id); p != nil { + up.Remove(p.Str("path")) + c.DB.Exec("DELETE FROM photos WHERE id = ?", id) + } + case "song": + c.DB.Exec("DELETE FROM songs WHERE id = ? AND couple_id = ?", id, coupleID) + case "checkin": + c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id) + c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, coupleID) + default: + return c.Err(400, "不支持的内容类型: "+typ) + } + return c.OK(M{"success": true, "msg": "已删除"}) +} + +func adminContents(c *httpapi.Ctx) error { + typ := c.Q("type") + if !c.QHas("type") { + typ = "diary" + } + coupleID, userID := c.QInt("couple_id"), c.QInt("user_id") + startDate, endDate := c.Q("start_date"), c.Q("end_date") + page, pageSize, offset := pageParams(c, 10, 50) + var where []string + var params []any + if coupleID > 0 { + where = append(where, "t.couple_id = ?") + params = append(params, coupleID) + } + if userID > 0 { + where = append(where, "t.created_by = ?") + params = append(params, userID) + } + if startDate != "" { + where = append(where, "t.created_at >= ?") + params = append(params, startDate+" 00:00:00") + } + if endDate != "" { + where = append(where, "t.created_at <= ?") + params = append(params, endDate+" 23:59:59") + } + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + coupleList, err := c.DB.Query("SELECT c.id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.id DESC") + if err != nil { + return err + } + userList, err := c.DB.Query("SELECT id, user_id, nickname, username FROM users ORDER BY id DESC") + if err != nil { + return err + } + const cols = "t.*, u.nickname as author_name, u.user_id as author_display_id, u.avatar as author_avatar" + var sql, countSQL string + switch typ { + case "diary": + sql = "SELECT " + cols + ", t.couple_id FROM diaries t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM diaries t " + whereClause + case "timeline": + sql = "SELECT " + cols + ", t.couple_id FROM timeline t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM timeline t " + whereClause + case "album": + sql = "SELECT " + cols + ", t.couple_id, (SELECT COUNT(*) FROM photos WHERE album_id = t.id) as photo_count FROM albums t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM albums t " + whereClause + case "photo": + sql = "SELECT " + cols + ", a.couple_id FROM photos t JOIN albums a ON t.album_id = a.id LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM photos t JOIN albums a ON t.album_id = a.id " + whereClause + case "song": + sql = "SELECT " + cols + ", t.couple_id FROM songs t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM songs t " + whereClause + case "checkin": + sql = "SELECT " + cols + ", t.couple_id FROM checkins t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" + countSQL = "SELECT COUNT(*) as cnt FROM checkins t " + whereClause + default: + return c.Err(400, "不支持的内容类型") + } + total := countOf(c, countSQL, params...) + items, err := c.DB.Query(sql, append(append([]any{}, params...), pageSize, offset)...) + if err != nil { + return err + } + for _, it := range items { + if _, ok := it["images"]; ok && it["images"] != nil { + it["images"] = jsonDecodeList(it["images"]) + } + } + return c.OK(M{ + "items": rows(items), "total": total, "page": page, "page_size": pageSize, + "total_pages": math.Ceil(float64(total) / float64(pageSize)), + "couples": rows(coupleList), "users": rows(userList), + }) +} + +func adminResetUserPassword(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + userID := c.InInt("user_id") + pw := c.InStr("new_password") + if userID <= 0 { + return c.Err(400, "用户ID无效") + } + if len(pw) < 6 { + return c.Err(400, "新密码至少6位") + } + if _, err := c.DB.Exec("UPDATE users SET password = ? WHERE id = ?", hashPassword(pw), userID); err != nil { + return err + } + return c.OK(M{"success": true, "message": "密码重置成功"}) +} + +func adminActivityLog(c *httpapi.Ctx) error { + cid := c.QInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + rs, err := c.DB.Query("SELECT al.*, u.nickname, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.couple_id = ? ORDER BY al.created_at DESC LIMIT 100", cid) + if err != nil { + return err + } + return c.OK(rows(rs)) +} + +func adminGetGlobalBg(c *httpapi.Ctx) error { + row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = 'bg_global'") + if row == nil { + return c.OK(M{"bg_image": nil}) + } + return c.OK(M{"bg_image": row["value"]}) +} + +func adminSetGlobalBg(c *httpapi.Ctx) error { + fh := c.File("bg") + if fh == nil { + return c.Err(400, "没有上传文件") + } + res := up.Handle(fh, "", "global") + if res.Error != "" { + return c.Err(400, res.Error) + } + if _, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES ('bg_global', ?) ON DUPLICATE KEY UPDATE `value` = ?, updated_at = CURRENT_TIMESTAMP", res.Path, res.Path); err != nil { + return err + } + return c.OK(M{"success": true, "path": res.Path}) +} + +func adminClearGlobalBg(c *httpapi.Ctx) error { + if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = 'bg_global'"); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func adminCoupleBgList(c *httpapi.Ctx) error { + rs, err := c.DB.Query("SELECT c.id as couple_id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name, CONCAT(u1.nickname, ' & ', u2.nickname) as couple_name, s1.value as bg_image FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN settings s1 ON s1.`key` = CONCAT('bg_', c.id) ORDER BY c.id DESC") + if err != nil { + return err + } + return c.OK(rows(rs)) +} + +func adminClearCoupleBg(c *httpapi.Ctx) error { + cid := c.InInt("couple_id") + if cid == 0 { + return c.Err(400, "缺少情侣ID") + } + if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", fmt.Sprintf("bg_%d", cid), fmt.Sprintf("bgver_%d", cid)); err != nil { + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go new file mode 100644 index 0000000..55e7ac8 --- /dev/null +++ b/past-go/internal/handler/album.go @@ -0,0 +1,346 @@ +package handler + +import ( + "fmt" + "strings" + + "past/internal/httpapi" + "past/internal/store" + "past/internal/upload" +) + +func registerAlbum(r *httpapi.Router) { + r.Handle("album_list", albumList) + r.Handle("album_add", albumAdd) + r.Handle("album_update", albumUpdate) + r.Handle("album_delete", albumDelete) + r.Handle("set_album_cover", setAlbumCover) + r.Handle("photos", photos) + r.Handle("photo_upload", photoUpload) + r.Handle("photo_delete", photoDelete) + r.Handle("photo_add", photoAdd) +} + +// coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ? +func coupleSpaceID(c *httpapi.Ctx, cid int64) string { + row, _ := c.DB.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid) + if row == nil { + return "" + } + return row.Str("couple_space_id") +} + +func albumList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK(demoData("album")) + } + items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC", ids[0], ids[1]) + if err != nil { + return err + } + for _, it := range items { + if store.Truthy(it["latest_photo"]) { + it["cover"] = it["latest_photo"] + } + } + return c.OK(rows(items)) +} + +func albumAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + uid := c.UID() + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { + return err + } + lat, lng := c.InFloat("lat"), c.InFloat("lng") + location := c.InTrim("location") + if lat != 0 && lng != 0 && location == "" { + location = c.App.Geo.ReverseGeocode(lat, lng) + } + id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + cid, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid) + if err != nil { + return err + } + title := c.InStr("title") + actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "新相册「" + mbTruncate(title, 20) + "」", + "thing2": "TA创建了新相册,快来一起看看吧 📸", + "date3": today(), + "thing4": "点击查看详情", + }, "pages/albums/albums", actID, uid) + } + return c.OK(M{"success": true, "id": fmt.Sprint(id)}) +} + +func albumUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { + return err + } + title, desc, sort, id := c.In("title"), inOr(c, "description", ""), inOr(c, "sort_order", 0), c.In("id") + hasCover, hasLat := c.InHas("cover"), c.InHas("lat") + switch { + case hasCover && hasLat: + _, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)", + title, desc, c.In("cover"), sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1]) + case hasCover: + _, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)", + title, desc, c.In("cover"), sort, id, ids[0], ids[1]) + case hasLat: + _, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)", + title, desc, sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1]) + default: + _, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)", + title, desc, sort, id, ids[0], ids[1]) + } + if err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func albumDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + id := c.In("id") + if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil { + return c.Err(404, "相册不存在") + } + if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", id); cnt != nil && cnt.Int("cnt") > 0 { + return c.Err(400, "请先删除相册中的全部照片") + } + if _, err := c.DB.Exec("DELETE FROM albums WHERE id = ?", id); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func setAlbumCover(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + ids := coupleUserIds(c, 0) + albumID := store.ToInt(c.Form("album_id")) + var a store.Row + if len(ids) == 2 { + a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1]) + } + if a == nil { + return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound) + } + spaceID := coupleSpaceID(c, c.CoupleID()) + fh := c.File("cover") + if fh == nil { + return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) + } + res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos")) + if res.Path != "" { + if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil { + return err + } + return c.OK(M{"success": true, "path": res.Path}) + } + if strings.Contains(res.Error, "违规") { + return c.ErrCode(400, res.Error, upload.ErrHandleFailed) + } + return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed) +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func photos(c *httpapi.Ctx) error { + albumID := c.Q("album_id") + if albumID == "" { + albumID = "0" + } + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK([]any{}) + } + items, err := c.DB.Query("SELECT p.* FROM photos p JOIN albums a ON p.album_id = a.id WHERE a.created_by IN (?, ?) AND p.album_id = ? ORDER BY p.sort_order ASC, p.created_at DESC", ids[0], ids[1], albumID) + if err != nil { + return err + } + return c.OK(rows(items)) +} + +// setFirstCover 相册第一张照片时自动设为封面 +func setFirstCover(c *httpapi.Ctx, albumID any, path string) { + if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", albumID); cnt != nil && cnt.Int("cnt") == 1 { + c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ? AND (cover IS NULL OR cover = '')", path, albumID) + } +} + +func photoUpload(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + uid := c.UID() + if uid == 0 { + return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) + } + cid := c.CoupleID() + if cid == 0 { + return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound) + } + spaceID := coupleSpaceID(c, cid) + albumID := c.Form("album_id") + if albumID == "" { + albumID = "0" + } + if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil { + return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound) + } + description := c.Form("description") + if err := checkTextOr400(c, strings.TrimSpace(description)); err != nil { + return err + } + uploaded := []string{} + errors := []string{} + files := c.Files("photos") + if len(files) == 0 { + files = c.Files("file") + } + if len(files) == 0 { + // 兼容某些安卓端直接传二进制:从原始请求体读取 + raw := c.Body() + if len(raw) > 100 { + res := up.SaveRaw(raw, "", "jpg", "", upload.CouplePath(spaceID, "photos")) + if res.Path != "" { + c.DB.Exec("INSERT INTO photos (album_id, path, description, created_by) VALUES (?, ?, ?, ?)", albumID, res.Path, description, uid) + uploaded = append(uploaded, res.Path) + setFirstCover(c, albumID, res.Path) + } else if strings.Contains(res.Error, "违规") { + errors = append(errors, "所发布内容含违规信息") + } + } + if len(uploaded) == 0 { + return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived) + } + } + for _, fh := range files { + res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos")) + if res.Path != "" { + c.DB.Exec("INSERT INTO photos (album_id, path, description, taken_at, location, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + albumID, res.Path, description, c.Form("taken_at"), c.Form("location"), c.Form("activity"), c.Form("batch_id"), uid) + uploaded = append(uploaded, res.Path) + setFirstCover(c, albumID, res.Path) + } else { + errors = append(errors, fh.Filename+": "+orDefault(res.Error, "未知错误")) + } + } + if len(uploaded) == 0 && len(errors) > 0 { + return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed) + } + if len(uploaded) > 0 { + actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "相册有更新", + "thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)), + "date3": today(), + "thing4": "点击查看详情", + }, "pages/albums/albums", actID, uid) + } + } + return c.OK(M{"success": true, "uploaded": len(uploaded), "errors": errors}) +} + +func photoDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + id := c.In("id") + photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return err + } + if photo != nil { + albumID := photo.Int("album_id") + up.Remove(photo.Str("path")) + c.DB.Exec("DELETE FROM photos WHERE id = ?", id) + album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID) + if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") { + next, _ := c.DB.QueryOne("SELECT path FROM photos WHERE album_id = ? ORDER BY created_at ASC LIMIT 1", albumID) + newCover := "" + if next != nil { + newCover = next.Str("path") + } + c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", newCover, albumID) + } + } + return c.OK(M{"success": true}) +} + +func photoAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + if !c.InBool("album_id") || !c.InBool("path") { + return c.Err(400, "参数不完整") + } + albumID := c.In("album_id") + if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil { + return c.Err(404, "相册不存在") + } + uid := c.UID() + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil { + return err + } + lat, lng := c.InFloat("lat"), c.InFloat("lng") + location := c.InTrim("location") + if lat != 0 && lng != 0 && location == "" { + location = c.App.Geo.ReverseGeocode(lat, lng) + } + path := c.In("path") + id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid) + if err != nil { + return err + } + setFirstCover(c, albumID, c.InStr("path")) + actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "相册有更新", + "thing2": "TA上传了新照片 📸", + "date3": today(), + "thing4": "点击查看详情", + }, "pages/albums/albums", actID, uid) + } + return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path}) +} diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go new file mode 100644 index 0000000..2ab44fd --- /dev/null +++ b/past-go/internal/handler/checkin.go @@ -0,0 +1,436 @@ +package handler + +import ( + "fmt" + "time" + + "past/internal/httpapi" + "past/internal/store" +) + +func registerCheckin(r *httpapi.Router) { + r.Handle("checkin_list", checkinList) + r.Handle("checkin_add", checkinAdd) + r.Handle("checkin_do", checkinDo) + r.Handle("checkin_uncheck", checkinUncheck) + r.Handle("checkin_detail", checkinDetail) + r.Handle("checkin_stats", checkinStats) + r.Handle("checkin_toggle", checkinToggle) + r.Handle("checkin_delete", checkinDelete) +} + +// weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一 +func weekMonday() string { + t := time.Now() + wd := int(t.Weekday()) + if wd == 0 { + wd = 7 + } + return t.AddDate(0, 0, -(wd - 1)).Format("2006-01-02") +} + +func monthStart() string { return time.Now().Format("2006-01") + "-01" } // date('Y-m-01') +func yearStart() string { return time.Now().Format("2006") + "-01-01" } // date('Y-01-01') + +var checkinTypeLabels = map[string]string{"both": "双方", "first": "第一视角", "her": "她打卡"} + +// first10 对应 $t ? substr($t, 0, 10) : null;返回 (值, 是否非空) +func first10(v any) (string, bool) { + s := store.ToStr(v) + if !store.Truthy(s) { + return "", false + } + if len(s) > 10 { + s = s[:10] + } + return s, true +} + +// periodDone 周期完成判定 +func periodDone(freq string, last string, has bool, today, monday, month string) bool { + switch freq { + case "once": + return has + case "unlimited": + return false + case "monthly": + return has && last >= month + case "weekly": + return has && last >= monday + default: + return has && last == today + } +} + +// decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段 +func decorateCheckin(item store.Row, uid int64, withAvatar bool) { + isCreator := uid == item.Int("created_by") + freq := store.ToStr(item["frequency"]) + if item.IsNull("frequency") { + freq = "daily" + } + typ := store.ToStr(item["type"]) + if item.IsNull("type") { + typ = "both" + } + label, ok := checkinTypeLabels[typ] + if !ok { + label = "双方" + } + item["type_label"] = label + var myTime, partnerTime any + if isCreator { + myTime, partnerTime = item["my_checkin_time"], item["partner_checkin_time"] + } else { + myTime, partnerTime = item["partner_checkin_time"], item["my_checkin_time"] + } + td, mon, ms := today(), weekMonday(), monthStart() + myLast, myHas := first10(myTime) + partnerLast, partnerHas := first10(partnerTime) + myDone := periodDone(freq, myLast, myHas, td, mon, ms) + partnerDone := periodDone(freq, partnerLast, partnerHas, td, mon, ms) + item["my_done_today"] = myDone + item["partner_done_today"] = partnerDone + switch typ { + case "first": + item["can_checkin"] = isCreator && !myDone + case "her": + item["can_checkin"] = !isCreator && !partnerDone + default: + item["can_checkin"] = !myDone + } + completed := 0 + if typ == "both" { + if myDone && partnerDone { + completed = 1 + } + } else if (typ == "first" && myDone) || (typ != "first" && partnerDone) { + completed = 1 + } + item["is_completed"] = completed + // 注意:PHP 的 checkin_list 会把时间字段换成当前视角,checkin_detail 不换(withAvatar 仅 list 为 true) + if withAvatar { + item["my_checkin_time"] = myTime + item["partner_checkin_time"] = partnerTime + } + orZero := func(v any) any { + if v == nil { + return 0 + } + return v + } + orEmpty := func(v any) any { + if v == nil { + return "" + } + return v + } + // 与 PHP 逐行顺序赋值保持一致:非创建者视角下 my_* 先被覆盖,随后 partner_* 读到的是已覆盖后的值 + // (PHP 原有行为,对方的 streak/照片/备注会显示为自己的值;为保持 API 一致这里原样复刻) + swap := func(my, partner string, def func(any) any) { + if isCreator { + item[my] = def(item[my]) + item[partner] = def(item[partner]) + } else { + item[my] = def(item[partner]) + item[partner] = def(item[my]) + } + } + swap("my_streak", "partner_streak", orZero) + swap("my_photo", "partner_photo", orEmpty) + swap("my_note", "partner_note", orEmpty) + if withAvatar { + ca, cn := orEmpty(item["creator_avatar"]), orEmpty(item["creator_name"]) + if isCreator { + item["my_checkin_avatar"], item["my_checkin_name"] = ca, cn + item["partner_checkin_avatar"], item["partner_checkin_name"] = "", "" + } else { + item["my_checkin_avatar"], item["my_checkin_name"] = "", "" + item["partner_checkin_avatar"], item["partner_checkin_name"] = ca, cn + } + } +} + +func checkinList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5}) + } + uid := c.UID() + items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1]) + if err != nil { + return err + } + for _, it := range items { + decorateCheckin(it, uid, true) + } + total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?)", ids[0], ids[1]) + var logTotal int64 + if cid := c.CoupleID(); cid != 0 { + logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid) + } + return c.OK(M{"items": rows(items), "week_both_count": 0, "total": total, "checkin_log_total": logTotal}) +} + +func countC(c *httpapi.Ctx, sql string, args ...any) int64 { + r, _ := c.DB.QueryOne(sql, args...) + if r == nil { + return 0 + } + return r.Int("c") +} + +func checkinAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + uid := c.UID() + if err := checkTextOr400(c, c.InTrim("title")); err != nil { + return err + } + id, err := c.DB.Insert("INSERT INTO checkins (couple_id, title, icon, type, frequency, is_completed, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + cid, c.In("title"), inOr(c, "icon", "✅"), inOr(c, "type", "dual"), inOr(c, "frequency", "daily"), 0, inOr(c, "sort_order", 0), uid) + if err != nil { + return err + } + logActivity(c.DB, cid, uid, "checkin_create", "添加了打卡项「"+c.InStr("title")+"」", "") + return c.OK(M{"success": true, "id": fmt.Sprint(id)}) +} + +// loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404) +func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) { + ids := coupleUserIds(c, 0) + var item store.Row + if len(ids) == 2 { + var err error + item, err = c.DB.QueryOne("SELECT * FROM checkins WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return nil, err + } + } + if item == nil { + return nil, c.Err(404, "打卡项不存在") + } + return item, nil +} + +func checkinDo(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, uid := c.CoupleID(), c.UID() + id := c.InInt("id") + if id == 0 { + return c.Err(400, "缺少打卡ID") + } + item, err := loadCheckinItem(c, id) + if err != nil { + return err + } + isCreator := uid == item.Int("created_by") + td, nowS := today(), now() + freq := store.ToStr(item["frequency"]) + if item.IsNull("frequency") { + freq = "daily" + } + typ := store.ToStr(item["type"]) + if item.IsNull("type") { + typ = "both" + } + mon, ms := weekMonday(), monthStart() + if typ == "first" && !isCreator { + return c.Err(403, "第一视角打卡仅创建者可操作") + } + if typ == "her" && isCreator { + return c.Err(403, "她打卡仅对方可操作") + } + myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField := "my_checkin_time", "my_streak", "my_photo", "my_note", "completed_by_me" + partnerTimeField := "partner_checkin_time" + if !isCreator { + myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField = "partner_checkin_time", "partner_streak", "partner_photo", "partner_note", "completed_by_partner" + partnerTimeField = "my_checkin_time" + } + myLastTime := store.ToStr(item[myTimeField]) + myLast, myHas := first10(item[myTimeField]) + if store.Truthy(myLastTime) { + if t, err := parseDate(myLastTime); err == nil && time.Since(t) < 5*time.Second { + return c.JSON(429, M{"error": "操作太快,请稍后再试", "cooldown": true}) + } + } + if periodDone(freq, myLast, myHas, td, mon, ms) { + return c.OK(M{"error": "本期已打卡", "already_done": true}) + } + yesterday := daysAgo(1) + myStreak := int64(1) + if myHas && myLast == yesterday { + myStreak = item.Int(myStreakField) + 1 + } + photo := inOr(c, "photo", "") + note := inOr(c, "note", "") + if err := checkTextOr400(c, store.ToStr(note)); err != nil { + return err + } + if _, err := c.DB.Exec(fmt.Sprintf("UPDATE checkins SET %s = 1, %s = ?, %s = ?, %s = ?, %s = ? WHERE id = ?", myDoneField, myTimeField, myStreakField, myPhotoField, myNoteField), + nowS, myStreak, photo, note, id); err != nil { + return err + } + allDone := true + if typ == "both" { + partnerLast, partnerHas := first10(item[partnerTimeField]) + allDone = periodDone(freq, partnerLast, partnerHas, td, mon, ms) + } + if allDone { + c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id) + } + title := item.Str("title") + actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "") + c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note) + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "打卡「" + mbTruncate(title, 20) + "」", + "thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak), + "date3": td, + "thing4": "点击查看详情", + }, "pages/punch/punch", actID, uid) + } + return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ}) +} + +func checkinUncheck(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + id := c.InInt("id") + if id == 0 { + return c.Err(400, "缺少打卡ID") + } + item, err := loadCheckinItem(c, id) + if err != nil { + return err + } + sql := "UPDATE checkins SET completed_by_me = 0, my_checkin_time = NULL, my_streak = 0, my_photo = '', my_note = '', is_completed = 0 WHERE id = ?" + if uid != item.Int("created_by") { + sql = "UPDATE checkins SET completed_by_partner = 0, partner_checkin_time = NULL, partner_streak = 0, partner_photo = '', partner_note = '', is_completed = 0 WHERE id = ?" + } + if _, err := c.DB.Exec(sql, id); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func checkinDetail(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK([]any{}) + } + id := c.QInt("id") + if id == 0 { + return c.Err(400, "缺少打卡ID") + } + item, err := c.DB.QueryOne("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.id = ? AND c.created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return err + } + if item == nil { + return c.Err(404, "打卡项不存在") + } + uid := c.UID() + decorateCheckin(item, uid, false) + td, mon, ms, ys := today(), weekMonday(), monthStart(), yearStart() + logs, err := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC", id) + if err != nil { + return err + } + var myT, myW, myM, myY, pT, pW, pM, pY int + for _, l := range logs { + d, _ := first10(l["created_at"]) + mine := l.Int("user_id") == uid + inc := func(cond bool, m, p *int) { + if cond { + if mine { + *m++ + } else { + *p++ + } + } + } + inc(d == td, &myT, &pT) + inc(d >= mon, &myW, &pW) + inc(d >= ms, &myM, &pM) + inc(d >= ys, &myY, &pY) + } + return c.OK(M{ + "item": item, "logs": rows(logs), + "my_today": myT, "my_week": myW, "my_month": myM, "my_year": myY, + "partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY, + }) +} + +func checkinStats(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK([]any{}) + } + td, ws := today(), weekMonday() + return c.OK(M{ + "week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ?", ids[0], ids[1], ws), + "week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time >= ?", ids[0], ids[1], ws), + "week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws), + "today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%"), + "today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%"), + }) +} + +func checkinToggle(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + id := c.In("id") + cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid) + newStatus := int64(1) + if cur != nil && store.Truthy(cur["is_completed"]) { + newStatus = 0 + } + var completedAt any + if newStatus == 1 { + completedAt = now() + } + if _, err := c.DB.Exec("UPDATE checkins SET is_completed = ?, completed_at = ? WHERE id = ? AND couple_id = ?", newStatus, completedAt, id, cid); err != nil { + return err + } + title := "" + if it, _ := c.DB.QueryOne("SELECT title FROM checkins WHERE id = ?", id); it != nil { + title = it.Str("title") + } + verb := "取消了" + if newStatus == 1 { + verb = "完成了" + } + logActivity(c.DB, cid, c.UID(), "checkin_toggle", verb+"打卡「"+title+"」", "") + return c.OK(M{"success": true, "is_completed": newStatus}) +} + +func checkinDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + id := c.In("id") + c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id) + if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil { + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/common.go b/past-go/internal/handler/common.go new file mode 100644 index 0000000..e0cacec --- /dev/null +++ b/past-go/internal/handler/common.go @@ -0,0 +1,431 @@ +// Package handler 按模块实现全部 action,与 backend/api/index.php 一一对应。 +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf16" + "unicode/utf8" + + "past/internal/httpapi" + "past/internal/store" + "past/internal/upload" +) + +type M = httpapi.M + +// deps 注入的依赖(上传器等) +var up *upload.Uploader + +// Register 注册全部 action +func Register(r *httpapi.Router, uploader *upload.Uploader) { + up = uploader + registerUser(r) + registerCouple(r) + registerTimeline(r) + registerAlbum(r) + registerDiary(r) + registerSongs(r) + registerCheckin(r) + registerPeriod(r) + registerBg(r) + registerSite(r) + registerActivity(r) + registerSubscribe(r) + registerPet(r) + registerAdmin(r) +} + +func now() string { return time.Now().Format("2006-01-02 15:04:05") } + +// needLoginRecord 对应“从未登录过,或距上次登录超过 5 分钟” +func needLoginRecord(lastLoginAt string) bool { + if lastLoginAt == "" { + return true + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", lastLoginAt, time.Local) + return err != nil || time.Since(t) > 300*time.Second +} +func today() string { return time.Now().Format("2006-01-02") } +func daysAgo(n int) string { return time.Now().AddDate(0, 0, -n).Format("2006-01-02") } + +// rows 保证列表非 nil(JSON 输出 [] 而非 null) +func rows(r []store.Row) []store.Row { + if r == nil { + return []store.Row{} + } + return r +} + +// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id +func generateUserId(db store.Querier) (string, error) { + row, err := db.QueryOne("SELECT `value` FROM settings WHERE `key` = 'last_user_id'") + if err != nil { + return "", err + } + var next int64 + if row != nil && row.Int("value") >= 520001 { + next = row.Int("value") + 1 + } else { + mx, err := db.QueryOne("SELECT MAX(CAST(user_id AS SIGNED)) as max_id FROM users WHERE user_id LIKE '520%'") + if err != nil { + return "", err + } + if mx != nil && mx.Int("max_id") != 0 { + next = mx.Int("max_id") + 1 + } else { + next = 520001 + } + } + if _, err := db.Exec("REPLACE INTO settings (`key`, `value`, updated_at) VALUES ('last_user_id', ?, CURRENT_TIMESTAMP)", fmt.Sprint(next)); err != nil { + return "", err + } + return fmt.Sprint(next), nil +} + +// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT) +func generateCoupleSpaceId(coupleID int64) string { + return fmt.Sprintf("CP%06d", coupleID) +} + +// logActivity 对应 logActivity(),返回 activity id +func logActivity(db store.Querier, coupleID, userID int64, actionType, description, detail string) int64 { + id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())", + coupleID, userID, actionType, description, detail) + return id +} + +// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空 +func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 { + if coupleID == 0 { + coupleID = c.CoupleID() + if coupleID == 0 { + return nil + } + } + row, _ := c.DB.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", coupleID) + if row == nil { + return nil + } + return []int64{row.Int("user1_id"), row.Int("user2_id")} +} + +// partnerUserId 对应 getPartnerUserId($userId);无则 0 +func partnerUserId(db store.Querier, userID int64) int64 { + u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID) + if u == nil || u.Int("couple_id") == 0 { + return 0 + } + cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id")) + if cp == nil { + return 0 + } + if cp.Int("user1_id") == userID { + return cp.Int("user2_id") + } + return cp.Int("user1_id") +} + +// userOpenId 对应 getUserOpenId($uid) +func userOpenId(db store.Querier, uid int64) string { + if uid == 0 { + return "" + } + row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid) + if row == nil { + return "" + } + return row.Str("openid") +} + +// softUnbind 对应 softUnbind($db, $cid):仅标记解除,数据保留 +func softUnbind(db store.Querier, cid int64) { + db.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE couple_id = ? AND status IN ('pending','agreed')", cid) + db.Exec("UPDATE couples SET status = 'unbound', data_deleted = 0 WHERE id = ?", cid) + db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid) +} + +// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料 +func executeUnbind(db store.Querier, cid int64) { + db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid) + db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid) + db.Exec("DELETE FROM songs WHERE couple_id = ?", cid) + db.Exec("DELETE FROM checkin_logs WHERE couple_id = ?", cid) + db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid) + albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid) + for _, a := range albums { + photos, _ := db.Query("SELECT path FROM photos WHERE album_id = ?", a.Int("id")) + for _, p := range photos { + up.Remove(p.Str("path")) + } + db.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id")) + } + db.Exec("DELETE FROM albums WHERE couple_id = ?", cid) + db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid) + users, _ := db.Query("SELECT id FROM users WHERE couple_id = ?", cid) + if len(users) > 0 { + ids := make([]any, 0, len(users)) + for _, u := range users { + ids = append(ids, u.Int("id")) + } + ph := placeholders(len(ids)) + args := append(append([]any{}, ids...), ids...) + db.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", args...) + db.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", ids...) + } + db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid) + db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid) + sp, _ := db.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid) + if sp != nil && sp.Str("couple_space_id") != "" { + dir := filepath.Join(up.Dir, "couples", sp.Str("couple_space_id")) + if st, err := os.Stat(dir); err == nil && st.IsDir() { + os.RemoveAll(dir) + } + } + db.Exec("DELETE FROM couples WHERE id = ?", cid) +} + +// createDefaultAlbums 对应 createDefaultAlbums() +func createDefaultAlbums(db store.Querier, coupleID, userID int64) { + defaults := [][2]string{ + {"日常甜蜜", "我们的小日常"}, + {"旅行记录", "一起走过的风景"}, + {"美食打卡", "一起吃的每一顿饭"}, + {"生活碎片", "生活中的点点滴滴"}, + {"节日纪念", "特别的节日和纪念日"}, + {"自拍合照", "我们的精彩瞬间"}, + } + for i, d := range defaults { + db.Exec("INSERT INTO albums (couple_id, title, description, sort_order, created_by) VALUES (?, ?, ?, ?, ?)", coupleID, d[0], d[1], i, userID) + } +} + +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} + +// resolveLoginLocation 对应 resolveLoginLocation($input, $fallback) +func resolveLoginLocation(c *httpapi.Ctx, fallback string) string { + raw := strings.TrimSpace(c.InStr("location")) + if raw == "" { + raw = strings.TrimSpace(fallback) + } + if raw == "" { + return "" + } + if strings.Contains(raw, ",") { + parts := strings.Split(raw, ",") + if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) { + lat := store.ToFloat(strings.TrimSpace(parts[0])) + lng := store.ToFloat(strings.TrimSpace(parts[1])) + if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" { + return addr + } + } + } + return raw +} + +// isNumeric 对应 PHP is_numeric +func isNumeric(s string) bool { + if s == "" { + return false + } + var f float64 + _, err := fmt.Sscanf(s, "%g", &f) + if err != nil { + return false + } + // Sscanf 允许前缀匹配,再严格检查字符集 + for _, ch := range s { + if !(ch >= '0' && ch <= '9') && ch != '.' && ch != '-' && ch != '+' && ch != 'e' && ch != 'E' { + return false + } + } + return true +} + +// demoData 对应 getDemoData($module) +func demoData(module string) []M { + ts := now() + switch module { + case "timeline": + return []M{ + {"id": -1, "title": "第一次相遇", "content": "还记得那天阳光正好,你穿着白色的衬衫,在图书馆的角落里安静地看书。", "event_date": daysAgo(30), "location": "图书馆", "images": []any{}, "icon": "❤️", "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -2, "title": "第一次约会", "content": "一起看了场电影,吃了顿美味的晚餐,聊了很多很多。", "event_date": daysAgo(25), "location": "电影院", "images": []any{}, "icon": "🎬", "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -3, "title": "一起做饭", "content": "第一次一起下厨,虽然把厨房弄得一团糟,但很开心。", "event_date": daysAgo(20), "location": "家", "images": []any{}, "icon": "🍳", "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -4, "title": "公园散步", "content": "傍晚在公园散步,夕阳很美,你更美。", "event_date": daysAgo(15), "location": "公园", "images": []any{}, "icon": "🌅", "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -5, "title": "纪念日", "content": "属于我们的特别日子,值得永远铭记。", "event_date": daysAgo(10), "location": "", "images": []any{}, "icon": "🎉", "sort_order": 0, "created_by": 0, "created_at": ts}, + } + case "album": + return []M{ + {"id": -1, "title": "甜蜜日常", "description": "记录我们的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -2, "title": "旅行记忆", "description": "一起走过的风景", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -3, "title": "美食打卡", "description": "一起吃的每一顿饭", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -4, "title": "生活碎片", "description": "生活中的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -5, "title": "我们的故事", "description": "从开始到现在", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + } + case "diary": + return []M{ + {"id": -1, "title": "初识", "content": "今天在图书馆遇见了你,心跳加速的感觉真好。", "mood": "😊", "weather": "☀️", "diary_date": daysAgo(5), "images": []any{}, "created_by": 0, "created_at": ts}, + {"id": -2, "title": "美好的一天", "content": "和你在一起的时间总是过得太快,期待下次见面。", "mood": "🥰", "weather": "☀️", "diary_date": daysAgo(4), "images": []any{}, "created_by": 0, "created_at": ts}, + {"id": -3, "title": "小确幸", "content": "收到你的消息,一整天的心情都变好了。", "mood": "💕", "weather": "⛅", "diary_date": daysAgo(3), "images": []any{}, "created_by": 0, "created_at": ts}, + {"id": -4, "title": "想念", "content": "今天没有见到你,好想你呀。", "mood": "😔", "weather": "🌧️", "diary_date": daysAgo(2), "images": []any{}, "created_by": 0, "created_at": ts}, + {"id": -5, "title": "期待未来", "content": "想象和你一起的每一个明天,都是那么美好。", "mood": "💖", "weather": "☀️", "diary_date": daysAgo(1), "images": []any{}, "created_by": 0, "created_at": ts}, + } + case "songs": + return []M{ + {"id": -1, "song_name": "告白气球", "artist": "周杰伦", "note": "第一次约会时听的歌", "created_by": 0, "created_at": ts}, + {"id": -2, "song_name": "小情歌", "artist": "苏打绿", "note": "你最喜欢的一首歌", "created_by": 0, "created_at": ts}, + {"id": -3, "song_name": "想你的夜", "artist": "关喆", "note": "每次想你的时候都会听", "created_by": 0, "created_at": ts}, + {"id": -4, "song_name": "有点甜", "artist": "汪苏泷", "note": "我们的主题曲", "created_by": 0, "created_at": ts}, + {"id": -5, "song_name": "最美的期待", "artist": "周笔畅", "note": "对未来充满期待", "created_by": 0, "created_at": ts}, + } + case "checkin": + return []M{ + {"id": -1, "title": "每天说爱你", "icon": "❤️", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -2, "title": "一起运动", "icon": "🏃", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -3, "title": "早睡打卡", "icon": "🌙", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -4, "title": "喝水提醒", "icon": "💧", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + {"id": -5, "title": "看书学习", "icon": "📚", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts}, + } + } + return []M{} +} + +// inOr 对应 $input['key'] ?? $def(null 也视为未设置) +func inOr(c *httpapi.Ctx, key string, def any) any { + if v := c.In(key); v != nil { + return v + } + return def +} + +// mbTruncate 对应 mb_strlen($s) > n ? mb_substr($s, 0, n) . '...' : $s +func mbTruncate(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + "..." + } + return s +} + +// jsonEncode 对应 PHP json_encode($v) 默认标志(存库用):转义 "/" 为 "\/",非 ASCII 转 \uXXXX(含代理对), +// 使 Go 写入的 JSON 字段与 PHP 历史数据字节一致。 +func jsonEncode(v any) string { + if v == nil { + return "null" + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "null" + } + return phpEscapeJSON(strings.TrimRight(buf.String(), "\n")) +} + +// phpEscapeJSON 把 Go 的 JSON 文本转换成 PHP json_encode 默认输出形式 +func phpEscapeJSON(s string) string { + var sb strings.Builder + inStr := false + for i := 0; i < len(s); { + ch := s[i] + if !inStr { + if ch == '"' { + inStr = true + } + sb.WriteByte(ch) + i++ + continue + } + switch { + case ch == '\\': + // 已转义序列(Go 产生的 \uXXXX / \" / \\ / \n 等)原样保留 + if i+1 < len(s) { + sb.WriteByte(ch) + sb.WriteByte(s[i+1]) + i += 2 + } else { + sb.WriteByte(ch) + i++ + } + case ch == '"': + inStr = false + sb.WriteByte(ch) + i++ + case ch == '/': + sb.WriteString("\\/") + i++ + case ch >= 0x80: + r, size := utf8.DecodeRuneInString(s[i:]) + if r >= 0x10000 { + r1, r2 := utf16.EncodeRune(r) + fmt.Fprintf(&sb, "\\u%04x\\u%04x", r1, r2) + } else { + fmt.Fprintf(&sb, "\\u%04x", r) + } + i += size + default: + sb.WriteByte(ch) + i++ + } + } + return sb.String() +} + +// jsonDecodeList 对应 json_decode($s, true) ?: []:解析失败或为空返回空数组 +func jsonDecodeList(v any) any { + s := store.ToStr(v) + if s == "" { + return []any{} + } + var out any + if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil { + return []any{} + } + switch x := out.(type) { + case []any: + if len(x) == 0 { + return []any{} + } + case map[string]any: + if len(x) == 0 { + return []any{} + } + case bool: + if !x { + return []any{} + } + case float64: + if x == 0 { + return []any{} + } + case string: + if x == "" || x == "0" { + return []any{} + } + } + return out +} + +// countRaw 对应 $stmt->fetch()['cnt'] 原样输出(未经 intval,PHP 8.0 下为字符串) +func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any { + r, _ := c.DB.QueryOne(sql, args...) + if r == nil { + return nil + } + return r[col] +} + +// randInt 对应 random_int(min, max) +func randInt(min, max int) int { return min + rand.Intn(max-min+1) } diff --git a/past-go/internal/handler/couple.go b/past-go/internal/handler/couple.go new file mode 100644 index 0000000..2216145 --- /dev/null +++ b/past-go/internal/handler/couple.go @@ -0,0 +1,590 @@ +package handler + +import ( + "fmt" + "math" + "regexp" + "strings" + "time" + + "past/internal/geo" + "past/internal/httpapi" + "past/internal/store" +) + +var targetIDRe = regexp.MustCompile(`^520\d{3,}$`) + +func registerCouple(r *httpapi.Router) { + r.Handle("generate_invite_link", generateInviteLink) + r.Handle("check_invite_data_status", checkInviteDataStatus) + r.Handle("check_invite_code", checkInviteCode) + r.Handle("invite_by_phone", inviteByPhone) + r.Handle("invite_partner", invitePartner) + r.Handle("my_invitations", myInvitations) + r.Handle("respond_invitation", respondInvitation) + r.Handle("accept_share_invite", acceptShareInvite) + r.Handle("couple_info", coupleInfo) + r.Handle("update_couple_info", updateCoupleInfo) + r.Handle("unbind_request", unbindRequest) + r.Handle("unbind_respond", unbindRespond) + r.Handle("unbind_cancel", unbindCancel) + r.Handle("unbind_status", unbindStatus) + r.Handle("unbind_couple", unbindCouple) +} + +// parseDate 对应 new DateTime($str)(仅支持常见格式) +func parseDate(s string) (time.Time, error) { + s = strings.TrimSpace(s) + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02", "2006/01/02", "2006-1-2", "2006.01.02"} { + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("DateTime::__construct(): Failed to parse time string (%s)", s) +} + +// diffDays 对应 DateTime::diff()->days(整天数,绝对值) +func diffDays(a, b time.Time) int64 { + d := a.Sub(b) + if d < 0 { + d = -d + } + return int64(math.Floor(d.Hours() / 24)) +} + +// birthdayCountdown 按日历日计算下一次生日。生日当天仍是 0 天,不会因为当天零点已过而跳到下一年。 +func birthdayCountdown(nowT, bday time.Time, currentAge int64) (time.Time, int64, int64) { + today := time.Date(nowT.Year(), nowT.Month(), nowT.Day(), 0, 0, 0, 0, nowT.Location()) + next := time.Date(today.Year(), bday.Month(), bday.Day(), 0, 0, 0, 0, nowT.Location()) + if next.Before(today) { + next = next.AddDate(1, 0, 0) + } + // 用 UTC 中的日历日做差,避免当前时分秒导致“明天生日却显示 0 天”。 + todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) + nextDate := time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC) + daysLeft := int64(nextDate.Sub(todayDate) / (24 * time.Hour)) + return next, daysLeft, currentAge + 1 +} + +func generateInviteLink(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + if user != nil && user.Int("couple_id") != 0 { + return c.Err(400, "您已绑定情侣") + } + if ex, _ := c.DB.QueryOne("SELECT code FROM invite_links WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", uid); ex != nil { + return c.OK(M{"success": true, "code": ex.Str("code"), "url": "?invite=" + ex.Str("code")}) + } + code := randomHex(8) + if _, err := c.DB.Exec("INSERT INTO invite_links (user_id, code) VALUES (?, ?)", uid, code); err != nil { + return err + } + return c.OK(M{"success": true, "code": code, "url": "?invite=" + code}) +} + +func checkInviteDataStatus(c *httpapi.Ctx) error { + uid := c.UID() + cp, _ := c.DB.QueryOne("SELECT c.id, c.data_deleted, c.status FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid) + if cp != nil { + return c.OK(M{"has_pending_data": true, "msg": "请等待系统删除之前的情侣数据后再进行绑定操作"}) + } + return c.OK(M{"has_pending_data": false}) +} + +func checkInviteCode(c *httpapi.Ctx) error { + code := strings.TrimSpace(c.Q("code")) + if code == "" { + return c.Err(400, "缺少邀请码") + } + link, err := c.DB.QueryOne("SELECT il.*, u.nickname, u.avatar FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", code) + if err != nil { + return err + } + if link == nil { + return c.Err(404, "邀请链接无效或已使用") + } + // PHP 中 $link['inviter_couple_id'] ?? $link['couple_id'] 两个键都不存在,判断恒为假,此处保持一致 + return c.OK(M{"success": true, "inviter": M{"nickname": link["nickname"], "avatar": link["avatar"]}}) +} + +func inviteByPhone(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + target := c.InTrim("username") + if !c.InHas("username") { + target = c.InTrim("phone") + } + message := c.InTrim("message") + if target == "" { + return c.Err(400, "请输入对方用户名") + } + if pass, msg := c.App.Wechat.CheckText(message, userOpenId(c.DB, uid)); !pass { + return c.Err(400, msg) + } + me, _ := c.DB.QueryOne("SELECT username, couple_id FROM users WHERE id = ?", uid) + if me != nil && me.Int("couple_id") != 0 { + return c.Err(400, "您已绑定情侣") + } + if me != nil && me.Str("username") == target { + return c.Err(400, "不能邀请自己") + } + t, _ := c.DB.QueryOne("SELECT id, username, couple_id FROM users WHERE username = ?", target) + if t == nil { + return c.Err(400, "该用户不存在") + } + if t.Int("couple_id") != 0 { + return c.Err(400, "对方已绑定情侣") + } + if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil { + return c.Err(400, "已发送过邀请,等待对方回应") + } + if _, err := c.DB.Exec("INSERT INTO invitations (from_user_id, to_user_id, to_phone, message, type) VALUES (?, ?, ?, ?, 'username')", uid, t.Int("id"), target, message); err != nil { + return err + } + return c.OK(M{"success": true, "msg": "邀请已发送"}) +} + +func invitePartner(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + targetID := c.InTrim("target_user_id") + message := c.InTrim("message") + if targetID == "" { + return c.Err(400, "请输入对方ID") + } + if !targetIDRe.MatchString(targetID) { + return c.Err(400, "ID格式不正确,请输入6位数字ID") + } + me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender FROM users WHERE id = ?", uid) + if me != nil && me.Int("couple_id") != 0 { + return c.Err(400, "您已绑定情侣") + } + if me != nil && me.Str("user_id") == targetID { + return c.Err(400, "不能绑定自己") + } + if ex, _ := c.DB.QueryOne("SELECT c.id FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid); ex != nil { + return c.Err(400, "请等待系统删除之前的情侣数据后再进行绑定操作") + } + t, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, nickname, gender FROM users WHERE user_id = ?", targetID) + if t == nil { + return c.Err(400, "该ID不存在,请确认对方ID是否正确") + } + if t.Int("couple_id") != 0 { + return c.Err(400, "对方已绑定情侣") + } + if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil { + return c.Err(400, "已发送过邀请,等待对方回应") + } + id, err := c.DB.Insert("INSERT INTO invitations (from_user_id, to_user_id, message, type) VALUES (?, ?, ?, 'id')", uid, t.Int("id"), message) + if err != nil { + return err + } + return c.OK(M{"success": true, "invitation_id": fmt.Sprint(id), "msg": "邀请已发送,等待对方同意"}) +} + +func myInvitations(c *httpapi.Ctx) error { + uid := c.UID() + user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + var coupleID int64 + if user != nil { + coupleID = user.Int("couple_id") + } + received, err := c.DB.Query("SELECT i.*, u.username as from_username, u.nickname as from_nickname, u.avatar as from_avatar FROM invitations i JOIN users u ON i.from_user_id = u.id WHERE i.to_user_id = ? ORDER BY i.created_at DESC", uid) + if err != nil { + return err + } + sent, err := c.DB.Query("SELECT i.*, u.username as to_username, u.nickname as to_nickname, u.avatar as to_avatar FROM invitations i JOIN users u ON i.to_user_id = u.id WHERE i.from_user_id = ? ORDER BY i.created_at DESC", uid) + if err != nil { + return err + } + links, err := c.DB.Query("SELECT * FROM invite_links WHERE user_id = ? ORDER BY created_at DESC", uid) + if err != nil { + return err + } + return c.OK(M{"received": rows(received), "sent": rows(sent), "links": rows(links), "couple_id": coupleID}) +} + +// bindCouple 创建情侣关系(事务):couples 记录、空间 ID、双方 couple_id;返回 coupleId +func bindCouple(c *httpapi.Ctx, user1, user2 int64, extra func(tx *store.Tx) error) (int64, error) { + tx, err := c.DB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2) + if err != nil { + return 0, err + } + if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", generateCoupleSpaceId(coupleID), coupleID); err != nil { + return 0, err + } + if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, user1, user2); err != nil { + return 0, err + } + if extra != nil { + if err := extra(tx); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + ensureUserDirs(user1) + ensureUserDirs(user2) + return coupleID, nil +} + +func respondInvitation(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + invID := c.InInt("invitation_id") + accept := c.InBool("accept") + inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid) + if err != nil { + return err + } + if inv == nil { + return c.Err(404, "邀请不存在或已处理") + } + fromID := inv.Int("from_user_id") + rs, _ := c.DB.Query("SELECT couple_id FROM users WHERE id IN (?, ?)", uid, fromID) + for _, r := range rs { + if r.Int("couple_id") != 0 { + return c.Err(400, "已绑定情侣,无法接受") + } + } + if accept { + coupleID, err := bindCouple(c, fromID, uid, func(tx *store.Tx) error { + _, err := tx.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID) + return err + }) + if err != nil { + return err + } + createDefaultAlbums(c.DB, coupleID, uid) + return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID)}) + } + c.DB.Exec("UPDATE invitations SET status = 'rejected', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID) + return c.OK(M{"success": true, "msg": "已拒绝邀请"}) +} + +func acceptShareInvite(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + inviterDisplayID := c.InTrim("inviter_id") + if inviterDisplayID == "" { + return c.Err(400, "缺少邀请人ID") + } + me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender, nickname FROM users WHERE id = ?", uid) + if me != nil && me.Int("couple_id") != 0 { + return c.Err(400, "您已绑定情侣") + } + inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID) + if inviter == nil { + return c.Err(400, "邀请人不存在") + } + if inviter.Int("couple_id") != 0 { + return c.Err(400, "邀请人已绑定情侣") + } + if me != nil && me.Str("user_id") == inviter.Str("user_id") { + return c.Err(400, "不能绑定自己") + } + if me != nil && me.Str("gender") != "" && inviter.Str("gender") != "" && me.Str("gender") == inviter.Str("gender") { + return c.Err(400, "双方性别一致,仅支持异性绑定") + } + inviterID := inviter.Int("id") + existing, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE ((from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)) AND status = 'pending'", uid, inviterID, inviterID, uid) + if existing != nil { + c.DB.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", existing.Int("id")) + } + coupleID, err := bindCouple(c, uid, inviterID, func(tx *store.Tx) error { + if existing == nil { + _, err := tx.Exec("INSERT INTO invitations (from_user_id, to_user_id, status, responded_at, type) VALUES (?, ?, 'accepted', CURRENT_TIMESTAMP, 'share')", inviterID, uid) + return err + } + return nil + }) + if err != nil { + return err + } + createDefaultAlbums(c.DB, coupleID, uid) + return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID), "partner_nickname": inviter["nickname"]}) +} + +func coupleInfo(c *httpapi.Ctx) error { + cid := c.CoupleID() + uid := c.UID() + if cid == 0 { + user, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, created_at FROM users WHERE id = ?", uid) + regDate := today() + if user != nil { + ca := user.Str("created_at") + if len(ca) >= 10 { + regDate = ca[:10] + } else { + regDate = ca + } + } + var days int64 + if regDate != "" { + t, err := parseDate(regDate) + if err != nil { + return err + } + days = diffDays(time.Now(), t) + } + var p1 any + if user != nil { + p1 = user + } + return c.OK(M{"not_bound": true, "partner1": p1, "partner2": nil, "love_start_date": regDate, "days_together": days}) + } + info, err := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid) + if err != nil { + return err + } + if info == nil { + info = store.Row{} + } + partners, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, region, location, lat, lng FROM users WHERE couple_id = ? ORDER BY id ASC", cid) + if err != nil { + return err + } + info["partner1"] = nil + info["partner2"] = nil + if len(partners) > 0 { + info["partner1"] = partners[0] + } + if len(partners) > 1 { + info["partner2"] = partners[1] + } + info["distance"] = nil + if len(partners) >= 2 { + lat1, lng1 := store.ToFloat(partners[0]["lat"]), store.ToFloat(partners[0]["lng"]) + lat2, lng2 := store.ToFloat(partners[1]["lat"]), store.ToFloat(partners[1]["lng"]) + if lat1 != 0 && lng1 != 0 && lat2 != 0 && lng2 != 0 { + info["distance"] = geo.Haversine(lat1, lng1, lat2, lng2) + } + } + var partner store.Row + for _, p := range partners { + if p.Int("id") != uid { + partner = p + } + } + if partner != nil && partner.Str("birthday") != "" { + bday, err := parseDate(partner.Str("birthday")) + if err != nil { + return err + } + next, daysLeft, ageNext := birthdayCountdown(time.Now(), bday, partner.Int("age")) + info["partner_birthday_countdown"] = M{ + "birthday": partner["birthday"], + "next_date": next.Format("2006-01-02"), + "days_left": daysLeft, + "age_next": ageNext, + } + } + if store.Truthy(info["love_start_date"]) { + start, err := parseDate(info.Str("love_start_date")) + if err != nil { + return err + } + info["days_together"] = diffDays(start, time.Now()) + } else { + info["days_together"] = 0 + } + return c.OK(info) +} + +func updateCoupleInfo(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := c.CoupleID() + if cid == 0 { + return c.Err(400, "请先绑定情侣") + } + uid := c.UID() + if pass, msg := c.App.Wechat.CheckText(c.InTrim("love_story"), userOpenId(c.DB, uid)); !pass { + return c.Err(400, msg) + } + if _, err := c.DB.Exec("UPDATE couples SET love_start_date = ?, love_story = ? WHERE id = ?", c.InStr("love_start_date"), c.InStr("love_story"), cid); err != nil { + return err + } + if store.Truthy(c.InStr("love_start_date")) { + logActivity(c.DB, cid, uid, "couple_edit", "设置了恋爱纪念日", "") + } + return c.OK(M{"success": true}) +} + +func userCoupleID(c *httpapi.Ctx, uid int64) int64 { + u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + if u == nil { + return 0 + } + return u.Int("couple_id") +} + +func unbindRequest(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + cid := userCoupleID(c, uid) + if cid == 0 { + return c.Err(400, "未绑定情侣") + } + existing, _ := c.DB.QueryOne("SELECT id, status, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid) + if existing != nil { + if existing.Str("status") == "pending" { + return c.Err(400, "已有待处理的解绑请求,请等待对方回应") + } + if existing.Str("status") == "agreed" { + softUnbind(c.DB, cid) + return c.OK(M{"success": true, "msg": "对方已同意解绑,情侣关系已解除,所有数据已保留"}) + } + return nil + } + if _, err := c.DB.Exec("INSERT INTO unbind_requests (couple_id, requester_id, status, created_at) VALUES (?, ?, 'pending', NOW())", cid, uid); err != nil { + return err + } + logActivity(c.DB, cid, uid, "unbind_request", "发起了解除情侣绑定请求", "") + return c.OK(M{"success": true, "msg": "解绑请求已发送,等待对方确认(72小时内有效)"}) +} + +func unbindRespond(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + // $input = json_decode(...) ?: $_POST + agree := c.InBool("agree") + if len(c.Input()) == 0 { + agree = store.Truthy(c.Form("agree")) + } + cid := userCoupleID(c, uid) + if cid == 0 { + return c.Err(400, "未绑定情侣") + } + req, _ := c.DB.QueryOne("SELECT id, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status = 'pending' AND requester_id != ? ORDER BY id DESC LIMIT 1", cid, uid) + if req == nil { + return c.Err(400, "没有待处理的解绑请求") + } + if created, err := parseDate(req.Str("created_at")); err == nil && time.Since(created) > 72*time.Hour { + c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id")) + return c.Err(400, "解绑请求已超过72小时,已自动取消") + } + if agree { + c.DB.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE id = ?", req.Int("id")) + logActivity(c.DB, cid, uid, "unbind_agreed", "同意了解除情侣绑定请求", "") + softUnbind(c.DB, cid) + return c.OK(M{"success": true, "msg": "已同意解绑,情侣关系已解除,所有数据已保留"}) + } + c.DB.Exec("UPDATE unbind_requests SET status = 'rejected', responded_at = NOW() WHERE id = ?", req.Int("id")) + logActivity(c.DB, cid, uid, "unbind_rejected", "拒绝了解除情侣绑定请求", "") + return c.OK(M{"success": true, "msg": "已拒绝解绑请求"}) +} + +func unbindCancel(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + cid := userCoupleID(c, uid) + if cid == 0 { + return c.Err(400, "未绑定情侣") + } + req, _ := c.DB.QueryOne("SELECT id, status FROM unbind_requests WHERE couple_id = ? AND requester_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid, uid) + if req == nil { + return c.Err(400, "没有可撤销的解绑请求") + } + c.DB.Exec("UPDATE unbind_requests SET status = 'cancelled', responded_at = NOW() WHERE id = ?", req.Int("id")) + logActivity(c.DB, cid, uid, "unbind_cancelled", "撤销了解除情侣绑定请求", "") + return c.OK(M{"success": true, "msg": "已撤销解绑请求"}) +} + +func unbindStatus(c *httpapi.Ctx) error { + uid := c.UID() + // $cid 来源不同类型不同:来自 users 行为字符串;来自 unbind_requests 反查时经过 intval 为整数 + var cidVal any + u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + cid := int64(0) + if u != nil && u.Int("couple_id") != 0 { + cid, cidVal = u.Int("couple_id"), u["couple_id"] + } + if cid == 0 { + found, _ := c.DB.QueryOne("SELECT ur.couple_id FROM unbind_requests ur WHERE ur.requester_id = ? OR ur.id IN (SELECT ur2.id FROM unbind_requests ur2 JOIN couples c ON ur2.couple_id = c.id WHERE (c.user1_id = ? OR c.user2_id = ?)) ORDER BY ur.id DESC LIMIT 1", uid, uid, uid) + if found != nil { + cid, cidVal = found.Int("couple_id"), found.Int("couple_id") + } + } + if cid == 0 { + return c.OK(M{"status": "none", "msg": "未绑定情侣"}) + } + req, err := c.DB.QueryOne("SELECT id, requester_id, status, created_at, responded_at FROM unbind_requests WHERE couple_id = ? ORDER BY id DESC LIMIT 1", cid) + if err != nil { + return err + } + dataDeleted := int64(0) + if cd, _ := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid); cd != nil { + dataDeleted = cd.Int("data_deleted") + } + if req == nil { + return c.OK(M{"status": "none", "msg": "无解绑请求", "couple_id": cidVal, "data_deleted": dataDeleted}) + } + if req.Str("status") == "pending" { + created, _ := parseDate(req.Str("created_at")) + elapsed := int64(time.Since(created).Seconds()) + remaining := int64(72*3600) - elapsed + if remaining < 0 { + remaining = 0 + } + if elapsed > 72*3600 { + c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id")) + req["status"] = "expired" + } else { + req["remaining_seconds"] = remaining + } + } + req["is_requester"] = req.Int("requester_id") == uid + requester, _ := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", req.Int("requester_id")) + name := "未知" + if requester != nil { + name = requester.Str("nickname") + if name == "" { + name = requester.Str("username") + } + } + req["requester_name"] = name + req["couple_id"] = cid + req["data_deleted"] = dataDeleted + return c.OK(req) +} + +func unbindCouple(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + cid := userCoupleID(c, uid) + if cid == 0 { + return c.Err(400, "未绑定情侣") + } + if ag, _ := c.DB.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); ag == nil { + return c.Err(400, "没有已确认的解绑请求,请先发起解绑") + } + softUnbind(c.DB, cid) + return c.OK(M{"success": true, "msg": "已解除绑定"}) +} diff --git a/past-go/internal/handler/date_test.go b/past-go/internal/handler/date_test.go new file mode 100644 index 0000000..a71d517 --- /dev/null +++ b/past-go/internal/handler/date_test.go @@ -0,0 +1,30 @@ +package handler + +import ( + "testing" + "time" +) + +func TestBirthdayCountdown(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*60*60) + tests := []struct { + name string + now time.Time + birthday time.Time + wantDate string + wantDays int64 + wantAge int64 + }{ + {"birthday today", time.Date(2026, 9, 19, 18, 30, 0, 0, loc), time.Date(2000, 9, 19, 0, 0, 0, 0, loc), "2026-09-19", 0, 27}, + {"birthday tomorrow", time.Date(2026, 9, 19, 23, 59, 0, 0, loc), time.Date(2000, 9, 20, 0, 0, 0, 0, loc), "2026-09-20", 1, 27}, + {"birthday passed", time.Date(2026, 9, 19, 10, 0, 0, 0, loc), time.Date(2000, 1, 2, 0, 0, 0, 0, loc), "2027-01-02", 105, 27}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + next, days, age := birthdayCountdown(tt.now, tt.birthday, 26) + if got := next.Format("2006-01-02"); got != tt.wantDate || days != tt.wantDays || age != tt.wantAge { + t.Fatalf("got date=%s days=%d age=%d; want date=%s days=%d age=%d", got, days, age, tt.wantDate, tt.wantDays, tt.wantAge) + } + }) + } +} diff --git a/past-go/internal/handler/diary.go b/past-go/internal/handler/diary.go new file mode 100644 index 0000000..58615f1 --- /dev/null +++ b/past-go/internal/handler/diary.go @@ -0,0 +1,325 @@ +package handler + +import ( + "fmt" + "regexp" + "strings" + + "past/internal/httpapi" + "past/internal/store" +) + +// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义 +const mediaBase = "https://past.sundynix.cn/backend/" + +var tagRe = regexp.MustCompile(`<[^>]*>`) + +// stripTags 近似 PHP strip_tags +func stripTags(s string) string { return tagRe.ReplaceAllString(s, "") } + +// mbSubstr 对应 mb_substr($s, 0, n) +func mbSubstr(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + } + return s +} + +// avatarURL 对应评论里的 avatar_url 拼接 +func avatarURL(avatar string) string { + if avatar == "" { + return "" + } + if strings.HasPrefix(avatar, "http") { + return avatar + } + return mediaBase + avatar +} + +// diaryDateOrFallback 对应 PHP !empty($input['diary_date']) 的日期默认逻辑。 +// 缺失、null、空字符串和纯空白都不允许进入 diary_date NOT NULL 列。 +func diaryDateOrFallback(value any, fallback string) string { + if date := strings.TrimSpace(store.ToStr(value)); date != "" { + return date + } + return fallback +} + +func registerDiary(r *httpapi.Router) { + r.Handle("diary_list", diaryList) + r.Handle("diary_add", diaryAdd) + r.Handle("diary_update", diaryUpdate) + r.Handle("diary_delete", diaryDelete) + r.Handle("diary_detail", diaryDetail) + r.Handle("diary_comment_list", diaryCommentList) + r.Handle("diary_comment_add", diaryCommentAdd) + r.Handle("diary_comment_delete", diaryCommentDelete) +} + +func diaryList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + demo := demoData("diary") + return c.OK(M{"items": demo, "total": len(demo)}) + } + page := c.QInt("page") + if !c.QHas("page") { + page = 1 + } + if page < 1 { + page = 1 + } + pageSize := int64(10) + if c.QHas("page_size") { + pageSize = c.QInt("page_size") + } + if pageSize < 1 { + pageSize = 1 + } + if pageSize > 50 { + pageSize = 50 + } + offset := (page - 1) * pageSize + cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diaries WHERE created_by IN (?, ?)", ids[0], ids[1]) + if err != nil { + return err + } + uid := c.UID() + items, err := c.DB.Query(`SELECT d.*, + (SELECT COUNT(*) FROM diary_comments WHERE diary_id = d.id) as comment_count, + (SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id) as like_count, + (SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id) as favorite_count, + (SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id AND user_id = ?) > 0 as is_liked, + (SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id AND user_id = ?) > 0 as is_favorited + FROM diaries d WHERE d.created_by IN (?, ?) ORDER BY d.diary_date DESC, d.created_at DESC LIMIT ? OFFSET ?`, + uid, uid, ids[0], ids[1], pageSize, offset) + if err != nil { + return err + } + for _, it := range items { + it["images"] = jsonDecodeList(it["images"]) + it["is_liked"] = store.Truthy(it["is_liked"]) + it["is_favorited"] = store.Truthy(it["is_favorited"]) + } + return c.OK(M{"items": rows(items), "total": cnt["cnt"], "page": page, "page_size": pageSize}) +} + +func diaryAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + uid := c.UID() + diaryDate := diaryDateOrFallback(c.In("diary_date"), today()) + images := inOr(c, "images", []any{}) + title := inOr(c, "title", "") + content := inOr(c, "content", "") + titleS, contentS := store.ToStr(title), store.ToStr(content) + region := c.InTrim("region") + lat, lng := c.InFloat("lat"), c.InFloat("lng") + location := c.InTrim("location") + if lat != 0 && lng != 0 && location == "" { + location = c.App.Geo.ReverseGeocode(lat, lng) + } + if err := checkTextOr400(c, strings.TrimSpace(titleS+" "+contentS)); err != nil { + return err + } + id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid) + if err != nil { + return err + } + imgCount := 0 + if l, ok := images.([]any); ok { + imgCount = len(l) + } + desc := "发表了日记" + if store.Truthy(titleS) { + desc += "「" + titleS + "」" + } + if imgCount > 0 { + desc += fmt.Sprintf(",上传了%d张图片", imgCount) + } + actID := logActivity(c.DB, cid, uid, "diary_create", desc, "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + displayTitle := "一篇新日记" + if store.Truthy(titleS) { + displayTitle = titleS + } + displayTitle = mbTruncate(displayTitle, 20) + displayContent := "TA写了一些心里话" + if store.Truthy(contentS) { + displayContent = mbSubstr(stripTags(contentS), 50) + } + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": displayTitle, "thing2": displayContent, "date3": today(), "thing4": "快去看看吧 💕", + }, "pages/diary/diary", actID, uid) + } + return c.OK(M{"success": true, "id": fmt.Sprint(id)}) +} + +func diaryUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + images := inOr(c, "images", []any{}) + // 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期; + // 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。 + diaryDate := strings.TrimSpace(c.InStr("diary_date")) + dateExpr := "COALESCE(NULLIF(?, ''), NULLIF(diary_date, ''), ?)" + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { + return err + } + if c.InHas("lat") { + _, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)", + c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), c.In("id"), ids[0], ids[1]) + } else { + _, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ? WHERE id = ? AND created_by IN (?, ?)", + c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.In("id"), ids[0], ids[1]) + } + if err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func diaryDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func countOf(c *httpapi.Ctx, sql string, args ...any) int64 { + r, _ := c.DB.QueryOne(sql, args...) + if r == nil { + return 0 + } + return r.Int("cnt") +} + +func diaryDetail(c *httpapi.Ctx) error { + id := c.QInt("id") + if id == 0 { + return c.Err(400, "缺少日记ID") + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + uid := c.UID() + item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return err + } + if item == nil { + return c.Err(404, "日记不存在") + } + item["images"] = jsonDecodeList(item["images"]) + item["comment_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_comments WHERE diary_id = ?", id) + if _, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id); err != nil { + return err + } + item["like_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id) + item["favorite_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ?", id) + item["is_liked"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ? AND user_id = ?", id, uid) > 0 + item["is_favorited"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ? AND user_id = ?", id, uid) > 0 + return c.OK(item) +} + +func diaryCommentList(c *httpapi.Ctx) error { + diaryID := c.QInt("diary_id") + if diaryID == 0 { + return c.Err(400, "缺少日记ID") + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at ASC", diaryID, ids[0], ids[1]) + if err != nil { + return err + } + for _, cm := range comments { + cm["avatar_url"] = avatarURL(cm.Str("avatar")) + } + return c.OK(rows(comments)) +} + +func diaryCommentAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + uid := c.UID() + if uid == 0 { + return c.Err(401, "请先登录") + } + diaryID := c.InInt("diary_id") + content := c.InTrim("content") + if diaryID == 0 { + return c.Err(400, "缺少日记ID") + } + if !store.Truthy(content) { + return c.Err(400, "请输入评论内容") + } + if err := checkTextOr400(c, content); err != nil { + return err + } + if d, _ := c.DB.QueryOne("SELECT id FROM diaries WHERE id = ? AND couple_id = ?", diaryID, cid); d == nil { + return c.Err(404, "日记不存在") + } + parentID := c.InInt("parent_id") + var replyToUserID any + if c.InHas("reply_to_user_id") { + replyToUserID = c.InInt("reply_to_user_id") + } + commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)", + diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username")) + if err != nil { + return err + } + comment, _ := c.DB.QueryOne("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.id = ?", commentID) + var out any = false + if comment != nil { + comment["avatar_url"] = avatarURL(comment.Str("avatar")) + out = comment + } + return c.OK(M{"success": true, "comment": out}) +} + +func diaryCommentDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + if uid == 0 { + return c.Err(401, "请先登录") + } + commentID := c.InInt("id") + if commentID == 0 { + return c.Err(400, "缺少评论ID") + } + if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil { + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/diary_test.go b/past-go/internal/handler/diary_test.go new file mode 100644 index 0000000..4a4362c --- /dev/null +++ b/past-go/internal/handler/diary_test.go @@ -0,0 +1,24 @@ +package handler + +import "testing" + +func TestDiaryDateOrFallback(t *testing.T) { + tests := []struct { + name string + value any + fallback string + want string + }{ + {"missing", nil, "2026-09-19", "2026-09-19"}, + {"empty", "", "2026-09-19", "2026-09-19"}, + {"whitespace", " ", "2026-09-19", "2026-09-19"}, + {"provided", " 2026-09-18 ", "2026-09-19", "2026-09-18"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := diaryDateOrFallback(tt.value, tt.fallback); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/past-go/internal/handler/jsonenc_test.go b/past-go/internal/handler/jsonenc_test.go new file mode 100644 index 0000000..ae0cb81 --- /dev/null +++ b/past-go/internal/handler/jsonenc_test.go @@ -0,0 +1,15 @@ +package handler + +import "testing" + +// 期望值来自 PHP 8.2: echo json_encode(["uploads/x.jpg","中文😊","a\"b\\c\n<>&"]); +func TestPhpEscapeJSON(t *testing.T) { + got := jsonEncode([]any{"uploads/x.jpg", "中文😊", "a\"b\\c\n<>&"}) + want := "[\"uploads\\/x.jpg\",\"\\u4e2d\\u6587\\ud83d\\ude0a\",\"a\\\"b\\\\c\\n<>&\"]" + if got != want { + t.Fatalf("got %s want %s", got, want) + } + if jsonDecodeList(got).([]any)[1] != "中文😊" { + t.Fatalf("round-trip failed") + } +} diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go new file mode 100644 index 0000000..0218b55 --- /dev/null +++ b/past-go/internal/handler/misc.go @@ -0,0 +1,294 @@ +package handler + +import ( + "math" + "strings" + "time" + + "past/internal/httpapi" + "past/internal/store" + "past/internal/upload" +) + +// ========== 通用上传 / 背景图 ========== + +func registerBg(r *httpapi.Router) { + r.Handle("upload", uploadGeneric) + r.Handle("get_bg_config", getBgConfig) + r.Handle("set_bg_image", setBgImage) + r.Handle("delete_bg_image", deleteBgImage) +} + +// uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败) +func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error { + if res.Path != "" { + return onSuccess(res.Path) + } + if strings.Contains(res.Error, "违规") { + return c.ErrCode(400, res.Error, upload.ErrHandleFailed) + } + return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed) +} + +func uploadGeneric(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + subDir := "photos" + if c.FormHas("sub_dir") { + subDir = c.Form("sub_dir") + } + uid := c.UID() + if uid == 0 { + return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) + } + prefix := upload.UserFilePath(uid, subDir) + var res *upload.Result + for _, f := range []string{"file", "avatar", "photo", "image"} { + if fh := c.File(f); fh != nil { + r := up.Handle(fh, "", prefix) + res = &r + break + } + } + if res == nil { + // 兼容某些 Android 端:原始请求体 + if raw := c.Body(); len(raw) > 100 { + r := up.HandleBytes(raw, "upload_"+time.Now().Format("20060102150405")+".jpg", "", prefix) + res = &r + } + } + if res == nil { + return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) + } + return uploadResult(c, *res, func(path string) error { + return c.OK(M{"success": true, "path": path}) + }) +} + +func settingValue(c *httpapi.Ctx, key string) (string, bool) { + row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil { + return "", false + } + return row.Str("value"), true +} + +func setSetting(c *httpapi.Ctx, key string, value any) error { + _, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", key, value, value) + return err +} + +func bgKeys(cid int64) (string, string) { + if cid != 0 { + return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid) + } + return "bg_global", "bgver_global" +} + +func getBgConfig(c *httpapi.Ctx) error { + bgKey, verKey := bgKeys(c.CoupleID()) + img, _ := settingValue(c, bgKey) + ver, _ := settingValue(c, verKey) + return c.OK(M{"image": img, "version": store.ToInt(ver)}) +} + +func setBgImage(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + cid := c.CoupleID() + if cid == 0 { + return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound) + } + couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers") + bgKey, verKey := bgKeys(cid) + nextVersion := func() int64 { + if old, ok := settingValue(c, verKey); ok { + return store.ToInt(old) + 1 + } + return 1 + } + saveBg := func(path string, version int64) error { + if err := setSetting(c, bgKey, path); err != nil { + return err + } + if err := setSetting(c, verKey, version); err != nil { + return err + } + return c.OK(M{"success": true, "path": path, "version": version}) + } + fh := c.File("bg") + if fh == nil { + fh = c.File("file") + } + if fh != nil { + version := nextVersion() + return uploadResult(c, up.Handle(fh, "", couplePath), func(path string) error { return saveBg(path, version) }) + } + if raw := c.Body(); len(raw) > 100 { + res := up.SaveRaw(raw, "bg_", "jpg", "", couplePath) + if res.Path != "" { + return saveBg(res.Path, nextVersion()) + } + if strings.Contains(res.Error, "违规") { + return c.ErrCode(400, res.Error, upload.ErrHandleFailed) + } + return c.ErrCode(400, "原始数据保存失败", upload.ErrRawBodySave) + } + return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) +} + +func deleteBgImage(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + bgKey, verKey := bgKeys(cid) + if v, ok := settingValue(c, bgKey); ok && v != "" { + up.Remove(v) + } + if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", bgKey, verKey); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +// ========== 站点公开 ========== + +func registerSite(r *httpapi.Router) { + r.Handle("site_info", siteInfo) + r.Handle("site_stats", siteStats) +} + +func siteInfo(c *httpapi.Ctx) error { + rs, err := c.DB.Query("SELECT `key`, `value` FROM settings") + if err != nil { + return err + } + if len(rs) == 0 { + return c.OK([]any{}) // PHP 空数组 → [] + } + out := M{} + for _, r := range rs { + out[r.Str("key")] = r["value"] + } + return c.OK(out) +} + +func siteStats(c *httpapi.Ctx) error { + raw := func(t string) any { return countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM "+t) } + cnt := func(t string) int64 { return countOf(c, "SELECT COUNT(*) as cnt FROM "+t) } + couples, users := raw("couples"), raw("users") + memories := cnt("timeline") + cnt("photos") + cnt("diaries") + var days any = 0 + if e, _ := c.DB.QueryOne("SELECT MIN(created_at) as d FROM users"); e != nil && e.Str("d") != "" { + if t, err := parseDate(e.Str("d")); err == nil { + d := math.Floor(time.Since(t).Seconds() / 86400) + if d < 1 { + d = 1 + } + days = d + } + } + return c.OK(M{"couples": couples, "users": users, "memories": memories, "days": days}) +} + +// ========== 操作记录 / 订阅 ========== + +func registerActivity(r *httpapi.Router) { + r.Handle("activity_log_list", activityLogList) +} + +func registerSubscribe(r *httpapi.Router) { + r.Handle("subscribe_template", subscribeTemplate) + r.Handle("unsubscribe_template", unsubscribeTemplate) + r.Handle("my_subscribe_templates", mySubscribeTemplates) +} + +// pageParams 对应 max(1, intval(page)) 与 min(maxSize, max(1, intval(page_size))) +func pageParams(c *httpapi.Ctx, defSize, maxSize int64) (page, pageSize, offset int64) { + page = 1 + if c.QHas("page") { + page = c.QInt("page") + } + if page < 1 { + page = 1 + } + pageSize = defSize + if c.QHas("page_size") { + pageSize = c.QInt("page_size") + } + if pageSize < 1 { + pageSize = 1 + } + if pageSize > maxSize { + pageSize = maxSize + } + return page, pageSize, (page - 1) * pageSize +} + +func activityLogList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK([]any{}) + } + page, pageSize, offset := pageParams(c, 6, 100) + totalRaw := countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM activity_log WHERE user_id IN (?, ?)", ids[0], ids[1]) + total := store.ToInt(totalRaw) + items, err := c.DB.Query("SELECT al.*, u.nickname, u.avatar, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.user_id IN (?, ?) ORDER BY al.created_at DESC LIMIT ? OFFSET ?", ids[0], ids[1], pageSize, offset) + if err != nil { + return err + } + return c.OK(M{"items": rows(items), "total": totalRaw, "page": page, "page_size": pageSize, "total_pages": math.Ceil(float64(total) / float64(pageSize))}) +} + +func subscribeTemplate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id") + if !store.Truthy(templateType) || !store.Truthy(templateID) { + return c.Err(400, "缺少参数") + } + if _, ok := c.App.Wechat.Templates()[templateType]; !ok { + return c.Err(400, "不支持的模板类型") + } + if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE template_id = VALUES(template_id)", uid, templateID, templateType); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func unsubscribeTemplate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + templateType := c.InTrim("template_type") + if !store.Truthy(templateType) { + return c.Err(400, "缺少参数") + } + if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", c.UID(), templateType); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func mySubscribeTemplates(c *httpapi.Ctx) error { + rs, err := c.DB.Query("SELECT template_type, template_id FROM subscribe_templates WHERE user_id = ?", c.UID()) + if err != nil { + return err + } + if len(rs) == 0 { + return c.OK(M{"templates": []any{}}) // PHP 空数组 → [] + } + out := M{} + for _, r := range rs { + out[r.Str("template_type")] = r["template_id"] + } + return c.OK(M{"templates": out}) +} diff --git a/past-go/internal/handler/period.go b/past-go/internal/handler/period.go new file mode 100644 index 0000000..7de60ff --- /dev/null +++ b/past-go/internal/handler/period.go @@ -0,0 +1,558 @@ +package handler + +import ( + "fmt" + "strings" + + "past/internal/httpapi" + "past/internal/store" +) + +func registerPeriod(r *httpapi.Router) { + r.Handle("period_list", periodList) + r.Handle("period_add", periodProtected(periodAdd)) + r.Handle("period_update", periodProtected(periodUpdate)) + r.Handle("period_delete", periodProtected(periodDelete)) + r.Handle("period_visible_get", periodVisibleGet) + r.Handle("period_visible_set", periodVisibleSet) + r.Handle("period_source_select", periodSourceSelect) + r.Handle("period_daily_save", periodProtected(periodDailySave)) + r.Handle("period_daily_get", periodProtected(periodDailyGet)) + r.Handle("period_sex_add", periodProtected(periodSexAdd)) + r.Handle("period_sex_delete", periodProtected(periodSexDelete)) + r.Handle("period_sex_list", periodProtected(periodSexList)) +} + +func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc { + return func(c *httpapi.Ctx) error { + if err := requirePeriodAccess(c); err != nil { + return err + } + if required, _, _, err := periodSourceState(c); err != nil { + return err + } else if required { + return c.Err(409, "请先选择姨姨记录来源") + } + return next(c) + } +} + +// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0 +func cidOrNil(c *httpapi.Ctx) any { + if cid := c.CoupleID(); cid != 0 { + return cid + } + return nil +} + +// coupleCond 根据 cid(可能为 nil)构造 couple_id 过滤条件与参数 +func coupleCond(cid any) (string, []any) { + if cid == nil { + return "couple_id IS NULL", nil + } + return "couple_id = ?", []any{cid} +} + +// 每人独立控制自己是否向伴侣开放同一份姨姨数据。 +func periodVisible(c *httpapi.Ctx, uid int64) int64 { + row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid) + if row == nil { + return 0 + } + return row.Int("period_visible") +} + +func emptyPeriod(visible int64) M { + return M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{}, "period_visible": visible, "can_access": false} +} + +type periodAccess struct { + uid, cid, partnerID int64 + visible, partnerVisible int64 +} + +func currentPeriodAccess(c *httpapi.Ctx) periodAccess { + uid := c.UID() + cid := c.CoupleID() + access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1} + if cid != 0 { + access.partnerVisible = 0 + partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid) + if partner != nil { + access.partnerID = partner.Int("id") + access.partnerVisible = partner.Int("period_visible") + } + } + return access +} + +func (a periodAccess) canAccess() bool { + return a.partnerVisible != 0 +} + +func requirePeriodAccess(c *httpapi.Ctx) error { + if !currentPeriodAccess(c).canAccess() { + return c.Err(403, "对方已关闭姨姨可见权限") + } + return nil +} + +func periodList(c *httpapi.Ctx) error { + access := currentPeriodAccess(c) + if !access.canAccess() { + return c.OK(emptyPeriod(access.visible)) + } + selectionRequired, me, partner, err := periodSourceState(c) + if err != nil { + return err + } + if selectionRequired { + myPreview, err := periodSourcePreview(c.DB, me) + if err != nil { + return err + } + partnerPreview, err := periodSourcePreview(c.DB, partner) + if err != nil { + return err + } + return c.OK(M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{}, + "period_visible": access.visible, "can_access": true, "has_couple": true, + "selection_required": true, "my_user_id": me, "partner_user_id": partner, + "my_preview": myPreview, "partner_preview": partnerPreview}) + } + + // 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。 + var where string + var args []any + if access.cid != 0 { + where = "couple_id = ?" + args = []any{access.cid} + } else { + where = "couple_id IS NULL AND user_id = ?" + args = []any{access.uid} + } + + cycles, err := c.DB.Query("SELECT * FROM period_records WHERE "+where+" ORDER BY start_date DESC", args...) + if err != nil { + return err + } + daily, err := c.DB.Query("SELECT * FROM period_daily_records WHERE "+where+" ORDER BY record_date DESC", args...) + if err != nil { + return err + } + sex, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+where+" ORDER BY record_date DESC, record_time ASC", args...) + if err != nil { + return err + } + return c.OK(M{"cycles": rows(cycles), "daily": rows(daily), "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0}) +} + +func periodAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date") + if !store.Truthy(startDate) || !store.Truthy(endDate) { + return c.Err(400, "请选择日期") + } + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil { + return err + } + cycleLen, periodLen := int64(28), int64(5) + if c.InHas("cycle_length") { + cycleLen = c.InInt("cycle_length") + } + if c.InHas("period_length") { + periodLen = c.InInt("period_length") + } + recID, err := c.DB.Insert("INSERT INTO period_records (couple_id, user_id, start_date, end_date, cycle_length, period_length, symptoms, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + cid, uid, startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", "")) + if err != nil { + return err + } + // 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日) + start, err := parseDate(startDate) + if err != nil { + return err + } + end, err := parseDate(endDate) + if err != nil { + return err + } + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil { + return err + } + } + actID, err := logActivityErr(c, cid, uid, "period_add", "记录了经期("+startDate+" ~ "+endDate+")") + if err != nil { + return err + } + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + if periodVisible(c, uid) != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "经期记录有更新", + "thing2": fmt.Sprintf("TA记录了经期(%d天),记得多关心哦 🌸", periodLen), + "date3": startDate, + "thing4": "点击查看详情", + }, "pages/period/period", actID, uid) + } + } + // PHP 返回的是 logActivity 之后的 lastInsertId(activity_log 的 id);Go 返回真实记录 id + return c.OK(M{"success": true, "id": fmt.Sprint(recID)}) +} + +// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。 +// 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。 +func logActivityErr(c *httpapi.Ctx, coupleID any, userID int64, actionType, description string) (int64, error) { + if coupleID == nil { + return 0, nil + } + return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())", + coupleID, userID, actionType, description, "") +} + +func periodUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + id := c.InInt("id") + if id == 0 { + return c.Err(400, "缺少记录ID") + } + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil { + return err + } + cycleLen, periodLen := int64(28), int64(5) + if c.InHas("cycle_length") { + cycleLen = c.InInt("cycle_length") + } + if c.InHas("period_length") { + periodLen = c.InInt("period_length") + } + startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date") + start, err := parseDate(startDate) + if err != nil { + return c.Err(400, "开始日期无效") + } + end, err := parseDate(endDate) + if err != nil || end.Before(start) { + return c.Err(400, "结束日期无效") + } + cond, condArgs := coupleCond(cid) + if cid == nil { + cond += " AND user_id = ?" + condArgs = append(condArgs, uid) + } + tx, err := c.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + oldArgs := append([]any{id}, condArgs...) + old, err := tx.QueryOne("SELECT start_date, end_date FROM period_records WHERE id = ? AND "+cond+" FOR UPDATE", oldArgs...) + if err != nil { + return err + } + if old == nil { + return c.Err(404, "周期记录不存在") + } + args := []any{startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""), id} + args = append(args, condArgs...) + if _, err := tx.Exec("UPDATE period_records SET start_date = ?, end_date = ?, cycle_length = ?, period_length = ?, symptoms = ?, note = ? WHERE id = ? AND "+cond, args...); err != nil { + return err + } + // 同步日历标记:去掉旧区间中不再属于任何周期的日期,再标记新区间。 + oldStart, err := parseDate(old.Str("start_date")) + if err != nil { + return err + } + oldEnd, err := parseDate(old.Str("end_date")) + if err != nil { + return err + } + for d := oldStart; !d.After(oldEnd); d = d.AddDate(0, 0, 1) { + if !d.Before(start) && !d.After(end) { + continue + } + date := d.Format("2006-01-02") + checkArgs := append(append([]any{}, condArgs...), date, date) + other, err := tx.QueryOne("SELECT id FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ? LIMIT 1", checkArgs...) + if err != nil { + return err + } + if other == nil { + clearArgs := append(append([]any{}, condArgs...), date) + if _, err := tx.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date = ?", clearArgs...); err != nil { + return err + } + } + } + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + date := d.Format("2006-01-02") + if cid == nil { + // MySQL 的 UNIQUE 索引允许多个 NULL;未绑定记录必须按用户和日期查找。 + existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, date) + if err != nil { + return err + } + if existing != nil { + if _, err := tx.Exec("UPDATE period_daily_records SET period = '1' WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", uid, date); err != nil { + return err + } + continue + } + } + if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, date); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func periodDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + id := c.InInt("id") + startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date") + cond, condArgs := coupleCond(cid) + if cid == nil { + cond += " AND user_id = ?" + condArgs = append(condArgs, uid) + } + if id != 0 { + delArgs := append([]any{id}, condArgs...) + if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil { + return err + } + } else if store.Truthy(startDate) && store.Truthy(endDate) { + delArgs := append(append([]any{}, condArgs...), startDate, endDate) + if _, err := c.DB.Exec("DELETE FROM period_records WHERE "+cond+" AND start_date = ? AND end_date = ?", delArgs...); err != nil { + return err + } + } + if store.Truthy(startDate) && store.Truthy(endDate) { + updArgs := append(append([]any{}, condArgs...), startDate, endDate) + if _, err := c.DB.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date >= ? AND record_date <= ?", updArgs...); err != nil { + return err + } + } + if _, err := logActivityErr(c, cid, uid, "period_delete", "删除了经期周期记录"); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func periodVisibleGet(c *httpapi.Ctx) error { + access := currentPeriodAccess(c) + return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != 0}) +} + +func periodVisibleSet(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + visible := c.InInt("period_visible") + if visible != 0 && visible != 1 { + return c.Err(400, "可见权限只能为 0 或 1") + } + if _, err := c.DB.Exec("UPDATE users SET period_visible = ? WHERE id = ?", visible, c.UID()); err != nil { + return err + } + return c.OK(M{"success": true, "period_visible": visible}) +} + +func periodDailySave(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + recordDate := c.InTrim("record_date") + if !store.Truthy(recordDate) { + return c.Err(400, "请选择日期") + } + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("mood")+" "+c.InStr("discharge")+" "+c.InStr("note"))); err != nil { + return err + } + // $input['temperature'] !== '' && !== null ? floatval(...) : null + numOrNil := func(key string) any { + v := c.In(key) + if v == nil || v == "" { + return nil + } + return store.ToFloat(v) + } + values := []any{store.ToStr(inOr(c, "period", "0")), store.ToStr(inOr(c, "sex", "0")), + inOr(c, "symptoms", ""), inOr(c, "mood", ""), inOr(c, "discharge", ""), + numOrNil("temperature"), numOrNil("weight"), inOr(c, "note", ""), inOr(c, "period_detail", "")} + if cid == nil { + // UNIQUE(record_date, couple_id) 对 NULL 不生效;按本人和日期更新已有行。 + existing, err := c.DB.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, recordDate) + if err != nil { + return err + } + if existing != nil { + args := append(append([]any{}, values...), uid, recordDate) + if _, err := c.DB.Exec("UPDATE period_daily_records SET period = ?, sex = ?, symptoms = ?, mood = ?, discharge = ?, temperature = ?, weight = ?, note = ?, period_detail = ?, updated_at = NOW() WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", args...); err != nil { + return err + } + } else { + args := append([]any{nil, uid, recordDate}, values...) + if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", args...); err != nil { + return err + } + } + } else { + args := append([]any{cid, uid, recordDate}, values...) + if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE period = VALUES(period), sex = VALUES(sex), symptoms = VALUES(symptoms), mood = VALUES(mood), discharge = VALUES(discharge), temperature = VALUES(temperature), weight = VALUES(weight), note = VALUES(note), period_detail = VALUES(period_detail), updated_at = NOW()", args...); err != nil { + return err + } + } + // 若本次将月经标记设为无,检查所属周期是否还有月经记录 + np := inOr(c, "period", "0") + if np == "0" || np == int64(0) || np == "" { + cond, condArgs := coupleCond(cid) + if cid == nil { + cond += " AND user_id = ?" + condArgs = append(condArgs, uid) + } + cycArgs := append(append([]any{}, condArgs...), recordDate, recordDate) + cycles, err := c.DB.Query("SELECT id, start_date, end_date FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ?", cycArgs...) + if err != nil { + return err + } + for _, cy := range cycles { + cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"]) + cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > 0", cntArgs...) + if err != nil { + return err + } + if cnt != nil && cnt.Int("cnt") == 0 { + delArgs := append([]any{cy.Int("id")}, condArgs...) + if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil { + return err + } + } + } + } + return c.OK(M{"success": true}) +} + +func periodDailyGet(c *httpapi.Ctx) error { + cid := cidOrNil(c) + uid := c.UID() + date := strings.TrimSpace(c.Q("record_date")) + if !store.Truthy(date) { + return c.Err(400, "请选择日期") + } + cond, condArgs := coupleCond(cid) + if cid == nil { + cond += " AND user_id = ?" + condArgs = append(condArgs, uid) + } + args := append(append([]any{}, condArgs...), date) + rec, err := c.DB.QueryOne("SELECT * FROM period_daily_records WHERE "+cond+" AND record_date = ?", args...) + if err != nil { + return err + } + if rec == nil { + return c.OK([]any{}) + } + return c.OK(rec) +} + +func periodSexAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + recordDate := c.InTrim("record_date") + if !store.Truthy(recordDate) { + return c.Err(400, "请选择日期") + } + recordTime := c.InTrim("record_time") + measure := c.InTrim("measure") + measureCustom := c.InTrim("measure_custom") + if err := checkTextOr400(c, measureCustom); err != nil { + return err + } + var rt any + if store.Truthy(recordTime) { + rt = recordTime + } + recID, err := c.DB.Insert("INSERT INTO period_sex_records (couple_id, user_id, record_date, record_time, measure, measure_custom) VALUES (?, ?, ?, ?, ?, ?)", + cid, uid, recordDate, rt, measure, measureCustom) + if err != nil { + return err + } + desc := "记录了亲密行为" + if store.Truthy(recordTime) { + desc += "(" + recordDate + " " + recordTime + ")" + } + actID, err := logActivityErr(c, cid, uid, "period_sex_add", desc) + if err != nil { + return err + } + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + if periodVisible(c, uid) != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情", + }, "pages/period/period", actID, uid) + } + } + return c.OK(M{"success": true, "id": fmt.Sprint(recID)}) +} + +func periodSexDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid := cidOrNil(c) + uid := c.UID() + id := c.InInt("id") + if id == 0 { + return c.Err(400, "缺少记录ID") + } + if cid == nil { + if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id IS NULL AND user_id = ?", id, uid); err != nil { + return err + } + } else { + if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id = ?", id, cid); err != nil { + return err + } + } + return c.OK(M{"success": true}) +} + +func periodSexList(c *httpapi.Ctx) error { + cid := cidOrNil(c) + uid := c.UID() + date := strings.TrimSpace(c.Q("record_date")) + if !store.Truthy(date) { + return c.Err(400, "请选择日期") + } + cond, condArgs := coupleCond(cid) + if cid == nil { + cond += " AND user_id = ?" + condArgs = append(condArgs, uid) + } + args := append(append([]any{}, condArgs...), date) + items, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+cond+" AND record_date = ? ORDER BY record_time ASC, id ASC", args...) + if err != nil { + return err + } + return c.OK(rows(items)) +} diff --git a/past-go/internal/handler/period_source.go b/past-go/internal/handler/period_source.go new file mode 100644 index 0000000..a32df62 --- /dev/null +++ b/past-go/internal/handler/period_source.go @@ -0,0 +1,147 @@ +package handler + +import ( + "fmt" + + "past/internal/httpapi" + "past/internal/store" +) + +// 绑定前的姨姨记录可能双方都有。只允许一份成为情侣的当前记录; +// 在用户选择前保留两份,选择时在同一事务中删除另一份并迁入当前情侣空间。 +func periodSourceState(c *httpapi.Ctx) (required bool, me, partner int64, err error) { + cid, uid := c.CoupleID(), c.UID() + if cid == 0 { + return false, uid, 0, nil + } + couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid) + if err != nil || couple == nil { + return false, 0, 0, err + } + if couple.Int("user1_id") == uid { + partner = couple.Int("user2_id") + } else if couple.Int("user2_id") == uid { + partner = couple.Int("user1_id") + } else { + return false, 0, 0, fmt.Errorf("账号不属于当前情侣") + } + if couple.Int("period_source_user_id") != 0 { + return false, uid, partner, nil + } + myCount, err := periodSourceCount(c.DB, uid) + if err != nil { + return false, 0, 0, err + } + partnerCount, err := periodSourceCount(c.DB, partner) + if err != nil { + return false, 0, 0, err + } + if myCount > 0 && partnerCount > 0 { + return true, uid, partner, nil + } + if myCount > 0 { + err = selectPeriodSource(c, uid) + } else if partnerCount > 0 { + err = selectPeriodSource(c, partner) + } + return false, uid, partner, err +} + +func periodSourceCount(db store.Querier, uid int64) (int64, error) { + row, err := db.QueryOne(`SELECT + (SELECT COUNT(*) FROM period_records WHERE user_id = ? AND couple_id IS NULL) + + (SELECT COUNT(*) FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL) + + (SELECT COUNT(*) FROM period_sex_records WHERE user_id = ? AND couple_id IS NULL) AS n`, + uid, uid, uid) + if err != nil { + return 0, err + } + return row.Int("n"), nil +} + +func periodSourcePreview(db store.Querier, uid int64) (M, error) { + row, err := db.QueryOne("SELECT COUNT(*) AS cycles, MAX(start_date) AS latest FROM period_records WHERE user_id = ? AND couple_id IS NULL", uid) + if err != nil { + return nil, err + } + total, err := periodSourceCount(db, uid) + if err != nil { + return nil, err + } + return M{"cycles": row.Int("cycles"), "latest": row.Str("latest"), "items": total}, nil +} + +func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error { + cid := c.CoupleID() + if cid == 0 { + return c.Err(400, "未绑定伴侣") + } + tx, err := c.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + couple, err := tx.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ? FOR UPDATE", cid) + if err != nil { + return err + } + if couple == nil || (keepUserID != couple.Int("user1_id") && keepUserID != couple.Int("user2_id")) { + return c.Err(400, "记录来源不是当前情侣成员") + } + if existing := couple.Int("period_source_user_id"); existing != 0 { + if existing == keepUserID { + return nil + } + return c.Err(409, "姨姨记录来源已经选定") + } + deleteUserID := couple.Int("user1_id") + if deleteUserID == keepUserID { + deleteUserID = couple.Int("user2_id") + } + for _, table := range []string{"period_records", "period_daily_records", "period_sex_records"} { + if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND couple_id IS NULL", deleteUserID); err != nil { + return err + } + } + // daily 的旧 NULL 记录可能有重复日期,逐条迁入以避免情侣日期唯一索引冲突。 + daily, err := tx.Query("SELECT id, record_date FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL ORDER BY id DESC FOR UPDATE", keepUserID) + if err != nil { + return err + } + for _, record := range daily { + existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id = ? AND record_date = ? LIMIT 1", cid, record["record_date"]) + if err != nil { + return err + } + if existing != nil { + if _, err := tx.Exec("DELETE FROM period_daily_records WHERE id = ?", record.Int("id")); err != nil { + return err + } + } else if _, err := tx.Exec("UPDATE period_daily_records SET couple_id = ? WHERE id = ?", cid, record.Int("id")); err != nil { + return err + } + } + for _, table := range []string{"period_records", "period_sex_records"} { + if _, err := tx.Exec("UPDATE "+table+" SET couple_id = ? WHERE user_id = ? AND couple_id IS NULL", cid, keepUserID); err != nil { + return err + } + } + if _, err := tx.Exec("UPDATE couples SET period_source_user_id = ? WHERE id = ?", keepUserID, cid); err != nil { + return err + } + return tx.Commit() +} + +func periodSourceSelect(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + if err := requirePeriodAccess(c); err != nil { + return err + } + keepUserID := c.InInt("user_id") + if err := selectPeriodSource(c, keepUserID); err != nil { + return err + } + return c.OK(M{"success": true, "source_user_id": keepUserID}) +} diff --git a/past-go/internal/handler/pet.go b/past-go/internal/handler/pet.go new file mode 100644 index 0000000..04922bc --- /dev/null +++ b/past-go/internal/handler/pet.go @@ -0,0 +1,522 @@ +package handler + +import ( + "fmt" + "math" + "time" + + "past/internal/httpapi" + "past/internal/store" +) + +func registerPet(r *httpapi.Router) { + r.Handle("pet_info", petInfo) + r.Handle("pet_adopt", petAdopt) + r.Handle("pet_daily_action", petDailyAction) + r.Handle("pet_daily_status", petDailyStatus) + r.Handle("pet_achievements", petAchievements) + r.Handle("pet_rename", petRename) + r.Handle("pet_equip", petEquip) + r.Handle("pet_dynamics", petDynamics) +} + +type petAch struct{ Key, Name, Desc, Icon string } + +// allPetAchievements 对应 getAllPetAchievements()(保持顺序) +var allPetAchievements = []petAch{ + {"first_adopt", "初次相遇", "领养第一只宠物", "🐣"}, + {"level_5", "成长之路", "宠物达到5级", "⭐"}, + {"level_10", "小有成就", "宠物达到10级", "🌟"}, + {"level_20", "资深铲屎官", "宠物达到20级", "👑"}, + {"level_30", "终极陪伴", "宠物达到30级", "💎"}, + {"stage_2", "少年初长成", "宠物进入少年期", "🐕"}, + {"stage_3", "青春活力", "宠物进入青年期", "🦮"}, + {"stage_4", "成熟稳重", "宠物进入成年期", "🐩"}, + {"stage_5", "终极形态", "宠物进入终极期", "🦁"}, + {"all_done_7", "一周全勤", "连续7天完成所有任务", "📅"}, + {"all_done_30", "月度之星", "连续30天完成所有任务", "🏆"}, + {"feed_100", "大胃王", "累计喂食100次", "🍖"}, + {"walk_50", "散步达人", "累计散步50次", "🚶"}, + {"bath_30", "爱干净", "累计洗澡30次", "🛁"}, + {"play_50", "玩伴", "累计玩耍50次", "🎾"}, + {"happiness_100", "快乐满分", "宠物心情达到100", "😊"}, + {"rename_first", "取名大师", "第一次给宠物改名", "✏️"}, + {"adopt_7days", "一周陪伴", "领养满7天", "📆"}, + {"adopt_30days", "一月相伴", "领养满30天", "🗓️"}, + {"adopt_100days", "百日纪念", "领养满100天", "💯"}, +} + +func petAchByKey(key string) petAch { + for _, a := range allPetAchievements { + if a.Key == key { + return a + } + } + return petAch{} +} + +// consecutiveDoneDays 从按日期倒序的 all_done 记录统计连续天数 +func consecutiveDoneDays(rows []store.Row) int64 { + var consecutive int64 + var prev time.Time + for i, r := range rows { + cur, _ := parseDate(r.Str("date")) + if i == 0 { + consecutive = 1 + } else { + if math.Round(prev.Sub(cur).Hours()/24) == 1 { + consecutive++ + } else { + break + } + } + prev = cur + } + return consecutive +} + +// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra) +// happiness < 0 表示 null(不检查心情成就) +func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int64, rename bool) { + db := c.DB + rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID) + unlocked := map[string]bool{} + for _, r := range rs { + unlocked[r.Str("achievement_key")] = true + } + var toUnlock []string + add := func(cond bool, key string) { + if cond && !unlocked[key] { + toUnlock = append(toUnlock, key) + } + } + add(true, "first_adopt") + add(level >= 5, "level_5") + add(level >= 10, "level_10") + add(level >= 20, "level_20") + add(level >= 30, "level_30") + add(stage >= 2, "stage_2") + add(stage >= 3, "stage_3") + add(stage >= 4, "stage_4") + add(stage >= 5, "stage_5") + if p, _ := db.QueryOne("SELECT adopted_at FROM pets WHERE couple_id = ?", coupleID); p != nil && p.Str("adopted_at") != "" { + if t, err := parseDate(p.Str("adopted_at")); err == nil { + days := int64(math.Floor(time.Since(t).Seconds() / 86400)) + add(days >= 7, "adopt_7days") + add(days >= 30, "adopt_30days") + add(days >= 100, "adopt_100days") + } + } + add(happiness >= 0 && happiness >= 100, "happiness_100") + if !unlocked["feed_100"] || !unlocked["walk_50"] || !unlocked["bath_30"] || !unlocked["play_50"] { + if cnt, _ := db.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); cnt != nil { + add(cnt.Int("feed_cnt") >= 100, "feed_100") + add(cnt.Int("walk_cnt") >= 50, "walk_50") + add(cnt.Int("bath_cnt") >= 30, "bath_30") + add(cnt.Int("play_cnt") >= 50, "play_50") + } + } + if !unlocked["all_done_7"] || !unlocked["all_done_30"] { + done, _ := db.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID) + consecutive := consecutiveDoneDays(done) + add(consecutive >= 7, "all_done_7") + add(consecutive >= 30, "all_done_30") + } + add(rename, "rename_first") + if len(toUnlock) == 0 { + return + } + // pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500, + // Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。 + db.ExecNoFK(func(q store.Querier) error { + for _, key := range toUnlock { + q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?)", coupleID, key) + a := petAchByKey(key) + q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon) + } + return nil + }) +} + +// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0) +func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) { + r1, r2 := coupleUserIDsRaw(c, coupleID) + return store.ToInt(r1), store.ToInt(r2) +} + +// coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0) +func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) { + rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID) + var u1, u2 any = int64(0), int64(0) + if len(rs) > 0 { + u1 = rs[0]["id"] + } + if len(rs) > 1 { + u2 = rs[1]["id"] + } + return u1, u2 +} + +func petInfo(c *httpapi.Ctx) error { + uid := c.UID() + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID) + if err != nil { + return err + } + if pet == nil { + return c.OK(M{"has_pet": false, "pet": nil}) + } + if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil { + c.DB.ExecNoFK(func(q store.Querier) error { + q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt')", coupleID) + q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣") + return nil + }) + } + daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, today()) + achs, _ := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ?", coupleID) + achKeys := []any{} + for _, a := range achs { + achKeys = append(achKeys, a["achievement_key"]) + } + equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID) + dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID) + u1raw, u2raw := coupleUserIDsRaw(c, coupleID) + u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw) + expForNext := pet.Int("level") * 100 + var expProgress any = 0 + if expForNext > 0 { + p := math.Round(float64(pet.Int("exp")) / float64(expForNext) * 100) + if p > 100 { + p = 100 + } + expProgress = p + } + counts := M{"feed": 0, "walk": 0, "bath": 0, "play": 0} + if ac, _ := c.DB.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); ac != nil { + counts = M{"feed": ac.Int("feed_cnt"), "walk": ac.Int("walk_cnt"), "bath": ac.Int("bath_cnt"), "play": ac.Int("play_cnt")} + } + done, _ := c.DB.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID) + var dailyOut any + if daily != nil { + dailyOut = daily + } + return c.OK(M{ + "has_pet": true, "pet": pet, "daily": dailyOut, "achievements": achKeys, "equipped": rows(equipped), "dynamics": rows(dynamics), + "user1_id": u1raw, "user2_id": u2raw, "is_user1": uid == u1, "is_user2": uid == u2, + "exp_for_next_level": expForNext, "exp_progress": expProgress, "action_counts": counts, + "consecutive_done_days": consecutiveDoneDays(done), + }) +} + +func petAdopt(c *httpapi.Ctx) error { + uid := c.UID() + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + name := c.InTrim("name") + if !c.InHas("name") { + name = "小狗狗" + } + breed := c.InTrim("breed") + if !c.InHas("breed") { + breed = "golden" + } + gender := int64(1) + if c.InHas("gender") { + gender = c.InInt("gender") + } + switch breed { + case "golden", "husky", "corgi", "shiba": + default: + breed = "golden" + } + if ex, _ := c.DB.QueryOne("SELECT id FROM pets WHERE couple_id = ?", coupleID); ex != nil { + return c.Err(400, "已领养过宠物") + } + petID, err := c.DB.Insert("INSERT INTO pets (couple_id, name, breed, gender, stage, level, exp, happiness, hunger, energy, cleanliness, adopted_at, created_by) VALUES (?, ?, ?, ?, 1, 1, 0, 100, 100, 100, 100, ?, ?)", + coupleID, name, breed, gender, today(), uid) + if err != nil { + return err + } + c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'adopt', ?)", coupleID, uid, "领养了"+name+"!🎉") + logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "") + c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID) + checkPetAchievements(c, coupleID, 1, 1, 100, false) + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + // PHP 中 $actId 未定义(null),不写 notify_log + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕", + }, "pages/pet/pet", 0, uid) + } + return c.OK(M{"success": true, "pet_id": fmt.Sprint(petID)}) +} + +var petActionEffects = map[string]map[string]int64{ + "feed": {"exp": 10, "happiness": 3, "hunger": 15}, + "water": {"exp": 8, "happiness": 2, "energy": 5}, + "walk": {"exp": 15, "happiness": 8, "energy": -10, "cleanliness": -5}, + "bath": {"exp": 12, "happiness": 5, "cleanliness": 20, "energy": -5}, + "sleep": {"exp": 5, "energy": 20, "happiness": 3}, + "play": {"exp": 12, "happiness": 10, "energy": -8}, +} + +var petActionMessages = map[string]string{ + "feed": "喂食了宠物 🍖", "water": "给宠物喝了水 💧", "walk": "带宠物去散步 🐾", + "bath": "给宠物洗了澡 🛁", "sleep": "哄宠物睡觉了 😴", "play": "和宠物玩耍了 🎾", +} + +var petDailyFields = []string{"feed_u1", "feed_u2", "water_u1", "water_u2", "walk_u1", "walk_u2", "bath_u1", "bath_u2", "sleep_u1", "sleep_u2", "play_u1", "play_u2"} + +func clamp100(v int64) int64 { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +} + +func petDailyAction(c *httpapi.Ctx) error { + uid := c.UID() + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + action := c.InTrim("action") + effects, ok := petActionEffects[action] + if !ok { + return c.Err(400, "无效的动作") + } + pet, _ := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID) + if pet == nil { + return c.Err(400, "请先领养宠物") + } + u1, _ := coupleUserIDsOrdered(c, coupleID) + suffix := "u2" + if uid == u1 { + suffix = "u1" + } + td := today() + daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td) + if daily == nil { + if _, err := c.DB.Exec("INSERT INTO pet_dailies (couple_id, date) VALUES (?, ?)", coupleID, td); err != nil { + return err + } + daily, _ = c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td) + } + field := action + "_" + suffix + if daily.Int(field) == 1 { + return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true}) + } + if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Int("id")); err != nil { + return err + } + allDone := true + for _, f := range petDailyFields { + if daily.Int(f) == 0 && f != field { + row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Int("id")) + if row == nil || row.Int(f) == 0 { + allDone = false + break + } + } + } + newHappiness := clamp100(pet.Int("happiness") + effects["happiness"]) + newHunger := clamp100(pet.Int("hunger") + effects["hunger"]) + newEnergy := clamp100(pet.Int("energy") + effects["energy"]) + newClean := clamp100(pet.Int("cleanliness") + effects["cleanliness"]) + newExp := pet.Int("exp") + effects["exp"] + newLevel := pet.Int("level") + // PHP: $newLevel = $pet['level'] 为行值(8.0 下是字符串),只有 ++ 后才变为整数;$newStage 同理 + var levelOut, stageOut any = pet["level"], pet["stage"] + expNeeded := newLevel * 100 + for newExp >= expNeeded { + newExp -= expNeeded + newLevel++ + levelOut = newLevel + expNeeded = newLevel * 100 + } + newStage := pet.Int("stage") + switch { + case newLevel >= 30: + newStage = 5 + case newLevel >= 20: + newStage = 4 + case newLevel >= 12: + newStage = 3 + case newLevel >= 5: + newStage = 2 + } + if newStage != pet.Int("stage") || newLevel >= 5 { + stageOut = newStage + } + if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?", + newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Int("id")); err != nil { + return err + } + msg := petActionMessages[action] + c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg) + if allDone { + c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("id")) + c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Int("id")) + c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid) + logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "") + } + checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false) + logActivity(c.DB, coupleID, uid, "pet_daily", msg, "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + notifyMsg := msg + if allDone { + notifyMsg = msg + ",今日所有任务完成!🎉" + } + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "宠物日常 🐾", "thing2": notifyMsg, "date3": td, "thing4": "点击查看宠物详情 💕", + }, "pages/pet/pet", 0, uid) + } + return c.OK(M{ + "success": true, "exp_gain": effects["exp"], "happiness": newHappiness, "hunger": newHunger, "energy": newEnergy, + "cleanliness": newClean, "level": levelOut, "stage": stageOut, "all_done": allDone, + }) +} + +func petDailyStatus(c *httpapi.Ctx) error { + uid := c.UID() + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + date := c.Q("date") + if !c.QHas("date") { + date = today() + } + daily, err := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, date) + if err != nil { + return err + } + u1, _ := coupleUserIDsOrdered(c, coupleID) + suffix, partnerSuffix := "u2", "u1" + if uid == u1 { + suffix, partnerSuffix = "u1", "u2" + } + var myDone, partnerDone any = []any{}, []any{} + var allDone any = false + if daily != nil { + md, pd := M{}, M{} + for _, a := range []string{"feed", "water", "walk", "bath", "sleep", "play"} { + md[a] = daily.Int(a+"_"+suffix) == 1 + pd[a] = daily.Int(a+"_"+partnerSuffix) == 1 + } + myDone, partnerDone = md, pd + allDone = daily["all_done"] + } + return c.OK(M{"date": date, "has_record": daily != nil, "my_done": myDone, "partner_done": partnerDone, "all_done": allDone}) +} + +func petAchievements(c *httpapi.Ctx) error { + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + unlocked, err := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ? ORDER BY unlocked_at ASC", coupleID) + if err != nil { + return err + } + at := map[string]any{} + for _, u := range unlocked { + k := u.Str("achievement_key") + if _, seen := at[k]; !seen { + at[k] = u["unlocked_at"] + } + } + result := make([]M, 0, len(allPetAchievements)) + for _, a := range allPetAchievements { + ua, ok := at[a.Key] + if !ok { + ua = nil + } + result = append(result, M{"key": a.Key, "name": a.Name, "desc": a.Desc, "icon": a.Icon, "unlocked": ok, "unlocked_at": ua}) + } + return c.OK(M{"achievements": result}) +} + +func petRename(c *httpapi.Ctx) error { + uid := c.UID() + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + name := c.InTrim("name") + if !store.Truthy(name) || len([]rune(name)) > 20 { + return c.Err(400, "名称长度1-20个字符") + } + if _, err := c.DB.Exec("UPDATE pets SET name = ?, updated_at = NOW() WHERE couple_id = ?", name, coupleID); err != nil { + return err + } + c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'rename', ?)", coupleID, uid, "给宠物改名为"+name+" ✏️") + logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "") + info, _ := c.DB.QueryOne("SELECT level, stage, happiness FROM pets WHERE couple_id = ?", coupleID) + var level, stage, happiness int64 + if info != nil { + level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness") + } + checkPetAchievements(c, coupleID, level, stage, happiness, true) + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕", + }, "pages/pet/pet", 0, uid) + } + return c.OK(M{"success": true, "name": name}) +} + +func petEquip(c *httpapi.Ctx) error { + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + itemKey := c.InTrim("item_key") + equip := int64(1) + if c.InHas("equip") { + equip = c.InInt("equip") + } + if !store.Truthy(itemKey) { + return c.Err(400, "缺少物品标识") + } + item, _ := c.DB.QueryOne("SELECT * FROM pet_items WHERE couple_id = ? AND item_key = ?", coupleID, itemKey) + if item == nil { + return c.Err(400, "物品不存在") + } + if equip != 0 { + c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_type = ? AND item_key != ?", coupleID, item["item_type"], itemKey) + c.DB.Exec("UPDATE pet_items SET equipped = 1 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey) + } else { + c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey) + } + return c.OK(M{"success": true, "equipped": equip == 1}) +} + +func petDynamics(c *httpapi.Ctx) error { + coupleID, err := requireCoupleID(c) + if err != nil { + return err + } + page := int64(1) + if c.QHas("page") { + page = c.QInt("page") + } + limit := int64(20) + offset := (page - 1) * limit + dynamics, err := c.DB.Query("SELECT pd.*, u.nickname, u.avatar FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT ? OFFSET ?", coupleID, limit, offset) + if err != nil { + return err + } + totalRaw := countRaw(c, "total", "SELECT COUNT(*) as total FROM pet_dynamics WHERE couple_id = ?", coupleID) + total := store.ToInt(totalRaw) + return c.OK(M{"dynamics": rows(dynamics), "total": totalRaw, "page": page, "has_more": (offset + limit) < total}) +} diff --git a/past-go/internal/handler/songs.go b/past-go/internal/handler/songs.go new file mode 100644 index 0000000..94bd7e4 --- /dev/null +++ b/past-go/internal/handler/songs.go @@ -0,0 +1,99 @@ +package handler + +import ( + "fmt" + "strings" + + "past/internal/httpapi" +) + +func registerSongs(r *httpapi.Router) { + r.Handle("songs_list", songsList) + r.Handle("songs_add", songsAdd) + r.Handle("songs_update", songsUpdate) + r.Handle("songs_delete", songsDelete) +} + +func songsList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK(demoData("songs")) + } + items, err := c.DB.Query("SELECT s.*, u.nickname, u.avatar FROM songs s LEFT JOIN users u ON s.created_by = u.id WHERE s.created_by IN (?, ?) ORDER BY s.created_at DESC", ids[0], ids[1]) + if err != nil { + return err + } + return c.OK(rows(items)) +} + +func songText(c *httpapi.Ctx) string { + return strings.TrimSpace(c.InStr("song_name") + " " + c.InStr("artist") + " " + c.InStr("note")) +} + +func songsAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + if !c.InBool("song_name") || !c.InBool("artist") { + return c.Err(400, "请填写歌曲名和歌手名") + } + uid := c.UID() + if err := checkTextOr400(c, songText(c)); err != nil { + return err + } + id, err := c.DB.Insert("INSERT INTO songs (couple_id, song_name, artist, note, created_by) VALUES (?, ?, ?, ?, ?)", + cid, c.In("song_name"), c.In("artist"), inOr(c, "note", ""), uid) + if err != nil { + return err + } + name, artist := c.InStr("song_name"), c.InStr("artist") + actID := logActivity(c.DB, cid, uid, "song_add", "收藏了「"+name+" - "+artist+"」", "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕", + }, "pages/lovewords/lovewords", actID, uid) + } + return c.OK(M{"success": true, "id": fmt.Sprint(id)}) +} + +func songsUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if !c.InBool("id") { + return c.Err(400, "参数错误") + } + if !c.InBool("song_name") || !c.InBool("artist") { + return c.Err(400, "请填写歌曲名和歌手名") + } + if err := checkTextOr400(c, songText(c)); err != nil { + return err + } + if _, err := c.DB.Exec("UPDATE songs SET song_name = ?, artist = ?, note = ? WHERE id = ? AND created_by IN (?, ?)", + c.In("song_name"), c.In("artist"), inOr(c, "note", ""), c.In("id"), ids[0], ids[1]); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func songsDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if _, err := c.DB.Exec("DELETE FROM songs WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { + return err + } + return c.OK(M{"success": true}) +} diff --git a/past-go/internal/handler/timeline.go b/past-go/internal/handler/timeline.go new file mode 100644 index 0000000..728fd34 --- /dev/null +++ b/past-go/internal/handler/timeline.go @@ -0,0 +1,145 @@ +package handler + +import ( + "fmt" + "strings" + + "past/internal/httpapi" +) + +func registerTimeline(r *httpapi.Router) { + r.Handle("timeline_list", timelineList) + r.Handle("timeline_add", timelineAdd) + r.Handle("timeline_update", timelineUpdate) + r.Handle("timeline_delete", timelineDelete) + r.Handle("timeline_detail", timelineDetail) +} + +// requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400); +func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return nil, c.Err(400, "请先绑定情侣") + } + return ids, nil +} + +// requireCoupleID 对应 $cid = getCoupleId(); if (!$cid) jsonResponse(['error' => '请先绑定情侣'], 400); +func requireCoupleID(c *httpapi.Ctx) (int64, error) { + cid := c.CoupleID() + if cid == 0 { + return 0, c.Err(400, "请先绑定情侣") + } + return cid, nil +} + +// checkTextOr400 文本安全检测;违规时已写出 400 +func checkTextOr400(c *httpapi.Ctx, text string) error { + if pass, msg := c.App.Wechat.CheckText(text, userOpenId(c.DB, c.UID())); !pass { + return c.Err(400, msg) + } + return nil +} + +func timelineList(c *httpapi.Ctx) error { + ids := coupleUserIds(c, 0) + if len(ids) == 0 { + return c.OK(demoData("timeline")) + } + items, err := c.DB.Query("SELECT * FROM timeline WHERE created_by IN (?, ?) ORDER BY event_date DESC, sort_order ASC", ids[0], ids[1]) + if err != nil { + return err + } + for _, it := range items { + it["images"] = jsonDecodeList(it["images"]) + } + return c.OK(rows(items)) +} + +func timelineAdd(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + cid, err := requireCoupleID(c) + if err != nil { + return err + } + uid := c.UID() + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { + return err + } + id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + cid, c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""), + jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid) + if err != nil { + return err + } + title := c.InStr("title") + actID := logActivity(c.DB, cid, uid, "timeline_create", "添加了记忆日「"+title+"」", "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": "记忆日更新", + "thing2": "TA记录了「" + mbTruncate(title, 20) + "」", + "date3": c.InStr("event_date"), + "thing4": "点击查看详情 💕", + }, "pages/timeline/timeline", actID, uid) + } + return c.OK(M{"success": true, "id": fmt.Sprint(id)}) +} + +func timelineUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil { + return err + } + _, err = c.DB.Exec("UPDATE timeline SET title = ?, content = ?, event_date = ?, location = ?, repeat_interval = ?, repeat_unit = ?, bg_image = ?, images = ?, icon = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)", + c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""), + jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), c.In("id"), ids[0], ids[1]) + if err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func timelineDelete(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil { + return err + } + return c.OK(M{"success": true}) +} + +func timelineDetail(c *httpapi.Ctx) error { + if err := c.RequireGET(); err != nil { + return err + } + ids, err := requireCoupleUserIds(c) + if err != nil { + return err + } + id := c.Q("id") + if id == "" || id == "0" { + return c.Err(400, "缺少ID") + } + item, err := c.DB.QueryOne("SELECT * FROM timeline WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]) + if err != nil { + return err + } + if item == nil { + return c.Err(404, "记录不存在") + } + item["images"] = jsonDecodeList(item["images"]) + return c.OK(item) +} diff --git a/past-go/internal/handler/user.go b/past-go/internal/handler/user.go new file mode 100644 index 0000000..5a85bcf --- /dev/null +++ b/past-go/internal/handler/user.go @@ -0,0 +1,639 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/crypto/bcrypt" + + "past/internal/geo" + "past/internal/httpapi" + "past/internal/store" + "past/internal/upload" +) + +var phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`) + +// hashPassword 对应 password_hash($pw, PASSWORD_DEFAULT),输出 $2y$ 前缀与 PHP 一致 +func hashPassword(pw string) string { + h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + return "$2y$" + strings.TrimPrefix(string(h), "$2a$") +} + +// verifyPassword 对应 password_verify() +func verifyPassword(pw, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +// last4 对应 substr($s, -4) +func last4(s string) string { + if len(s) <= 4 { + return s + } + return s[len(s)-4:] +} + +// ensureUserDirs 创建用户独立文件夹 users/{uid}/{photos,diaries,covers,timeline} +func ensureUserDirs(uid int64) { + base := filepath.Join(up.Dir, "users", fmt.Sprint(uid)) + for _, t := range []string{"photos", "diaries", "covers", "timeline"} { + os.MkdirAll(filepath.Join(base, t), 0755) + } +} + +func registerUser(r *httpapi.Router) { + r.Handle("register", register) + r.Handle("register_by_invite", registerByInvite) + r.Handle("login", login) + r.Handle("wechat_login", wechatLogin) + r.Handle("check_auth", checkAuth) + r.Handle("logout", logout) + r.Handle("my_info", myInfo) + r.Handle("update_profile", updateProfile) + r.Handle("update_location", updateLocation) + r.Handle("update_ip_location", updateIPLocation) + r.Handle("update_partner_profile", updatePartnerProfile) + r.Handle("upload_avatar", uploadAvatar) + r.Handle("upload_partner_avatar", uploadPartnerAvatar) +} + +func register(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + phone := c.InTrim("phone") + password := c.InStr("password") + nickname := c.InTrim("nickname") + if phone == "" { + return c.Err(400, "请输入手机号") + } + if !phoneRe.MatchString(phone) { + return c.Err(400, "请输入正确的手机号") + } + if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil { + return c.Err(400, "该手机号已注册") + } + if len(password) < 6 { + return c.Err(400, "密码至少6位") + } + if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass { + return c.Err(400, msg) + } + displayID, err := generateUserId(c.DB) + if err != nil { + return err + } + displayName := nickname + if displayName == "" { + displayName = "用户" + last4(phone) + } + gender := c.InStr("gender") + if gender != "male" && gender != "female" { + gender = "" + } + region := c.InTrim("region") + userID, err := c.DB.Insert("INSERT INTO users (user_id, phone, username, password, nickname, gender, region) VALUES (?, ?, ?, ?, ?, ?, ?)", + displayID, phone, phone, hashPassword(password), displayName, gender, region) + if err != nil { + return err + } + // PHP 每次请求都会执行 install.php 的回填 UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL, + // 效果等价于注册后下一请求即回填;Go 只在启动时执行迁移,因此在此处直接回填 + c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE id = ? AND last_login_at IS NULL", userID) + token := httpapi.NewToken() + c.SaveUserToken(userID, token) + // PHP: user_id 来自 lastInsertId(),为字符串 + return c.OK(M{"success": true, "user_id": fmt.Sprint(userID), "display_id": displayID, "username": phone, "nickname": displayName, "couple_id": nil, "region": region, "token": token}) +} + +func registerByInvite(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + phone := c.InTrim("phone") + password := c.InStr("password") + nickname := c.InTrim("nickname") + inviteCode := c.InTrim("invite_code") + if phone == "" { + return c.Err(400, "请输入手机号") + } + if !phoneRe.MatchString(phone) { + return c.Err(400, "请输入正确的手机号") + } + if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil { + return c.Err(400, "该手机号已注册") + } + if len(password) < 6 { + return c.Err(400, "密码至少6位") + } + if inviteCode == "" { + return c.Err(400, "缺少邀请码") + } + if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass { + return c.Err(400, msg) + } + link, err := c.DB.QueryOne("SELECT il.*, u.couple_id as inviter_couple_id FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", inviteCode) + if err != nil { + return err + } + if link == nil { + return c.Err(400, "邀请链接无效或已使用") + } + if link.Int("inviter_couple_id") != 0 { + return c.Err(400, "邀请人已绑定情侣") + } + displayID, err := generateUserId(c.DB) + if err != nil { + return err + } + displayName := nickname + if displayName == "" { + displayName = "用户" + last4(phone) + } + tx, err := c.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + newUserID, err := tx.Insert("INSERT INTO users (user_id, phone, username, password, nickname) VALUES (?, ?, ?, ?, ?)", + displayID, phone, phone, hashPassword(password), displayName) + if err != nil { + return err + } + inviterID := link.Int("user_id") + coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID) + if err != nil { + return err + } + spaceID := generateCoupleSpaceId(coupleID) + if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil { + return err + } + ensureUserDirs(inviterID) + ensureUserDirs(newUserID) + if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil { + return err + } + if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Int("id")); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + createDefaultAlbums(c.DB, coupleID, newUserID) + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newUserID) + token := httpapi.NewToken() + c.SaveUserToken(newUserID, token) + return c.OK(M{"success": true, "user_id": fmt.Sprint(newUserID), "display_id": displayID, "username": phone, "couple_id": fmt.Sprint(coupleID), "nickname": displayName, "token": token}) +} + +func login(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + loginField := c.InTrim("phone") + if loginField == "" { + loginField = c.InTrim("username") + } + password := c.InStr("password") + if loginField == "" { + return c.Err(400, "请输入手机号") + } + user, err := c.DB.QueryOne("SELECT * FROM users WHERE username = ? OR phone = ?", loginField, loginField) + if err != nil { + return err + } + if user == nil { + return c.Err(400, "用户不存在,请先注册") + } + if !verifyPassword(password, user.Str("password")) { + return c.Err(401, "用户名或密码错误") + } + token := httpapi.NewToken() + uid := user.Int("id") + c.SaveUserToken(uid, token) + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) + if loc := resolveLoginLocation(c, ""); loc != "" { + c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid) + } + if user.Int("couple_id") != 0 { + logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "") + } + return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"], + "couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token}) +} + +func wechatLogin(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + code := c.InTrim("code") + if code == "" { + return c.Err(400, "缺少登录凭证") + } + openid, wxErr, available := c.App.Wechat.Jscode2Session(code) + if !available { + return c.Err(500, "微信登录服务暂不可用") + } + if wxErr != "" { + return c.Err(400, "微信登录失败: "+wxErr) + } + if openid == "" { + return c.Err(400, "获取用户标识失败") + } + user, err := c.DB.QueryOne("SELECT id, user_id, nickname, avatar, gender, birthday, couple_id, region FROM users WHERE openid = ?", openid) + if err != nil { + return err + } + if user != nil { + uid := user.Int("id") + token := httpapi.NewToken() + c.SaveUserToken(uid, token) + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) + region := c.InTrim("region") + if region != "" && user.Str("region") == "" { + c.DB.Exec("UPDATE users SET region = ? WHERE id = ?", region, uid) + user["region"] = region + } + if loc := resolveLoginLocation(c, region); loc != "" { + c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid) + } + if cid := user.Int("couple_id"); cid != 0 { + actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "") + if partnerID := partnerUserId(c.DB, uid); partnerID != 0 { + nick := user.Str("nickname") + if nick == "" { + nick = "你的TA" + } + c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ + "thing1": nick + " 登录了小程序", + "thing2": "TA已上线,快来互动吧 💕", + "date3": today(), + "thing4": "点击查看详情", + }, "pages/index/index", actID, uid) + } + } + return c.OK(M{ + "success": true, "is_new": false, + "user_id": user["id"], "display_id": user["user_id"], + "couple_id": user["couple_id"], "nickname": user["nickname"], + "avatar": user.Str("avatar"), "gender": user.Str("gender"), + "birthday": user.Str("birthday"), "region": user.Str("region"), + "token": token, + }) + } + displayID, err := generateUserId(c.DB) + if err != nil { + return err + } + region := c.InTrim("region") + defaultNick := "用户" + last4(displayID) + newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region) VALUES (?, ?, ?, ?, ?, ?)", + displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region) + if err != nil { + return err + } + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newID) + if loc := resolveLoginLocation(c, region); loc != "" { + c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, newID) + } + token := httpapi.NewToken() + c.SaveUserToken(newID, token) + return c.OK(M{ + "success": true, "is_new": true, + "user_id": fmt.Sprint(newID), "display_id": displayID, + "couple_id": nil, "nickname": defaultNick, + "avatar": "", "gender": "", "birthday": "", "region": region, + "token": token, + }) +} + +func checkAuth(c *httpapi.Ctx) error { + uid := c.UID() + if uid == 0 { + return c.OK(M{"logged_in": false}) + } + // 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同 + if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil { + if needLoginRecord(user.Str("last_login_at")) { + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) + if user.Int("couple_id") != 0 { + c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid) + } + } + } + user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid) + if err != nil { + return err + } + return c.OK(M{"logged_in": true, "user": user}) +} + +func logout(c *httpapi.Ctx) error { + if uid := c.UID(); uid != 0 { + // PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理 + c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token()) + } + return c.OK(M{"success": true}) +} + +func myInfo(c *httpapi.Ctx) error { + uid := c.UID() + user, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.couple_id, u.created_at FROM users u WHERE u.id = ?", uid) + if err != nil { + return err + } + if user == nil { + return c.OK(false) + } + if user.Int("couple_id") != 0 { + partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Int("couple_id"), uid) + if err != nil { + return err + } + if partner == nil { + user["partner"] = false + } else { + user["partner"] = partner + } + } + return c.OK(user) +} + +func updateProfile(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + nickname := c.InTrim("nickname") + avatar := c.InStr("avatar") + gender := c.InStr("gender") + if nickname != "" { + if pass, msg := c.App.Wechat.CheckText(nickname, userOpenId(c.DB, uid)); !pass { + return c.Err(400, msg) + } + } + hasAge := c.InHas("age") + age := c.InInt("age") + birthday := c.InTrim("birthday") + region := c.InTrim("region") + var fields []string + var params []any + var changed []string + if store.Truthy(nickname) { + fields = append(fields, "nickname = ?") + params = append(params, nickname) + changed = append(changed, "昵称") + } + if store.Truthy(avatar) { + fields = append(fields, "avatar = ?") + params = append(params, avatar) + changed = append(changed, "头像") + } + if store.Truthy(gender) { + fields = append(fields, "gender = ?") + params = append(params, gender) + changed = append(changed, "性别") + } + if hasAge { + fields = append(fields, "age = ?") + params = append(params, age) + changed = append(changed, "年龄") + } + if birthday != "" { + fields = append(fields, "birthday = ?") + params = append(params, birthday) + changed = append(changed, "生日") + } + if region != "" { + fields = append(fields, "region = ?") + params = append(params, region) + changed = append(changed, "地区") + } + if len(fields) > 0 { + fields = append(fields, "updated_at = CURRENT_TIMESTAMP") + params = append(params, uid) + if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil { + return err + } + if cid := c.CoupleID(); cid != 0 && len(changed) > 0 { + logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "") + } + } + return c.OK(M{"success": true}) +} + +func updateLocation(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + location := c.InTrim("location") + if location == "" { + return c.Err(400, "位置不能为空") + } + saved := location + var lat, lng float64 + if strings.Contains(location, ",") { + parts := strings.Split(location, ",") + if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) { + lat = store.ToFloat(strings.TrimSpace(parts[0])) + lng = store.ToFloat(strings.TrimSpace(parts[1])) + if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" { + saved = addr + } else { + old, _ := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", uid) + if old != nil && old.Str("location") != "" && !strings.Contains(old.Str("location"), ",") { + saved = old.Str("location") + } else { + saved = "" + } + } + } + } + if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", saved, lat, lng, uid); err != nil { + return err + } + var distance any + if cid := c.CoupleID(); cid != 0 { + partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid) + if partner != nil && lat != 0 && lng != 0 { + distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"])) + } + } + return c.OK(M{"success": true, "location": saved, "lat": lat, "lng": lng, "distance": distance}) +} + +func updateIPLocation(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + var newLat, newLng float64 + if c.InHas("lat") { + newLat = c.InFloat("lat") + } + if c.InHas("lng") { + newLng = c.InFloat("lng") + } + u, _ := c.DB.QueryOne("SELECT location, lat, lng, last_ip FROM users WHERE id = ?", uid) + current := "" + var oldLat, oldLng float64 + if u != nil { + current = u.Str("location") + oldLat = store.ToFloat(u["lat"]) + oldLng = store.ToFloat(u["lng"]) + } + if newLat != 0 && newLng != 0 && oldLat != 0 && oldLng != 0 { + dist := geo.Haversine(oldLat, oldLng, newLat, newLng) + if dist <= 5.0 { + return c.OK(M{"success": true, "location": current, "cached": true, "distance": math.Round(dist*100) / 100}) + } + } + clientIP := c.ClientIP() + address := "" + if newLat != 0 && newLng != 0 { + address = c.App.Geo.ReverseGeocode(newLat, newLng) + } + if address == "" { + address = c.App.Geo.IPGeolocate(clientIP) + } + if address != "" { + if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, last_ip = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", address, newLat, newLng, clientIP, uid); err != nil { + return err + } + return c.OK(M{"success": true, "location": address, "cached": false}) + } + return c.OK(M{"success": true, "location": current, "cached": true}) +} + +// verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应 +func verifyPartner(c *httpapi.Ctx, uid, partnerID int64) error { + user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + if user == nil || user.Int("couple_id") == 0 { + return c.Err(400, "你还没有绑定情侣") + } + partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Int("couple_id")) + if partner == nil { + return c.Err(400, "无法修改此用户信息") + } + return nil +} + +func updatePartnerProfile(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + partnerID := c.InInt("partner_id") + nickname := c.InTrim("nickname") + avatar := c.InStr("avatar") + gender := c.InTrim("gender") + hasAge := c.InHas("age") + age := c.InInt("age") + birthday := c.InTrim("birthday") + if err := verifyPartner(c, uid, partnerID); err != nil { + return err + } + var fields []string + var params []any + var changed []string + if store.Truthy(nickname) { + fields = append(fields, "nickname = ?") + params = append(params, nickname) + changed = append(changed, "昵称") + } + if store.Truthy(avatar) { + fields = append(fields, "avatar = ?") + params = append(params, avatar) + changed = append(changed, "头像") + } + if store.Truthy(gender) { + fields = append(fields, "gender = ?") + params = append(params, gender) + changed = append(changed, "性别") + } + if hasAge { + fields = append(fields, "age = ?") + params = append(params, age) + changed = append(changed, "年龄") + } + if birthday != "" { + fields = append(fields, "birthday = ?") + params = append(params, birthday) + changed = append(changed, "生日") + } + if len(fields) > 0 { + fields = append(fields, "updated_at = CURRENT_TIMESTAMP") + params = append(params, partnerID) + if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil { + return err + } + if cid := c.CoupleID(); cid != 0 && len(changed) > 0 { + logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "") + } + } + return c.OK(M{"success": true}) +} + +// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file +func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error { + u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", targetUID) + uidPath := fmt.Sprintf("uid%d", targetUID) + if u != nil { + uidPath = u.Str("user_id") + } + fh := c.File("avatar") + if fh == nil { + fh = c.File("file") + } + if fh == nil { + return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) + } + res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath)) + if res.Path != "" { + if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil { + return err + } + return c.OK(M{"success": true, "path": res.Path}) + } + if strings.Contains(res.Error, "违规") { + return c.ErrCode(400, res.Error, upload.ErrHandleFailed) + } + msg := res.Error + if msg == "" { + msg = "未知错误" + } + return c.ErrCode(400, "上传失败: "+msg, upload.ErrHandleFailed) +} + +func uploadAvatar(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + return doAvatarUpload(c, c.UID()) +} + +func uploadPartnerAvatar(c *httpapi.Ctx) error { + if c.Method != "POST" { + return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) + } + uid := c.UID() + partnerID := store.ToInt(c.Form("partner_id")) + if err := verifyPartner(c, uid, partnerID); err != nil { + return err + } + return doAvatarUpload(c, partnerID) +} diff --git a/past-go/internal/httpapi/auth.go b/past-go/internal/httpapi/auth.go new file mode 100644 index 0000000..e1d8453 --- /dev/null +++ b/past-go/internal/httpapi/auth.go @@ -0,0 +1,182 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "regexp" + "sync" + "time" +) + +var bearerRe = regexp.MustCompile(`Bearer\s(\S+)`) + +// Token 对应 getBearerToken() + $_POST['token'] + $_GET['token'] 三通道 +func (c *Ctx) Token() string { + if m := bearerRe.FindStringSubmatch(c.G.GetHeader("Authorization")); m != nil { + return m[1] + } + if t := c.Form("token"); t != "" { + return t + } + if t := c.Q("token"); t != "" { + return t + } + return "" +} + +// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回 0。 +func (c *Ctx) UID() int64 { + if c.uidResolved { + return c.uid + } + c.uidResolved = true + tok := c.Token() + if tok == "" { + return 0 + } + row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok) + if err != nil || row == nil { + return 0 + } + c.uid = row.Int("user_id") + return c.uid +} + +// CoupleID 对应 getCoupleId();无则返回 0 +func (c *Ctx) CoupleID() int64 { + uid := c.UID() + if uid == 0 { + return 0 + } + row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid) + if row == nil { + return 0 + } + return row.Int("couple_id") +} + +// NewToken 生成 32 位 hex token(与 PHP session_id 格式一致) +func NewToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。 +// PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录); +// Go 没有 session 文件,因此保留旧 token 以维持同样的多端登录效果。 +func (c *Ctx) SaveUserToken(userID int64, token string) { + c.DB.Exec("DELETE FROM user_tokens WHERE expires_at < NOW()") + c.DB.Exec("INSERT INTO user_tokens (token, user_id, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))", token, userID) +} + +// requireUserLogin 对应 requireUserLogin():401/404,并做 5 分钟去重的登录记录 +func (c *Ctx) requireUserLogin() error { + uid := c.UID() + if uid == 0 { + return c.Err(401, "请先登录") + } + user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid) + if err != nil { + return err + } + if user == nil { + return c.Err(404, "用户不存在") + } + needRecord := false + if user.IsNull("last_login_at") || user.Str("last_login_at") == "" { + needRecord = true + } else if t, err := time.ParseInLocation("2006-01-02 15:04:05", user.Str("last_login_at"), time.Local); err != nil || time.Since(t) > 300*time.Second { + needRecord = true + } + if needRecord { + c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) + if user.Int("couple_id") != 0 { + c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid) + } + } + return nil +} + +// ---------- 后台管理员会话(对应 PHP Cookie Session)---------- + +const adminCookieName = "PHPSESSID" +const adminSessionTTL = 12 * time.Hour + +type adminSession struct { + AdminID int64 + Username string + Expires time.Time +} + +type AdminSessions struct { + mu sync.Mutex + m map[string]*adminSession +} + +func NewAdminSessions() *AdminSessions { + s := &AdminSessions{m: map[string]*adminSession{}} + go func() { + for range time.Tick(10 * time.Minute) { + s.mu.Lock() + now := time.Now() + for k, v := range s.m { + if v.Expires.Before(now) { + delete(s.m, k) + } + } + s.mu.Unlock() + } + }() + return s +} + +func (s *AdminSessions) get(id string) *adminSession { + s.mu.Lock() + defer s.mu.Unlock() + v := s.m[id] + if v == nil || v.Expires.Before(time.Now()) { + delete(s.m, id) + return nil + } + v.Expires = time.Now().Add(adminSessionTTL) + return v +} + +// AdminSession 当前请求的管理员会话;未登录返回 nil +func (c *Ctx) AdminSession() *adminSession { + id, err := c.G.Cookie(adminCookieName) + if err != nil || id == "" { + return nil + } + return c.App.Admin.get(id) +} + +// AdminLogin 建立管理员会话并写 Cookie(对应 session_start + $_SESSION['admin_logged_in']=true) +func (c *Ctx) AdminLogin(adminID int64, username string) { + id := NewToken() + c.App.Admin.mu.Lock() + c.App.Admin.m[id] = &adminSession{AdminID: adminID, Username: username, Expires: time.Now().Add(adminSessionTTL)} + c.App.Admin.mu.Unlock() + c.G.SetSameSite(http.SameSiteLaxMode) + c.G.SetCookie(adminCookieName, id, 0, "/", "", false, true) +} + +// AdminLogout 对应 session_destroy() +func (c *Ctx) AdminLogout() { + if id, err := c.G.Cookie(adminCookieName); err == nil { + c.App.Admin.mu.Lock() + delete(c.App.Admin.m, id) + c.App.Admin.mu.Unlock() + } + c.G.SetCookie(adminCookieName, "", -1, "/", "", false, true) +} + +// requireAdminLogin 对应 requireAdminLogin() +func (c *Ctx) requireAdminLogin() error { + if c.AdminSession() == nil { + return c.Err(401, "未登录或登录已过期") + } + return nil +} diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go new file mode 100644 index 0000000..2bbb693 --- /dev/null +++ b/past-go/internal/httpapi/ctx.go @@ -0,0 +1,316 @@ +// Package httpapi 提供与 PHP 版 index.php 等价的请求上下文、路由分发、鉴权和响应封装。 +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "past/internal/config" + "past/internal/geo" + "past/internal/store" + "past/internal/wechat" +) + +// App 全局依赖 +type App struct { + Cfg *config.Config + DB *store.DB + Wechat *wechat.Client + Geo *geo.Client + Admin *AdminSessions +} + +// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。 +type Ctx struct { + G *gin.Context + W http.ResponseWriter + R *http.Request + App *App + DB *store.DB + Action string + CamelCase bool + Method string + + input map[string]any + inputParsed bool + body []byte + bodyRead bool + uid int64 + uidResolved bool + responded bool +} + +// errResponded 表示响应已写出(对应 PHP 的 exit)。 +var errResponded = errors.New("responded") + +// IsResponded 判断错误是否为“已响应”哨兵 +func IsResponded(err error) bool { return errors.Is(err, errResponded) } + +// ---------- 响应 ---------- + +// JSON 对应 jsonResponse($data, $code)。不用 gin 自带的 c.JSON(它会把 <>& 转义成 \u003c), +// 而是自行编码后通过 c.Data 输出,保持与 PHP json_encode(JSON_UNESCAPED_UNICODE) 一致。 +func (c *Ctx) JSON(code int, v any) error { + if c.responded { + return errResponded + } + c.responded = true + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + c.G.Data(500, "application/json; charset=utf-8", []byte(`{"error":"json encode failed"}`)) + return errResponded + } + if c.CamelCase { + var payload any + dec := json.NewDecoder(bytes.NewReader(buf.Bytes())) + dec.UseNumber() + if err := dec.Decode(&payload); err != nil { + return err + } + buf.Reset() + if err := enc.Encode(renameKeys(payload, camelName)); err != nil { + return err + } + } + c.G.Data(code, "application/json; charset=utf-8", bytes.TrimRight(buf.Bytes(), "\n")) + return errResponded +} + +// OK 返回 200 +func (c *Ctx) OK(v any) error { return c.JSON(200, v) } + +// Err 对应 jsonResponse(['error' => $msg], $code) +func (c *Ctx) Err(code int, msg string) error { + return c.JSON(code, M{"error": msg}) +} + +// ErrCode 带错误码的上传错误:['error' => msg, 'code' => n] +func (c *Ctx) ErrCode(code int, msg string, errCode int) error { + return c.JSON(code, M{"error": msg, "code": errCode}) +} + +// M 便捷的 JSON 对象类型 +type M = map[string]any + +// RequirePOST 对应 if ($method !== 'POST') jsonResponse(['error' => '方法不允许'], 405); +func (c *Ctx) RequirePOST() error { + if c.Method != "POST" { + return c.Err(405, "方法不允许") + } + return nil +} + +func (c *Ctx) RequireGET() error { + if c.Method != "GET" { + return c.Err(405, "方法不允许") + } + return nil +} + +// ---------- 请求体 / 参数 ---------- + +// Body 原始请求体(php://input) +func (c *Ctx) Body() []byte { + if !c.bodyRead { + c.bodyRead = true + if c.R.Body != nil { + c.body, _ = io.ReadAll(c.R.Body) + } + } + return c.body +} + +// Input 对应 $input = json_decode(file_get_contents('php://input'), true);解析失败返回空 map。 +func (c *Ctx) Input() map[string]any { + if !c.inputParsed { + c.inputParsed = true + c.input = map[string]any{} + // Multipart fields belong to Form; reading them as JSON would consume the upload stream. + if c.isForm() { + return c.input + } + b := c.Body() + if len(bytes.TrimSpace(b)) > 0 { + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var v any + if dec.Decode(&v) == nil { + if m, ok := v.(map[string]any); ok { + if c.CamelCase { + m = renameKeys(m, snakeName).(map[string]any) + } + c.input = normalizeNumbers(m).(map[string]any) + } + } + } + } + return c.input +} + +// normalizeNumbers 把 json.Number 转成 int64 或 float64(整数保持整数,对齐 PHP json_decode) +func normalizeNumbers(v any) any { + switch x := v.(type) { + case json.Number: + if i, err := x.Int64(); err == nil { + return i + } + f, _ := x.Float64() + return f + case map[string]any: + for k, val := range x { + x[k] = normalizeNumbers(val) + } + return x + case []any: + for i, val := range x { + x[i] = normalizeNumbers(val) + } + return x + } + return v +} + +// In 对应 $input['key'] ?? nil +func (c *Ctx) In(key string) any { return c.Input()[key] } + +// InHas 对应 isset($input['key']) +func (c *Ctx) InHas(key string) bool { + v, ok := c.Input()[key] + return ok && v != nil +} + +// InStr 对应 (string)($input['key'] ?? ”) +func (c *Ctx) InStr(key string) string { return store.ToStr(c.In(key)) } + +// InTrim 对应 trim($input['key'] ?? ”) +func (c *Ctx) InTrim(key string) string { return strings.TrimSpace(c.InStr(key)) } + +// InInt 对应 intval($input['key'] ?? 0) +func (c *Ctx) InInt(key string) int64 { return store.ToInt(c.In(key)) } + +// InFloat 对应 floatval($input['key'] ?? 0) +func (c *Ctx) InFloat(key string) float64 { return store.ToFloat(c.In(key)) } + +// InBool 对应 PHP 真值判断 +func (c *Ctx) InBool(key string) bool { return store.Truthy(c.In(key)) } + +// InList 对应 is_array($input['key']) ? $input['key'] : [] +func (c *Ctx) InList(key string) []any { + if l, ok := c.In(key).([]any); ok { + return l + } + return []any{} +} + +// InMap 对应关联数组 +func (c *Ctx) InMap(key string) map[string]any { + if m, ok := c.In(key).(map[string]any); ok { + return m + } + return nil +} + +// Q 对应 $_GET['key'] ?? ” +func (c *Ctx) Q(key string) string { + if c.CamelCase { + if value, ok := c.G.GetQuery(camelName(key)); ok { + return value + } + } + return c.G.Query(key) +} + +// QHas 对应 isset($_GET['key']) +func (c *Ctx) QHas(key string) bool { + if c.CamelCase { + if _, ok := c.G.GetQuery(camelName(key)); ok { + return true + } + } + _, ok := c.G.GetQuery(key) + return ok +} + +// QInt 对应 intval($_GET['key'] ?? 0) +func (c *Ctx) QInt(key string) int64 { return store.ToInt(c.Q(key)) } + +// Form 对应 $_POST['key'] ?? ”(表单/multipart 字段) +func (c *Ctx) Form(key string) string { + if !c.isForm() { + return "" + } + if c.CamelCase { + if value, ok := c.G.GetPostForm(camelName(key)); ok { + return value + } + } + return c.G.PostForm(key) +} + +// FormHas 对应 isset($_POST['key']) +func (c *Ctx) FormHas(key string) bool { + if !c.isForm() { + return false + } + if c.CamelCase { + if _, ok := c.G.GetPostForm(camelName(key)); ok { + return true + } + } + _, ok := c.G.GetPostForm(key) + return ok +} + +// isForm 只有表单/multipart 请求才解析 $_POST(其它类型的 body 留给 Input() 读取 JSON) +func (c *Ctx) isForm() bool { + ct := c.R.Header.Get("Content-Type") + return strings.HasPrefix(ct, "multipart/form-data") || strings.HasPrefix(ct, "application/x-www-form-urlencoded") +} + +// File 对应 $_FILES['name'](单文件);不存在返回 nil。 +func (c *Ctx) File(name string) *multipart.FileHeader { + fs := c.Files(name) + if len(fs) == 0 { + return nil + } + return fs[0] +} + +// Files 对应 $_FILES['name']:与 PHP 一致,同名字段 name 重复时只保留最后一个;name[] 才是多文件数组 +func (c *Ctx) Files(name string) []*multipart.FileHeader { + if !strings.HasPrefix(c.R.Header.Get("Content-Type"), "multipart/form-data") { + return nil + } + form, err := c.G.MultipartForm() + if err != nil || form == nil { + return nil + } + if fs := form.File[name]; len(fs) > 0 { + return fs[len(fs)-1:] + } + return form.File[name+"[]"] +} + +// ClientIP 对应 getClientIp():X-Forwarded-For → X-Real-IP → RemoteAddr +// (gin 自带的 ClientIP 只信任 TrustedProxies,这里保持 PHP 的宽松取法,Nginx/frp 链路上由 Nginx 设置头) +func (c *Ctx) ClientIP() string { + if xff := c.G.GetHeader("X-Forwarded-For"); xff != "" { + ip := strings.TrimSpace(strings.Split(xff, ",")[0]) + if ip != "" && ip != "unknown" { + return ip + } + } + if xr := strings.TrimSpace(c.G.GetHeader("X-Real-Ip")); xr != "" && xr != "unknown" { + return xr + } + return c.G.RemoteIP() +} diff --git a/past-go/internal/httpapi/naming.go b/past-go/internal/httpapi/naming.go new file mode 100644 index 0000000..bca4b26 --- /dev/null +++ b/past-go/internal/httpapi/naming.go @@ -0,0 +1,48 @@ +package httpapi + +import "strings" + +// camelName is the public API naming convention; DB and handlers keep their established names. +func camelName(s string) string { + parts := strings.Split(s, "_") + for i := 1; i < len(parts); i++ { + if len(parts[i]) > 0 { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + } + return strings.Join(parts, "") +} + +func snakeName(s string) string { + var b strings.Builder + for i, c := range s { + if c >= 'A' && c <= 'Z' { + if i > 0 { + b.WriteByte('_') + } + b.WriteRune(c + ('a' - 'A')) + } else { + b.WriteRune(c) + } + } + return b.String() +} + +func renameKeys(v any, name func(string) string) any { + switch x := v.(type) { + case map[string]any: + result := make(map[string]any, len(x)) + for k, val := range x { + result[name(k)] = renameKeys(val, name) + } + return result + case []any: + result := make([]any, len(x)) + for i, val := range x { + result[i] = renameKeys(val, name) + } + return result + default: + return v + } +} diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go new file mode 100644 index 0000000..a31c111 --- /dev/null +++ b/past-go/internal/httpapi/router.go @@ -0,0 +1,243 @@ +package httpapi + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "runtime/debug" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束; +// 返回其它 error 对应 PHP 顶层 catch → 500 {"error": message}。 +type HandlerFunc func(c *Ctx) error + +type Router struct { + app *App + handlers map[string]HandlerFunc + adminFS fs.FS // React 后台构建产物(embed) +} + +func NewRouter(app *App, adminFS fs.FS) *Router { + return &Router{app: app, handlers: map[string]HandlerFunc{}, adminFS: adminFS} +} + +// Handle 注册 action +func (r *Router) Handle(action string, h HandlerFunc) { + if _, dup := r.handlers[action]; dup { + panic("duplicate action: " + action) + } + r.handlers[action] = h +} + +// Actions 已注册的 action 列表(用于自检) +func (r *Router) Actions() []string { + out := make([]string, 0, len(r.handlers)) + for k := range r.handlers { + out = append(out, k) + } + return out +} + +// 与 index.php 中 $publicActions / $adminActions 完全一致 +var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login", + "check_invite_code", "register_by_invite", "check_auth") + +var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations") + +func set(items ...string) map[string]bool { + m := make(map[string]bool, len(items)) + for _, s := range items { + m[s] = true + } + return m +} + +var actionInURIRe = regexp.MustCompile(`[?&]action=([^&]+)`) + +// Engine 构建 gin 引擎:API 入口、上传文件静态服务、后台 SPA。 +func (r *Router) Engine(debugMode bool) *gin.Engine { + if debugMode { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + e := gin.New() + e.Use(gin.Recovery(), accessLog()) + e.RedirectTrailingSlash = false + e.MaxMultipartMemory = 32 << 20 + + // 简洁 API 路径;旧入口保留给已发布客户端和管理后台。 + e.Any("/api", r.serveAPI) + e.Any("/api/", r.serveAPI) + e.Any("/api/:action", r.serveAPI) + e.Any("/backend/api/index.php", r.serveAPI) + e.Any("/backend/api/", r.serveAPI) + e.Any("/backend/api", r.serveAPI) + + // 上传文件:DB 中路径 uploads/... → URL /backend/uploads/...(gin.Dir 关闭目录列表;缓存 1 个月,对应 .htaccess) + uploadDir := r.app.Cfg.UploadDir() + uploads := e.Group("/backend/uploads", func(c *gin.Context) { + // 只对真实存在的文件加长缓存;404 不能被浏览器缓存(否则文件补上后仍显示不出来) + p := filepath.Join(uploadDir, filepath.FromSlash(strings.TrimPrefix(c.Param("filepath"), "/"))) + if st, err := os.Stat(p); err == nil && !st.IsDir() && !strings.Contains(c.Param("filepath"), "..") { + c.Header("Cache-Control", "public, max-age=2592000") + } else { + c.Header("Cache-Control", "no-store") + } + }) + uploads.StaticFS("/", gin.Dir(uploadDir, false)) + + // 管理后台 SPA + e.GET("/backend/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/backend/admin/") }) + e.GET("/backend/admin/*filepath", r.serveAdmin) + + e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") }) + return e +} + +func (r *Router) serveAPI(g *gin.Context) { + w, req := g.Writer, g.Request + // CORS(与 PHP 一致) + h := w.Header() + // API 响应按用户身份变化,且权限可能随时修改,不能复用旧的 GET 结果。 + h.Set("Cache-Control", "no-store") + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if req.Method == http.MethodOptions { + w.WriteHeader(200) + return + } + + c := &Ctx{G: g, W: w, R: req, App: r.app, DB: r.app.DB, Method: req.Method} + g.Set("action", "") + + // 路径中的 action 优先,兼容旧客户端的查询和表单参数。 + action := g.Param("action") + if action == "" { + action = req.URL.Query().Get("action") + } + if action == "" { + action = c.Form("action") + } + if action == "" { + if m := actionInURIRe.FindStringSubmatch(req.RequestURI); m != nil { + if v, err := url.QueryUnescape(m[1]); err == nil { + action = v + } + } + } + // A camelCase path opts into camelCase request/response fields. Snake paths retain legacy payloads. + publicAction := action + c.CamelCase = g.Param("action") != "" && !strings.Contains(action, "_") + action = snakeName(action) + c.Action = action + g.Set("action", publicAction) + + defer func() { + if rec := recover(); rec != nil { + slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack())) + if !c.responded { + c.Err(500, fmt.Sprint(rec)) + } + } + g.Set("uid", c.uid) + }() + + if action == "" { + c.JSON(200, M{ + "name": "我们past API", + "version": "2.0.0", + "publicEndpoints": []string{"register", "login", "wechatLogin", "siteInfo", "siteStats", "adminLogin", "checkInviteCode", "registerByInvite"}, + }) + return + } + + // 权限检查(与 PHP 的 if/elseif 顺序一致) + var err error + if adminActions[action] && action != "admin_login" { + err = c.requireAdminLogin() + } else if !publicActions[action] && !adminActions[action] { + err = c.requireUserLogin() + } + if err != nil { + if !IsResponded(err) { + c.Err(500, err.Error()) + } + return + } + + hf, ok := r.handlers[action] + if !ok { + c.Err(400, "未知操作") + return + } + if err := hf(c); err != nil && !IsResponded(err) { + slog.Error("handler error", "action", action, "err", err) + c.Err(500, err.Error()) + } + if !c.responded { + // PHP 中每个分支都以 jsonResponse 结束;兜底返回空对象,避免挂起 + c.JSON(200, M{}) + } +} + +// accessLog gin 中间件:结构化访问日志(slog) +func accessLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + attrs := []any{"method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "ms", time.Since(start).Milliseconds()} + if a, ok := c.Get("action"); ok { + attrs = append(attrs, "action", a) + } + if uid, ok := c.Get("uid"); ok { + attrs = append(attrs, "uid", uid) + } + slog.Info("http", attrs...) + } +} + +// serveAdmin 提供 embed 的后台 SPA;未命中的路径回退到 index.html +func (r *Router) serveAdmin(g *gin.Context) { + p := strings.TrimPrefix(path.Clean("/"+g.Param("filepath")), "/") + if p == "" { + p = "index.html" + } + if _, err := fs.Stat(r.adminFS, p); err != nil { + p = "index.html" + } + if strings.HasSuffix(p, ".html") { + g.Header("Cache-Control", "no-cache") + } + // 不用 http.FileServer:它会把 /index.html 301 到 ./ + f, err := r.adminFS.Open(p) + if err != nil { + g.Status(404) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil { + g.Status(404) + return + } + rs, ok := f.(io.ReadSeeker) + if !ok { + b, _ := io.ReadAll(f) + rs = bytes.NewReader(b) + } + http.ServeContent(g.Writer, g.Request, p, st.ModTime(), rs) +} diff --git a/past-go/internal/httpapi/router_test.go b/past-go/internal/httpapi/router_test.go new file mode 100644 index 0000000..8dfa5fb --- /dev/null +++ b/past-go/internal/httpapi/router_test.go @@ -0,0 +1,127 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "past/internal/config" +) + +func TestCleanAPIRoutes(t *testing.T) { + app := &App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()} + router := NewRouter(app, fstest.MapFS{"index.html": {Data: []byte("admin")}}) + router.Handle("site_info", func(c *Ctx) error { return c.JSON(200, M{"action": c.Action, "value": c.Q("value")}) }) + router.Handle("admin_login", func(c *Ctx) error { + c.AdminLogin(1, "test") + return c.JSON(200, M{"success": true}) + }) + router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) }) + router.Handle("site_stats", func(c *Ctx) error { + return c.JSON(200, M{"value": c.Form("value"), "token": c.Token()}) + }) + engine := router.Engine(false) + for _, uri := range []string{"/api/site_info?value=hello", "/api/site_info?action=wrong&value=hello", "/backend/api/index.php?action=site_info&value=hello"} { + t.Run(uri, func(t *testing.T) { + out := httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, uri, nil)) + var result map[string]any + if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if out.Code != 200 || result["action"] != "site_info" || result["value"] != "hello" { + t.Fatalf("%d %s", out.Code, out.Body.String()) + } + if out.Header().Get("Cache-Control") != "no-store" { + t.Fatal("API responses must not be cached") + } + }) + } + for _, uri := range []string{"/api/couple_info", "/api/period_visible_set", "/api/upload", "/api/admin_stats"} { + out := httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, uri, strings.NewReader("{}"))) + if out.Code != 401 { + t.Fatalf("unauthenticated %s: %d %s", uri, out.Code, out.Body.String()) + } + } + out := httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodOptions, "/api/upload", nil)) + if out.Code != 200 || out.Header().Get("Access-Control-Allow-Headers") == "" { + t.Fatal("missing preflight support") + } + // Multipart parameters remain available after routing by URL path. + var body bytes.Buffer + form := multipart.NewWriter(&body) + form.WriteField("value", "upload-value") + form.WriteField("token", "android-form-token") + form.Close() + req := httptest.NewRequest(http.MethodPost, "/api/site_stats", &body) + req.Header.Set("Content-Type", form.FormDataContentType()) + out = httptest.NewRecorder() + engine.ServeHTTP(out, req) + if out.Code != 200 || !strings.Contains(out.Body.String(), "android-form-token") || !strings.Contains(out.Body.String(), "upload-value") { + t.Fatalf("multipart response: %d %s", out.Code, out.Body.String()) + } + // Admin login and authenticated requests share the existing Cookie session. + out = httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, "/api/admin_login", nil)) + cookies := out.Result().Cookies() + if len(cookies) == 0 || cookies[0].Path != "/" { + t.Fatal("admin Cookie must cover the new API paths") + } + req = httptest.NewRequest(http.MethodGet, "/api/admin_stats", nil) + req.AddCookie(cookies[0]) + out = httptest.NewRecorder() + engine.ServeHTTP(out, req) + if out.Code != 200 { + t.Fatalf("authenticated admin: %d %s", out.Code, out.Body.String()) + } +} + +func TestCamelCaseContract(t *testing.T) { + router := NewRouter(&App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()}, fstest.MapFS{}) + router.Handle("site_info", func(c *Ctx) error { + return c.OK(M{"user_id": int64(9007199254740993), "cycle_length": c.InInt("cycle_length"), "profile_data": c.InMap("profile_data"), "page_size": c.Q("page_size"), "period_visible": c.Form("period_visible"), "avatar_path": "uploads/some_file.jpg"}) + }) + engine := router.Engine(false) + out := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/siteInfo?pageSize=20", strings.NewReader(`{"cycleLength":25,"profileData":{"startDate":"2026-10-05"}}`)) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(out, req) + body := out.Body.String() + for _, expected := range []string{`"userId":9007199254740993`, `"cycleLength":25`, `"profileData":{"startDate":"2026-10-05"}`, `"pageSize":"20"`, `uploads/some_file.jpg`} { + if !strings.Contains(body, expected) { + t.Fatalf("missing %s: %d %s", expected, out.Code, body) + } + } + if strings.Contains(body, `"cycle_length"`) { + t.Fatal("snake field leaked onto camel API") + } + // Legacy routes retain their existing JSON fields. + out = httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/backend/api/index.php?action=site_info", nil)) + if !strings.Contains(out.Body.String(), `"user_id"`) { + t.Fatal("legacy contract changed") + } + var upload bytes.Buffer + form := multipart.NewWriter(&upload) + form.WriteField("periodVisible", "0") + form.Close() + req = httptest.NewRequest(http.MethodPost, "/api/siteInfo", &upload) + req.Header.Set("Content-Type", form.FormDataContentType()) + out = httptest.NewRecorder() + engine.ServeHTTP(out, req) + if !strings.Contains(out.Body.String(), `"periodVisible":"0"`) { + t.Fatalf("camel multipart: %s", out.Body.String()) + } + out = httptest.NewRecorder() + engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/api/adminStats", nil)) + if out.Code != 401 { + t.Fatal("camel action bypassed admin authentication") + } +} diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go new file mode 100644 index 0000000..2e5e6e5 --- /dev/null +++ b/past-go/internal/notify/notify.go @@ -0,0 +1,162 @@ +// Package notify 对应 cron_notify.php:定时扫描 activity_log,补发未成功的订阅消息。 +// PHP 版本靠外部 cron 触发;Go 版本在进程内按固定间隔运行。 +package notify + +import ( + "log/slog" + "strings" + "time" + + "past/internal/store" + "past/internal/wechat" +) + +const ( + maxBatch = 20 + maxRetries = 3 + scanMinutes = 30 +) + +var actionTypes = []string{ + "login", "timeline_create", "album_create", "photo_upload", "diary_create", "diary_comment_add", + "song_add", "checkin_do", "period_add", "period_sex_add", "pet_adopt", "pet_daily", "pet_rename", "pet_all_done", +} + +var actionLabels = map[string]string{ + "login": "登录了", "timeline_create": "新增了记忆日", "album_create": "创建了新相册", "photo_upload": "上传了新照片", + "diary_create": "写了新的日记", "diary_comment_add": "评论了日记", "song_add": "添加了新歌曲", "checkin_do": "完成了打卡", + "period_add": "记录了姨妈期", "period_sex_add": "记录了亲密时光", "pet_adopt": "领养了新宠物", "pet_daily": "照顾了宠物", + "pet_rename": "给宠物改了名", "pet_all_done": "完成了所有宠物任务", +} + +var pageMap = map[string]string{ + "login": "pages/index/index", "timeline_create": "pages/timeline/timeline", "album_create": "pages/albums/albums", + "photo_upload": "pages/albums/albums", "diary_create": "pages/diary/diary", "diary_comment_add": "pages/diary/diary", + "song_add": "pages/lovewords/lovewords", "checkin_do": "pages/punch/punch", "period_add": "pages/period/period", + "period_sex_add": "pages/period/period", "pet_adopt": "pages/pet/pet", "pet_daily": "pages/pet/pet", + "pet_rename": "pages/pet/pet", "pet_all_done": "pages/pet/pet", +} + +func mbSub(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + } + return s +} + +// Run 按 interval 周期执行一次补发(阻塞,需 go 调用) +func Run(db *store.DB, wx *wechat.Client, interval time.Duration) { + for { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("cron_notify panic", "err", r) + } + }() + Once(db, wx, false) + }() + time.Sleep(interval) + } +} + +// Once 执行一次扫描;force=true 时不限时间且忽略重试上限(对应 --force) +func Once(db *store.DB, wx *wechat.Client, force bool) { + now := time.Now().Format("2006-01-02 15:04:05") + ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",") + args := make([]any, 0, len(actionTypes)+1) + for _, a := range actionTypes { + args = append(args, a) + } + var sql string + if force { + sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl WHERE nl.status = 'success') AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20" + } else { + sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.created_at >= ? AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl WHERE nl.status = 'success') AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20" + args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05")) + } + activities, err := db.Query(sql, args...) + if err != nil { + slog.Error("cron_notify 查询失败", "err", err) + return + } + if len(activities) == 0 { + return + } + processed, success, fail, skip := 0, 0, 0, 0 + for _, a := range activities { + activityID, coupleID, userID, actionType := a.Int("id"), a.Int("couple_id"), a.Int("user_id"), a.Str("action_type") + nickname := a.Str("nickname") + if nickname == "" { + nickname = "你的TA" + } + partnerID := partnerUserID(db, userID) + if partnerID == 0 { + skip++ + continue + } + cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, partnerID) + retry := int64(0) + if cnt != nil { + retry = cnt.Int("cnt") + } + if retry >= maxRetries && !force { + skip++ + continue + } + // buildNotifyData + date := a.Str("created_at") + if len(date) >= 10 { + date = date[:10] + } + desc := a.Str("description") + label, ok := actionLabels[actionType] + if !ok { + label = "有了新操作" + } + page, ok := pageMap[actionType] + if !ok { + page = "pages/index/index" + } + thing1 := nickname + " " + label + thing2 := desc + if thing2 == "" { + thing2 = thing1 + } + data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"} + + ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID) + status, errMsg := "success", "" + if !ok { + status, errMsg = "failed", "发送失败(可能用户未订阅或微信接口返回错误)" + } + if retry == 0 { + db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + activityID, coupleID, userID, actionType, "notify", partnerID, status, errMsg, now) + } else { + db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errMsg, now, activityID, partnerID) + } + processed++ + if ok { + success++ + } else { + fail++ + } + } + slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip) +} + +func partnerUserID(db *store.DB, userID int64) int64 { + u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID) + if u == nil || u.Int("couple_id") == 0 { + return 0 + } + cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id")) + if cp == nil { + return 0 + } + if cp.Int("user1_id") == userID { + return cp.Int("user2_id") + } + return cp.Int("user1_id") +} diff --git a/past-go/internal/store/db.go b/past-go/internal/store/db.go new file mode 100644 index 0000000..91fb13f --- /dev/null +++ b/past-go/internal/store/db.go @@ -0,0 +1,374 @@ +// Package store 封装 MySQL 访问。查询结果统一返回 Row(map),并按列类型转换成与 PHP PDO(mysqlnd) +// 一致的 JSON 表现:整数列 → 数字,DECIMAL/聚合 SUM/AVG → 字符串,日期时间 → 字符串,NULL → null。 +package store + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type DB struct { + *sql.DB + // NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为: + // 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。 + NumericAsString bool +} + +// Row 一行查询结果,键为列名(或别名)。 +type Row map[string]any + +func Open(dsn string) (*DB, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + return &DB{DB: db}, nil +} + +// Querier 由 *DB 与 *Tx 共同实现,便于处理函数在事务内外复用。 +type Querier interface { + Query(query string, args ...any) ([]Row, error) + QueryOne(query string, args ...any) (Row, error) + Exec(query string, args ...any) (sql.Result, error) + Insert(query string, args ...any) (int64, error) +} + +type sqlRunner interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func (d *DB) Query(query string, args ...any) ([]Row, error) { + return queryRows(d.DB, d.NumericAsString, query, args...) +} + +// QueryOne 返回第一行;无结果时返回 nil, nil(对应 PDO fetch() 返回 false)。 +func (d *DB) QueryOne(query string, args ...any) (Row, error) { + return queryOne(d.DB, d.NumericAsString, query, args...) +} + +func (d *DB) Exec(query string, args ...any) (sql.Result, error) { + return d.DB.ExecContext(context.Background(), query, args...) +} + +// Insert 执行 INSERT 并返回自增 ID。 +func (d *DB) Insert(query string, args ...any) (int64, error) { + res, err := d.Exec(query, args...) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Tx 事务 +type Tx struct { + *sql.Tx + numStr bool +} + +func (d *DB) Begin() (*Tx, error) { + tx, err := d.DB.Begin() + if err != nil { + return nil, err + } + return &Tx{tx, d.NumericAsString}, nil +} + +func (t *Tx) Query(query string, args ...any) ([]Row, error) { + return queryRows(t.Tx, t.numStr, query, args...) +} +func (t *Tx) QueryOne(query string, args ...any) (Row, error) { + return queryOne(t.Tx, t.numStr, query, args...) +} +func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { + return t.Tx.ExecContext(context.Background(), query, args...) +} +func (t *Tx) Insert(query string, args ...any) (int64, error) { + res, err := t.Exec(query, args...) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error) { + rows, err := queryRows(r, numStr, query, args...) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + return rows[0], nil +} + +func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, error) { + rs, err := r.QueryContext(context.Background(), query, args...) + if err != nil { + return nil, err + } + defer rs.Close() + cols, err := rs.Columns() + if err != nil { + return nil, err + } + types, err := rs.ColumnTypes() + if err != nil { + return nil, err + } + kinds := make([]colKind, len(cols)) + for i, t := range types { + kinds[i] = kindOf(t.DatabaseTypeName()) + if numStr { + kinds[i] = kindString + } + } + out := make([]Row, 0, 8) + raw := make([]sql.RawBytes, len(cols)) + ptrs := make([]any, len(cols)) + for i := range raw { + ptrs[i] = &raw[i] + } + for rs.Next() { + if err := rs.Scan(ptrs...); err != nil { + return nil, err + } + row := make(Row, len(cols)) + for i, c := range cols { + row[c] = convert(kinds[i], raw[i]) + } + out = append(out, row) + } + return out, rs.Err() +} + +type colKind int + +const ( + kindString colKind = iota + kindInt + kindFloat +) + +func kindOf(dbType string) colKind { + t := strings.ToUpper(dbType) + t = strings.TrimPrefix(t, "UNSIGNED ") + switch t { + case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "YEAR", "BIT": + return kindInt + case "FLOAT", "DOUBLE": + return kindFloat + default: + // DECIMAL / VARCHAR / TEXT / DATETIME / DATE / JSON ... 在 PDO 下均为字符串 + return kindString + } +} + +func convert(k colKind, b sql.RawBytes) any { + if b == nil { + return nil + } + switch k { + case kindInt: + if n, err := strconv.ParseInt(string(b), 10, 64); err == nil { + return n + } + return string(b) + case kindFloat: + if f, err := strconv.ParseFloat(string(b), 64); err == nil { + return f + } + return string(b) + default: + return string(b) + } +} + +// ---- Row 取值辅助(对应 PHP 弱类型读取)---- + +func (r Row) Int(key string) int64 { + return ToInt(r[key]) +} + +func (r Row) Str(key string) string { + return ToStr(r[key]) +} + +func (r Row) IsNull(key string) bool { + v, ok := r[key] + return !ok || v == nil +} + +// ToInt 模拟 PHP intval() +func ToInt(v any) int64 { + switch x := v.(type) { + case nil: + return 0 + case int64: + return x + case int: + return int64(x) + case float64: + return int64(x) + case bool: + if x { + return 1 + } + return 0 + case string: + return atoiPrefix(x) + } + return 0 +} + +// ToFloat 模拟 PHP floatval() +func ToFloat(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case int64: + return float64(x) + case int: + return float64(x) + case float64: + return x + case bool: + if x { + return 1 + } + return 0 + case string: + s := strings.TrimSpace(x) + // PHP floatval 取前缀数字 + end := 0 + for end < len(s) && (s[end] == '-' || s[end] == '+' || s[end] == '.' || (s[end] >= '0' && s[end] <= '9') || s[end] == 'e' || s[end] == 'E') { + end++ + } + for end > 0 { + if f, err := strconv.ParseFloat(s[:end], 64); err == nil { + return f + } + end-- + } + return 0 + } + return 0 +} + +// ToStr 模拟 PHP 字符串化(数字转字符串,null 转空串) +func ToStr(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case int64: + return strconv.FormatInt(x, 10) + case int: + return strconv.Itoa(x) + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + if x { + return "1" + } + return "" + } + return fmt.Sprint(v) +} + +// Truthy 模拟 PHP 的布尔判断(empty 的反义) +func Truthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case string: + return x != "" && x != "0" + case int64: + return x != 0 + case int: + return x != 0 + case float64: + return x != 0 + case bool: + return x + case []any: + return len(x) > 0 + case map[string]any: + return len(x) > 0 + } + return true +} + +func atoiPrefix(s string) int64 { + s = strings.TrimSpace(s) + end := 0 + if end < len(s) && (s[end] == '-' || s[end] == '+') { + end++ + } + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if end == 0 { + return 0 + } + n, err := strconv.ParseInt(s[:end], 10, 64) + if err != nil { + // 可能是 "12.5" 这种,取整数部分 + if f, ferr := strconv.ParseFloat(s, 64); ferr == nil { + return int64(f) + } + return 0 + } + return n +} + +// ExecNoFK 在一条独占连接上临时关闭外键检查执行 fn(用于 PHP 代码里向 pet_dynamics 写 user_id=0 的场景)。 +func (d *DB) ExecNoFK(fn func(q Querier) error) error { + conn, err := d.DB.Conn(context.Background()) + if err != nil { + return err + } + defer conn.Close() + if _, err := conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 0"); err != nil { + return err + } + defer conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 1") + return fn(&connQuerier{conn, d.NumericAsString}) +} + +type connQuerier struct { + *sql.Conn + numStr bool +} + +func (c *connQuerier) Query(query string, args ...any) ([]Row, error) { + return queryRows(c.Conn, c.numStr, query, args...) +} +func (c *connQuerier) QueryOne(query string, args ...any) (Row, error) { + return queryOne(c.Conn, c.numStr, query, args...) +} +func (c *connQuerier) Exec(query string, args ...any) (sql.Result, error) { + return c.Conn.ExecContext(context.Background(), query, args...) +} +func (c *connQuerier) Insert(query string, args ...any) (int64, error) { + res, err := c.Exec(query, args...) + if err != nil { + return 0, err + } + return res.LastInsertId() +} diff --git a/past-go/internal/store/schema.go b/past-go/internal/store/schema.go new file mode 100644 index 0000000..44f3547 --- /dev/null +++ b/past-go/internal/store/schema.go @@ -0,0 +1,165 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type addColumn struct { + Table, Column, DDL string +} + +// InitSchema 对应 install.php 的 initDatabase():建表、补列、默认数据、历史数据修正。 +// PHP 版本每次请求都执行;Go 版本只在启动时执行一次。对已迁移的线上库幂等。 +func (db *DB) InitSchema() error { + // 会话级设置(SET NAMES / FOREIGN_KEY_CHECKS)必须落在同一条连接上,因此整个初始化独占一条连接 + conn, err := db.DB.Conn(context.Background()) + if err != nil { + return err + } + defer conn.Close() + d := &connRunner{conn} + if _, err := d.Exec("SET NAMES utf8mb4"); err != nil { + return err + } + // 与 PHP 一致:建表阶段关闭外键检查(表之间存在前向引用),结束后恢复 + if _, err := d.Exec("SET FOREIGN_KEY_CHECKS = 0"); err != nil { + return err + } + defer d.Exec("SET FOREIGN_KEY_CHECKS = 1") + + for _, stmt := range createTables { + if _, err := d.Exec(stmt); err != nil { + name := strings.Fields(stmt)[5] + return fmt.Errorf("建表 %s 失败: %w", name, err) + } + } + + // 默认管理员(密码 admin123) + cnt, err := d.QueryOne("SELECT COUNT(*) as cnt FROM admins") + if err != nil { + return err + } + if cnt.Int("cnt") == 0 { + hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + // 与 PHP password_hash 一致使用 $2y$ 前缀(算法相同) + phpHash := "$2y$" + strings.TrimPrefix(string(hash), "$2a$") + if _, err := d.Exec("INSERT INTO admins (username, password) VALUES (?, ?)", "admin", phpHash); err != nil { + return err + } + } + + // 补列(含 users.phone 的特殊迁移逻辑) + for _, ac := range addColumns { + exists, err := d.columnExists(ac.Table, ac.Column) + if err != nil { + return err + } + if exists { + continue + } + if _, err := d.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", ac.Table, ac.Column, ac.DDL)); err != nil { + return fmt.Errorf("ALTER TABLE %s ADD COLUMN %s: %w", ac.Table, ac.Column, err) + } + if ac.Table == "users" && ac.Column == "phone" { + d.Exec("UPDATE users SET phone = username WHERE phone = '' OR phone IS NULL") + d.Exec("UPDATE users SET phone = CONCAT(id, '_old') WHERE phone = '' OR phone IS NULL") + d.Exec("CREATE UNIQUE INDEX idx_users_phone ON users(phone)") + } + } + + // period 相关表 couple_id 允许 NULL:未绑定情侣时按 NULL 保存(对齐 cidOrNil 的设计)。 + // 旧库建表时为 NOT NULL,这里做幂等的放宽约束迁移。 + for _, t := range []string{"period_records", "period_daily_records", "period_sex_records"} { + if _, err := d.Exec("ALTER TABLE " + t + " MODIFY couple_id INT NULL"); err != nil { + slog.Debug("period 表 couple_id 修改为 NULL 失败", "table", t, "err", err) + } + } + + // 确保 username 唯一索引(PHP 用 try/catch 忽略已存在) + if _, err := d.Exec("CREATE UNIQUE INDEX idx_users_username ON users(username)"); err != nil { + slog.Debug("idx_users_username 已存在或创建失败", "err", err) + } + + // 修正已有照片 created_by 为 NULL 的记录(补齐为第一个用户) + np, err := d.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE created_by IS NULL") + if err != nil { + return err + } + if np.Int("cnt") > 0 { + first, _ := d.QueryOne("SELECT id FROM users LIMIT 1") + if first != nil { + d.Exec("UPDATE photos SET created_by = ? WHERE created_by IS NULL", first.Int("id")) + } + } + + // 修正已有错误设置值 + d.Exec("UPDATE settings SET `value` = ? WHERE `key` = ? AND `value` = ?", "我们past", "site_title", "叁叁肆计划巡演记录视频") + + // 默认站点设置 + defaults := [][2]string{ + {"site_title", "我们past"}, + {"site_subtitle", "记录每一个心动瞬间"}, + {"theme_color", "#ff6b81"}, + {"bg_style", "gradient"}, + {"show_timeline", "1"}, + {"show_album", "1"}, + {"show_diary", "1"}, + {"show_anniversary", "1"}, + {"show_songs", "1"}, + {"show_checkin", "1"}, + } + for _, kv := range defaults { + if _, err := d.Exec("INSERT IGNORE INTO settings (`key`, `value`) VALUES (?, ?)", kv[0], kv[1]); err != nil { + return err + } + } + + // 补全历史数据:last_login_at 为 NULL 的用户设为 created_at + d.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL") + + // 爱爱记录 measure 字段 '无性行为' → '无措施' + d.Exec("UPDATE period_sex_records SET measure = '无措施' WHERE measure = '无性行为'") + + // index.php 的 diary_list/diary_detail 引用了 diary_likes / diary_favorites,但 install.php 从未建表 + // (线上库应已存在)。这里仅在缺失时补建,避免全新环境 500;已存在则不做任何改动。 + for _, t := range []string{"diary_likes", "diary_favorites"} { + if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS ` + t + ` ( + id INT AUTO_INCREMENT PRIMARY KEY, + diary_id INT NOT NULL, + user_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_diary_user (diary_id, user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil { + return err + } + } + + return nil +} + +// connRunner 让单条 *sql.Conn 具备 Query/QueryOne/Exec 能力 +type connRunner struct{ *sql.Conn } + +func (c *connRunner) Query(query string, args ...any) ([]Row, error) { + return queryRows(c.Conn, false, query, args...) +} +func (c *connRunner) QueryOne(query string, args ...any) (Row, error) { + return queryOne(c.Conn, false, query, args...) +} +func (c *connRunner) Exec(query string, args ...any) (sql.Result, error) { + return c.Conn.ExecContext(context.Background(), query, args...) +} + +func (d *connRunner) columnExists(table, column string) (bool, error) { + rows, err := d.Query("SHOW COLUMNS FROM "+table+" LIKE ?", column) + if err != nil { + return false, err + } + return len(rows) > 0, nil +} diff --git a/past-go/internal/store/schema_sql.go b/past-go/internal/store/schema_sql.go new file mode 100644 index 0000000..e8e096a --- /dev/null +++ b/past-go/internal/store/schema_sql.go @@ -0,0 +1,421 @@ +package store + +// 本文件由 backend/install.php 的 SQL 语句自动提取生成,表结构与线上 PHP 版本保持一致。 +// 请勿手工改动字段定义;如需新增字段,追加到 addColumns 中。 + +// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。 +var createTables = []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(20) UNIQUE, + openid VARCHAR(100) UNIQUE, + phone VARCHAR(50) DEFAULT '', + username VARCHAR(100) DEFAULT '', + password VARCHAR(255) DEFAULT '', + nickname VARCHAR(100) DEFAULT '', + avatar VARCHAR(500) DEFAULT '', + gender VARCHAR(10) DEFAULT '', + couple_id INT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME DEFAULT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS couples ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_space_id VARCHAR(50) UNIQUE, + user1_id INT NOT NULL, + user2_id INT NOT NULL, + love_start_date VARCHAR(20) DEFAULT '', + love_story TEXT, + status VARCHAR(20) DEFAULT 'active', + data_deleted TINYINT DEFAULT 0, + period_visible TINYINT DEFAULT 0 COMMENT '男方是否可见女生日历 0-不可见 1-可见', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS invitations ( + id INT AUTO_INCREMENT PRIMARY KEY, + from_user_id INT NOT NULL, + to_user_id INT DEFAULT NULL, + to_phone VARCHAR(50) DEFAULT '', + status VARCHAR(20) DEFAULT 'pending', + message TEXT, + invite_code VARCHAR(100) DEFAULT '', + type VARCHAR(20) DEFAULT 'phone', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + responded_at DATETIME, + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS unbind_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + requester_id INT NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + responded_at DATETIME DEFAULT NULL, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS invite_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + code VARCHAR(100) NOT NULL UNIQUE, + used INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS timeline ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT, + event_date VARCHAR(20) NOT NULL, + location VARCHAR(200) DEFAULT '', + images TEXT, + icon VARCHAR(20) DEFAULT '❤️', + sort_order INT DEFAULT 0, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS albums ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + cover VARCHAR(500) DEFAULT '', + sort_order INT DEFAULT 0, + lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度', + lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度', + location VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称', + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS photos ( + id INT AUTO_INCREMENT PRIMARY KEY, + album_id INT NOT NULL, + path VARCHAR(500) NOT NULL, + description TEXT, + taken_at VARCHAR(50) DEFAULT '', + location VARCHAR(200) DEFAULT '', + lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度', + lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度', + activity VARCHAR(200) DEFAULT '', + batch_id VARCHAR(50) DEFAULT '', + sort_order INT DEFAULT 0, + created_by INT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS diaries ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT, + mood VARCHAR(20) DEFAULT '😊', + weather VARCHAR(20) DEFAULT '☀️', + diary_date VARCHAR(20) NOT NULL, + images TEXT, + is_anniversary INT DEFAULT 0, + region VARCHAR(100) DEFAULT '' COMMENT '发布地区', + lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度', + lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度', + location VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称', + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS songs ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + song_name VARCHAR(255) NOT NULL, + artist VARCHAR(255) NOT NULL, + note VARCHAR(120) DEFAULT '', + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS checkins ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + icon VARCHAR(20) DEFAULT '✅', + type VARCHAR(10) DEFAULT 'dual', + frequency VARCHAR(10) DEFAULT 'daily', + is_completed INT DEFAULT 0, + completed_by_me INT DEFAULT 0, + completed_by_partner INT DEFAULT 0, + my_checkin_time DATETIME, + partner_checkin_time DATETIME, + my_streak INT DEFAULT 0, + partner_streak INT DEFAULT 0, + my_photo VARCHAR(500) DEFAULT '', + partner_photo VARCHAR(500) DEFAULT '', + my_note VARCHAR(200) DEFAULT '', + partner_note VARCHAR(200) DEFAULT '', + completed_at DATETIME, + sort_order INT DEFAULT 0, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS checkin_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + checkin_id INT NOT NULL, + couple_id INT NOT NULL, + user_id INT NOT NULL, + photo VARCHAR(500) DEFAULT '', + note VARCHAR(200) DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS period_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NULL, + user_id INT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + cycle_length INT DEFAULT 28, + period_length INT DEFAULT 5, + symptoms VARCHAR(500) DEFAULT '', + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS period_daily_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NULL, + user_id INT NOT NULL, + record_date DATE NOT NULL, + period ENUM('0','1','2','3') DEFAULT '0' COMMENT '0-无 1-少量 2-中量 3-大量', + sex ENUM('0','1') DEFAULT '0' COMMENT '0-无 1-爱爱', + symptoms VARCHAR(200) DEFAULT '' COMMENT '症状,逗号分隔', + mood VARCHAR(100) DEFAULT '' COMMENT '心情', + discharge VARCHAR(100) DEFAULT '' COMMENT '白带', + temperature DECIMAL(4,1) DEFAULT NULL COMMENT '体温', + weight DECIMAL(5,1) DEFAULT NULL COMMENT '体重', + note TEXT COMMENT '备注', + period_detail TEXT COMMENT '经期详情JSON(量/颜色/疼痛)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_couple (record_date, couple_id), + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS period_sex_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NULL, + user_id INT NOT NULL, + record_date DATE NOT NULL, + record_time TIME DEFAULT NULL COMMENT '记录时间,精确到分钟', + measure VARCHAR(100) DEFAULT '' COMMENT '措施类型', + measure_custom VARCHAR(200) DEFAULT '' COMMENT '其他措施自定义内容', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_date_couple (record_date, couple_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS settings ( + ` + "`" + `key` + "`" + ` VARCHAR(255) PRIMARY KEY, + ` + "`" + `value` + "`" + ` TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS diary_comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + diary_id INT NOT NULL, + couple_id INT NOT NULL, + user_id INT NOT NULL, + content TEXT NOT NULL, + parent_id INT DEFAULT 0 COMMENT '回复的评论ID,0为顶级评论', + reply_to_user_id INT DEFAULT NULL COMMENT '被回复的用户ID', + reply_to_username VARCHAR(100) DEFAULT '' COMMENT '被回复的用户昵称', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (diary_id) REFERENCES diaries(id) ON DELETE CASCADE, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_diary (diary_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS subscribe_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + template_id VARCHAR(100) NOT NULL, + template_type VARCHAR(50) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_template (user_id, template_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS activity_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + user_id INT NOT NULL, + action_type VARCHAR(50) NOT NULL, + description VARCHAR(500) NOT NULL, + detail TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_couple_time (couple_id, created_at DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS admins ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS user_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + token VARCHAR(128) NOT NULL UNIQUE, + user_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS notify_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + activity_id INT NOT NULL, + couple_id INT NOT NULL, + user_id INT NOT NULL, + action_type VARCHAR(50) NOT NULL, + template_type VARCHAR(50) NOT NULL, + to_user_id INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT '', + error_msg TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at DATETIME DEFAULT NULL, + UNIQUE KEY uk_activity (activity_id, to_user_id), + INDEX idx_status_time (status, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pets ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL DEFAULT '小狗狗', + breed VARCHAR(20) NOT NULL DEFAULT 'golden' COMMENT 'golden/husky/corgi/shiba', + gender TINYINT NOT NULL DEFAULT 1 COMMENT '1-男 2-女', + stage INT NOT NULL DEFAULT 1 COMMENT '1幼犬 2少年 3青年 4成年 5终极', + level INT NOT NULL DEFAULT 1, + exp INT NOT NULL DEFAULT 0, + happiness INT NOT NULL DEFAULT 100, + hunger INT NOT NULL DEFAULT 100, + energy INT NOT NULL DEFAULT 100, + cleanliness INT NOT NULL DEFAULT 100, + adopted_at DATE NOT NULL, + created_by INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pet_dailies ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + date DATE NOT NULL, + feed_u1 TINYINT DEFAULT 0, + feed_u2 TINYINT DEFAULT 0, + water_u1 TINYINT DEFAULT 0, + water_u2 TINYINT DEFAULT 0, + walk_u1 TINYINT DEFAULT 0, + walk_u2 TINYINT DEFAULT 0, + bath_u1 TINYINT DEFAULT 0, + bath_u2 TINYINT DEFAULT 0, + sleep_u1 TINYINT DEFAULT 0, + sleep_u2 TINYINT DEFAULT 0, + play_u1 TINYINT DEFAULT 0, + play_u2 TINYINT DEFAULT 0, + all_done TINYINT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_couple_date (couple_id, date), + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pet_achievements ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + achievement_key VARCHAR(50) NOT NULL, + unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_couple_ach (couple_id, achievement_key), + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pet_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + item_key VARCHAR(50) NOT NULL, + item_type VARCHAR(20) NOT NULL COMMENT 'accessory/collar/scarf/background/consumable', + quantity INT DEFAULT 1, + equipped TINYINT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + KEY idx_couple (couple_id), + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS pet_dynamics ( + id INT AUTO_INCREMENT PRIMARY KEY, + couple_id INT NOT NULL, + user_id INT NOT NULL, + action VARCHAR(30) NOT NULL, + message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + KEY idx_couple_time (couple_id, created_at DESC), + FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, +} + +// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。 +var addColumns = []addColumn{ + {"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"}, + {"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"}, + {"checkins", "completed_by_me", "INT DEFAULT 0"}, + {"checkins", "completed_by_partner", "INT DEFAULT 0"}, + {"checkins", "my_checkin_time", "DATETIME"}, + {"checkins", "partner_checkin_time", "DATETIME"}, + {"checkins", "my_streak", "INT DEFAULT 0"}, + {"checkins", "partner_streak", "INT DEFAULT 0"}, + {"checkins", "my_photo", "VARCHAR(500) DEFAULT ''"}, + {"checkins", "partner_photo", "VARCHAR(500) DEFAULT ''"}, + {"checkins", "my_note", "VARCHAR(200) DEFAULT ''"}, + {"checkins", "partner_note", "VARCHAR(200) DEFAULT ''"}, + {"period_daily_records", "period_detail", "TEXT COMMENT '经期详情JSON(量/颜色/疼痛)'"}, + {"users", "openid", "VARCHAR(100) UNIQUE"}, + {"users", "phone", "VARCHAR(50) DEFAULT ''"}, + {"users", "gender", "VARCHAR(10) DEFAULT ''"}, + {"users", "period_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '姨姨:是否对伴侣可见'"}, + {"users", "age", "INT DEFAULT 0"}, + {"users", "birthday", "VARCHAR(20) DEFAULT ''"}, + {"users", "last_login_at", "DATETIME DEFAULT NULL"}, + {"users", "region", "VARCHAR(100) DEFAULT '' COMMENT '地区,如云南楚雄'"}, + {"users", "location", "VARCHAR(200) DEFAULT '' COMMENT '模糊定位,如纬度,经度'"}, + {"users", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度'"}, + {"users", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度'"}, + {"users", "last_login_location", "VARCHAR(200) DEFAULT '' COMMENT '最近一次登录地址'"}, + {"users", "ip_geo_date", "DATE DEFAULT NULL COMMENT '最后一次 IP 定位日期(每日限流)'"}, + {"users", "last_ip", "VARCHAR(64) DEFAULT '' COMMENT '最后一次 IP 定位的客户端 IP 地址'"}, + {"invitations", "to_phone", "VARCHAR(50) DEFAULT ''"}, + {"invitations", "invite_code", "VARCHAR(100) DEFAULT ''"}, + {"invitations", "type", "VARCHAR(20) DEFAULT 'phone'"}, + {"couples", "couple_space_id", "VARCHAR(50) UNIQUE"}, + {"couples", "period_source_user_id", "INT DEFAULT NULL COMMENT '绑定前姨姨记录选定的来源用户'"}, + {"couples", "data_deleted", "TINYINT DEFAULT 0"}, + {"photos", "taken_at", "VARCHAR(50) DEFAULT ''"}, + {"photos", "location", "VARCHAR(200) DEFAULT ''"}, + {"photos", "batch_id", "VARCHAR(50) DEFAULT ''"}, + {"photos", "activity", "VARCHAR(200) DEFAULT ''"}, + {"photos", "created_by", "INT DEFAULT NULL"}, + {"diaries", "region", "VARCHAR(100) DEFAULT '' COMMENT '发布地区' AFTER is_anniversary"}, + {"diaries", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER region"}, + {"diaries", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"}, + {"diaries", "location", "VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称' AFTER lng"}, + {"albums", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER sort_order"}, + {"albums", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"}, + {"albums", "location", "VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称' AFTER lng"}, + {"photos", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER location"}, + {"photos", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"}, + {"timeline", "location", "VARCHAR(200) DEFAULT '' AFTER event_date"}, + {"timeline", "repeat_interval", "INT DEFAULT 0 AFTER location"}, + {"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"}, + {"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"}, + {"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"}, +} diff --git a/past-go/internal/upload/upload.go b/past-go/internal/upload/upload.go new file mode 100644 index 0000000..8af0357 --- /dev/null +++ b/past-go/internal/upload/upload.go @@ -0,0 +1,194 @@ +// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。 +package upload + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// 上传错误码(对应 config.php UPLOAD_ERR_* 常量) +const ( + ErrNoFileReceived = 1001 + ErrFileFieldMissing = 1002 + ErrHandleFailed = 1003 + ErrRawBodySave = 1004 + ErrAlbumNotFound = 1005 + ErrNotLoggedIn = 1006 + ErrNotBound = 1007 + ErrMethodNotAllowed = 1008 + ErrPartnerInvalid = 1009 + ErrFileTooLarge = 1010 +) + +var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true} + +// ImageChecker 图片内容安全检测(由 wechat 包实现) +type ImageChecker interface { + CheckImage(filePath, openid string) (bool, string) +} + +type Uploader struct { + // Dir 上传根目录(UPLOAD_DIR) + Dir string + MaxSize int64 + Checker ImageChecker +} + +// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...] +type Result struct { + Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg + Error string +} + +// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId} +func AvatarPath(userID string) string { return "avatars/" + userID } + +// CouplePath 对应 couplePath($spaceId, $type) +func CouplePath(spaceID, typ string) string { + if typ != "" { + return "couples/" + spaceID + "/" + typ + } + return "couples/" + spaceID +} + +// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type} +func UserFilePath(userID int64, typ string) string { + p := fmt.Sprintf("users/%d", userID) + if typ != "" { + p += "/" + typ + } + return p +} + +// NewFilename 对应 date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . ext +func NewFilename(ext string) string { + b := make([]byte, 4) + rand.Read(b) + return time.Now().Format("20060102150405") + "_" + hex.EncodeToString(b) + "." + ext +} + +// Ext 取小写扩展名(不含点) +func Ext(name string) string { + return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) +} + +// Handle 对应 handleUpload($file, $subDir, $prefixPath) +func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result { + if fh == nil { + return Result{Error: "未接收到文件"} + } + f, err := fh.Open() + if err != nil { + return Result{Error: "未接收到文件"} + } + defer f.Close() + return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath) +} + +// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源 +func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result { + if size > u.MaxSize { + return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)} + } + ext := Ext(filename) + if !allowedExts[ext] { + return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"} + } + // MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝 + head := make([]byte, 512) + n, _ := io.ReadFull(f, head) + // finfo 只返回主类型(不带 "; charset=...") + mime := http.DetectContentType(head[:n]) + if i := strings.Index(mime, ";"); i >= 0 { + mime = strings.TrimSpace(mime[:i]) + } + if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" { + return Result{Error: "文件MIME类型不匹配: " + mime} + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return Result{Error: "文件读取失败"} + } + return u.save(f, "", ext, subDir, prefixPath) +} + +// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验) +func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result { + return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath) +} + +// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。 +// namePrefix 为文件名前缀(如 "bg_")。 +func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result { + return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath) +} + +func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result { + rel := "uploads/" + dir := u.Dir + if prefixPath != "" { + rel += prefixPath + "/" + dir = filepath.Join(dir, filepath.FromSlash(prefixPath)) + } + if subDir != "" { + rel += subDir + "/" + dir = filepath.Join(dir, filepath.FromSlash(subDir)) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()} + } + name := namePrefix + NewFilename(ext) + dest := filepath.Join(dir, name) + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()} + } + if _, err := io.Copy(out, src); err != nil { + out.Close() + os.Remove(dest) + return Result{Error: "文件保存失败。系统错误: " + err.Error()} + } + out.Close() + slog.Info("upload saved", "path", rel+name) + + if u.Checker != nil { + if pass, msg := u.Checker.CheckImage(dest, ""); !pass { + os.Remove(dest) + return Result{Error: msg} + } + } + return Result{Path: rel + name} +} + +// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。 +func (u *Uploader) Remove(relPath string) { + if relPath == "" || strings.Contains(relPath, "..") { + return + } + p := strings.TrimPrefix(relPath, "uploads/") + if p == relPath { + return + } + os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p))) +} + +// Abs 返回相对路径对应的绝对路径 +func (u *Uploader) Abs(relPath string) string { + p := strings.TrimPrefix(relPath, "uploads/") + return filepath.Join(u.Dir, filepath.FromSlash(p)) +} + +// Exists 文件是否存在 +func (u *Uploader) Exists(relPath string) bool { + st, err := os.Stat(u.Abs(relPath)) + return err == nil && !st.IsDir() +} diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go new file mode 100644 index 0000000..2442c79 --- /dev/null +++ b/past-go/internal/wechat/wechat.go @@ -0,0 +1,317 @@ +// Package wechat 对应 config.php 中的微信相关函数:access_token 缓存、内容安全检测、订阅消息、jscode2session。 +package wechat + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "sync" + "time" + + "past/internal/store" +) + +type Client struct { + AppID string + Secret string + NotifyTemplateID string + DataDir string // 用于 access_token.cache + DB *store.DB + + http *http.Client + mu sync.Mutex +} + +func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client { + return &Client{ + AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, DataDir: dataDir, DB: db, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES +func (c *Client) Templates() map[string]string { + return map[string]string{"notify": c.NotifyTemplateID} +} + +func (c *Client) cacheFile() string { return filepath.Join(c.DataDir, "data", "access_token.cache") } + +// AccessToken 获取 access_token;文件缓存 115 分钟(按修改时间判断),与 PHP 一致。 +func (c *Client) AccessToken() string { + c.mu.Lock() + defer c.mu.Unlock() + cf := c.cacheFile() + if st, err := os.Stat(cf); err == nil && time.Since(st.ModTime()) < 6900*time.Second { + if b, err := os.ReadFile(cf); err == nil && len(bytes.TrimSpace(b)) > 0 { + return string(bytes.TrimSpace(b)) + } + } + u := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret) + resp, err := c.http.Get(u) + if err != nil { + return "" + } + defer resp.Body.Close() + var data struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.AccessToken == "" { + return "" + } + os.MkdirAll(filepath.Dir(cf), 0755) + os.WriteFile(cf, []byte(data.AccessToken), 0644) + return data.AccessToken +} + +func (c *Client) clearTokenCache() { + c.mu.Lock() + os.Remove(c.cacheFile()) + c.mu.Unlock() +} + +// postJSON 对应 wechatPost:非 200 返回 nil +func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any { + cl := &http.Client{Timeout: timeout} + resp, err := cl.Post(u, "application/json", bytes.NewReader(body)) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil + } + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil + } + return out +} + +func errcode(m map[string]any) int64 { + if m == nil { + return -1 + } + if v, ok := m["errcode"]; ok { + return store.ToInt(v) + } + return -1 +} + +// CheckText 文本内容安全检测(msg_sec_check)。返回 (pass, errmsg)。 +// 与 PHP 一致:token 获取失败 / 网络异常 / 非 87014 错误均放行。 +func (c *Client) CheckText(text, openid string) (bool, string) { + if bytes.TrimSpace([]byte(text)) == nil || len(bytes.TrimSpace([]byte(text))) == 0 { + return true, "" + } + tok := c.AccessToken() + if tok == "" { + return true, "" + } + body, _ := marshalUnescaped(map[string]any{ + "version": 2, "openid": openid, "scene": 2, "content": text, + }) + res := c.postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+tok, body, 10*time.Second) + if res == nil { + return true, "" + } + if errcode(res) == 87014 { + return false, "所发布内容含违规信息" + } + return true, "" +} + +// CheckImage 图片内容安全检测(media_check_async)。PHP 实现只是异步提交,结果永远放行。 +func (c *Client) CheckImage(filePath, openid string) (bool, string) { + st, err := os.Stat(filePath) + if err != nil || st.IsDir() { + return true, "" + } + tok := c.AccessToken() + if tok == "" { + return true, "" + } + f, err := os.Open(filePath) + if err != nil { + return true, "" + } + defer f.Close() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("media", filepath.Base(filePath)) + io.Copy(fw, f) + mw.WriteField("scene", "2") + mw.WriteField("openid", openid) + mw.Close() + req, _ := http.NewRequest("POST", "https://api.weixin.qq.com/wxa/media_check_async?access_token="+tok, &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := c.http.Do(req) + if err == nil { + resp.Body.Close() + } + return true, "" +} + +// Jscode2Session 通过 code 换取 openid。返回 (openid, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。 +func (c *Client) Jscode2Session(code string) (openid string, wxErr string, available bool) { + u := "https://api.weixin.qq.com/sns/jscode2session?appid=" + c.AppID + "&secret=" + c.Secret + "&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code" + resp, err := c.http.Get(u) + if err != nil { + return "", "", false + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if len(b) == 0 { + return "", "", false + } + var data map[string]any + if err := json.Unmarshal(b, &data); err != nil { + return "", "", true + } + if ec, ok := data["errcode"]; ok && store.ToInt(ec) != 0 { + msg := store.ToStr(data["errmsg"]) + if msg == "" { + msg = "未知错误" + } + return "", msg, true + } + return store.ToStr(data["openid"]), "", true +} + +// SendSubscribeMessage 对应 sendSubscribeMessage()。 +func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool { + templateID, ok := c.Templates()[templateType] + if !ok { + if activityID != 0 { + c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "模板类型不存在") + } + return false + } + db := c.DB + sub, _ := db.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) + if sub == nil { + if activityID != 0 { + c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户未订阅") + } + return false + } + user, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", toUserID) + if user == nil || user.Str("openid") == "" { + if activityID != 0 { + c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户无openid") + } + return false + } + msgData := map[string]any{} + for k, v := range data { + msgData[k] = map[string]any{"value": v} + } + if page == "" { + page = "pages/index/index" + } + post, _ := marshalUnescaped(map[string]any{ + "touser": user.Str("openid"), "template_id": templateID, "page": page, + "data": msgData, "miniprogram_state": "formal", "lang": "zh_CN", + }) + + lastError := "" + for attempt := 1; attempt <= 2; attempt++ { + tok := c.AccessToken() + if tok == "" { + lastError = "获取access_token失败" + if attempt < 2 { + c.clearTokenCache() + } + continue + } + cl := &http.Client{Timeout: 5 * time.Second} + resp, err := cl.Post("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="+tok, "application/json", bytes.NewReader(post)) + if err != nil || resp.StatusCode != 200 { + code := 0 + if resp != nil { + code = resp.StatusCode + resp.Body.Close() + } + lastError = fmt.Sprintf("HTTP %d", code) + if attempt < 2 { + c.clearTokenCache() + time.Sleep(200 * time.Millisecond) + } + continue + } + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + resp.Body.Close() + ec := errcode(result) + if ec == 0 { + if activityID != 0 { + c.LogNotifyResult(activityID, toUserID, userID, templateType, "success", "") + } + return true + } + lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec) + if ec == 40001 && attempt < 2 { + c.clearTokenCache() + continue + } + // 43101 可能是「一次性订阅额度已用尽」,并非用户主动关闭订阅; + // 此时删除记录会导致前端设置页显示为未勾选,误导用户。仅在 openid 无效(40003)时清理。 + if ec == 40003 { + db.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) + } + break + } + if activityID != 0 { + c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", lastError) + } + return false +} + +// LogNotifyResult 对应 logNotifyResult() +func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateType, status, errorMsg string) { + db := c.DB + now := time.Now().Format("2006-01-02 15:04:05") + lookup := userID + if lookup == 0 { + lookup = toUserID + } + u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup) + var coupleID any + if u != nil { + coupleID = u["couple_id"] + } + actionType := "" + if activityID != 0 { + a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID) + if a != nil { + actionType = a.Str("action_type") + } + } + cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, toUserID) + var err error + if cnt != nil && cnt.Int("cnt") > 0 { + _, err = db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errorMsg, now, activityID, toUserID) + } else { + _, err = db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now) + } + if err != nil { + slog.Error("logNotifyResult error", "err", err) + } +} + +func marshalUnescaped(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/past-go/web/admin/.gitignore b/past-go/web/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/past-go/web/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/past-go/web/admin/.oxlintrc.json b/past-go/web/admin/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/past-go/web/admin/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/past-go/web/admin/README.md b/past-go/web/admin/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/past-go/web/admin/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/past-go/web/admin/components.json b/past-go/web/admin/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/past-go/web/admin/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/past-go/web/admin/index.html b/past-go/web/admin/index.html new file mode 100644 index 0000000..95fd713 --- /dev/null +++ b/past-go/web/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + 我们past · 管理后台 + + +
+ + + diff --git a/past-go/web/admin/package-lock.json b/past-go/web/admin/package-lock.json new file mode 100644 index 0000000..ec721f3 --- /dev/null +++ b/past-go/web/admin/package-lock.json @@ -0,0 +1,7630 @@ +{ + "name": "admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin", + "version": "0.0.0", + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.103.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cn": "^0.3.0", + "lucide-react": "^1.46.0", + "radix-ui": "^1.6.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "shadcn": "^4.21.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@types/node": "^24.13.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.75.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", + "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^0.8.0", + "commander": "^11.1.0", + "conf": "^10.2.0", + "dotenv": "^17.2.1", + "enquirer": "^2.4.1", + "env-paths": "^2.2.1", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "open": "^8.4.2", + "picomatch": "^4.0.4", + "systeminformation": "^5.22.11", + "undici": "^7.11.0", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/primitives": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-0.8.0.tgz", + "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz", + "integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz", + "integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ast-types": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.3.tgz", + "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "license": "MIT", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.3.0.tgz", + "integrity": "sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q==", + "license": "MIT", + "bin": { + "cn": "bin/cn.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/conf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", + "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "license": "MIT", + "dependencies": { + "ajv": "^8.6.3", + "ajv-formats": "^2.1.1", + "atomically": "^1.7.0", + "debounce-fn": "^4.0.0", + "dot-prop": "^6.0.1", + "env-paths": "^2.2.1", + "json-schema-typed": "^7.0.3", + "onetime": "^5.1.2", + "pkg-up": "^3.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/conf/node_modules/json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "license": "BSD-2-Clause" + }, + "node_modules/conf/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.46.0.tgz", + "integrity": "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/radix-ui": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.21.0.tgz", + "integrity": "sha512-UU2mFNusW8C5rvadKdH69vERYZqUlOOlXBcf0MYhYLdTGP6DPti7X4qovCu+RTfCqsAgq/T+YfE0Vnttxh9aiw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "cn": "^0.2.4", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "kleur": "^4.1.5", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "socks": "^2.8.8", + "stringify-object": "^5.0.0", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "undici": "^7.27.2", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + }, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/shadcn/node_modules/cn": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.2.6.tgz", + "integrity": "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==", + "license": "MIT", + "bin": { + "cn": "bin/cn.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/systeminformation": { + "version": "5.33.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.11.tgz", + "integrity": "sha512-CNGk+882EQbsbPzaoxeBcYyx+nk0uTj5JzW8w0EXGSCazndNRAh9SE0V5tyMbdONGKilbIBJsyskF4WmZDk7uA==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-spinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/past-go/web/admin/package.json b/past-go/web/admin/package.json new file mode 100644 index 0000000..dff9fa9 --- /dev/null +++ b/past-go/web/admin/package.json @@ -0,0 +1,39 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.103.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cn": "^0.3.0", + "lucide-react": "^1.46.0", + "radix-ui": "^1.6.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "shadcn": "^4.21.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@types/node": "^24.13.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/past-go/web/admin/public/favicon.svg b/past-go/web/admin/public/favicon.svg new file mode 100644 index 0000000..ccd48f3 --- /dev/null +++ b/past-go/web/admin/public/favicon.svg @@ -0,0 +1,3 @@ + + 💗 + diff --git a/past-go/web/admin/public/icons.svg b/past-go/web/admin/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/past-go/web/admin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/past-go/web/admin/src/App.tsx b/past-go/web/admin/src/App.tsx new file mode 100644 index 0000000..728151b --- /dev/null +++ b/past-go/web/admin/src/App.tsx @@ -0,0 +1,54 @@ +import { Navigate, Route, Routes, useLocation } from "react-router-dom" +import { useQuery } from "@tanstack/react-query" +import { api } from "@/lib/api" +import Layout from "@/components/Layout" +import Login from "@/pages/Login" +import Dashboard from "@/pages/Dashboard" +import UsersPage from "@/pages/Users" +import CouplesPage from "@/pages/Couples" +import ContentsPage from "@/pages/Contents" +import BackgroundsPage from "@/pages/Backgrounds" +import UnbindPage from "@/pages/Unbind" +import PetsPage from "@/pages/Pets" +import SettingsPage from "@/pages/Settings" + +// 登录态:admin_check_auth(Cookie 会话) +export function useAuth() { + return useQuery({ + queryKey: ["auth"], + // admin_check_auth 与 PHP 一致:未登录时直接 401,这里把它当作 logged_in=false + queryFn: () => api<{ logged_in: boolean; username?: string }>("adminCheckAuth", null, "GET").catch(() => ({ logged_in: false })), + staleTime: 60_000, + }) +} + +export default function App() { + const auth = useAuth() + const loc = useLocation() + + if (auth.isPending) { + return
加载中…
+ } + const loggedIn = !!auth.data?.logged_in + + return ( + + : } /> + {loggedIn ? ( + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + } /> + )} + + ) +} diff --git a/past-go/web/admin/src/assets/hero.png b/past-go/web/admin/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/past-go/web/admin/src/assets/vite.svg b/past-go/web/admin/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/past-go/web/admin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/past-go/web/admin/src/components/CoupleDetailDialog.tsx b/past-go/web/admin/src/components/CoupleDetailDialog.tsx new file mode 100644 index 0000000..a10baeb --- /dev/null +++ b/past-go/web/admin/src/components/CoupleDetailDialog.tsx @@ -0,0 +1,176 @@ +import { useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { api, fmtTime, parseImages, daysSince, BREED, BREED_EMOJI, STAGE, STAGE_COLOR } from "@/lib/api" +import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview" +import { Empty, Loading, ErrorBox, RecordItem } from "@/components/common" +import { DeleteContentDialog, type DeleteContentTarget } from "@/components/UserDialogs" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" + +export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | null; onClose: () => void }) { + const qc = useQueryClient() + const q = useQuery({ + queryKey: ["admin_couple_detail", coupleId], + queryFn: () => api("adminCoupleDetail", { couple_id: coupleId }, "GET"), + enabled: !!coupleId, + }) + const [del, setDel] = useState(null) + const d = q.data + const users: any[] = d?.users || [] + const delBtn = (type: string, id: number, label: string) => ( + + ) + + return ( + !o && onClose()}> + + + 💕 情侣详情 + +
+ 情侣成员: + {users.length ? users.map((u) => ( + + + {u.nickname || u.username || "?"} (ID:{u.user_id || "-"}) + + )) : 已解除绑定} +
+
+
+ {q.isPending ? : q.isError ? : ( + + + 📅 记忆日 + 📝 日记 + 📷 相册 + 🖼️ 照片 + 🎵 歌曲 + ✅ 打卡 + 📋 操作记录 + 🐾 宠物 + + + + {!d.timeline?.length ? : d.timeline.map((t: any) => ( + {t.event_date}{delBtn("timeline", t.id, t.title)}} + footer={
}> + {t.title}{t.location && 📍{t.location}} + {t.content &&
{t.content}
} +
+ ))} +
+ + {!d.diaries?.length ? : d.diaries.map((x: any) => ( + {x.diary_date}{delBtn("diary", x.id, x.title || "日记")}} footer={}> + {x.title || "一篇日记"} + + ))} + + + {!d.albums?.length ? : d.albums.map((a: any) => ( + +
{a.title}
{a.description}
+
+ ))} +
+ + {!d.photos?.length ? : ( + <> +
{d.photos.map((p: any) => )}
+
共 {d.photos.length} 张照片
+ + )} +
+ + {!d.songs?.length ? : d.songs.map((s: any) => ( + + {s.song_name} — {s.artist}{s.note &&
📝 {s.note}
} +
+ ))} +
+ + {!d.checkins?.length ? : d.checkins.map((c: any) => ( + + {c.title || "打卡"} {c.type === "single" || c.type === "first" || c.type === "her" ? "单人" : "双人"} +
我{c.completed_by_me ? "✅" : "⬜"} TA{c.completed_by_partner ? "✅" : "⬜"}
+
+ ))} +
+ + +
+ )} + setDel(null)} onDone={() => { qc.invalidateQueries({ queryKey: ["admin_couple_detail", coupleId] }); qc.invalidateQueries({ queryKey: ["admin_contents"] }) }} /> +
+
+ ) +} + +function ActivityTab({ coupleId }: { coupleId: number }) { + const q = useQuery({ queryKey: ["admin_activity_log", coupleId], queryFn: () => api("adminActivityLog", { couple_id: coupleId }, "GET") }) + if (q.isPending) return + if (q.isError) return + if (!q.data.length) return + return ( +
+ {q.data.map((a) => ( + {fmtTime(a.created_at)}}> + {a.nickname || "?"} {a.description} + + ))} +
+ ) +} + +function Bar({ label, icon, val, color }: { label: string; icon: string; val: number; color: string }) { + const pct = Math.min(100, Math.max(0, val)) + return ( +
+
{icon} {label}{val}
+
+
+ ) +} + +// 宠物面板(情侣详情 / admin_couple_detail 返回的 pet 含 dailies/achievements 等) +export function PetPanel({ pet }: { pet: any }) { + if (!pet) return + const stageClr = STAGE_COLOR[pet.stage - 1] || STAGE_COLOR[0] + const allDone = (d: any) => ["feed", "water", "walk", "bath", "sleep", "play"].every((k) => d[`${k}_u1`] && d[`${k}_u2`]) + return ( +
+
+
{BREED_EMOJI[pet.breed] || "🐕"}
+
{pet.name}
+
{BREED[pet.breed] || pet.breed} · {pet.gender == 1 ? "♂ 男孩" : "♀ 女孩"} · Lv.{pet.level}
+ {STAGE[pet.stage] || "幼犬期"} +
领养 {daysSince(pet.adopted_at)} 天
+
+
+ + + + +
+ {pet.achievements?.length > 0 && ( +
+
🏆 成就 ({pet.achievements.length})
+
{pet.achievements.map((a: any) => {a.achievement_key})}
+
+ )} + {pet.dailies?.length > 0 && ( +
+
📋 日常记录 (近60天)
+
+ {pet.dailies.map((dd: any) => ( +
{dd.date}{allDone(dd) ? "✅ 全部完成" : "🔄 部分完成"}
+ ))} +
+
+ )} +
+ ) +} diff --git a/past-go/web/admin/src/components/ImagePreview.tsx b/past-go/web/admin/src/components/ImagePreview.tsx new file mode 100644 index 0000000..ae69fe5 --- /dev/null +++ b/past-go/web/admin/src/components/ImagePreview.tsx @@ -0,0 +1,75 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react" +import { X } from "lucide-react" +import { mediaUrl } from "@/lib/api" +import { cn } from "@/lib/utils" + +const Ctx = createContext<(url: string) => void>(() => {}) + +// 全局图片灯箱(对应旧版 previewImage) +export function ImagePreviewProvider({ children }: { children: ReactNode }) { + const [url, setUrl] = useState(null) + const open = useCallback((u: string) => u && setUrl(u), []) + return ( + + {children} + {url && ( +
setUrl(null)}> + + e.stopPropagation()} /> +
+ )} +
+ ) +} + +export function useImagePreview() { + return useContext(Ctx) +} + +// 可点击放大的缩略图 +export function Thumb({ path, className, title }: { path?: string | null; className?: string; title?: string }) { + const preview = useImagePreview() + const url = mediaUrl(path) + if (!url) return null + return ( + { + e.stopPropagation() + preview(url) + }} + /> + ) +} + +export function ThumbGallery({ paths, className }: { paths: string[]; className?: string }) { + if (!paths.length) return null + return ( +
+ {paths.map((p, i) => ( + + ))} +
+ ) +} + +// 头像:有图显示图,无图显示首字母渐变圆 +export function Avatar({ path, name, size = 36 }: { path?: string | null; name?: string | null; size?: number }) { + const preview = useImagePreview() + const url = mediaUrl(path) + const style = { width: size, height: size, fontSize: size * 0.42 } + if (url) + return ( + { e.stopPropagation(); preview(url) }} /> + ) + return ( +
+ {(name || "?").trim().charAt(0).toUpperCase()} +
+ ) +} diff --git a/past-go/web/admin/src/components/Layout.tsx b/past-go/web/admin/src/components/Layout.tsx new file mode 100644 index 0000000..c24ccf8 --- /dev/null +++ b/past-go/web/admin/src/components/Layout.tsx @@ -0,0 +1,44 @@ +import { useState } from "react" +import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom" +import { useQueryClient, useIsFetching } from "@tanstack/react-query" +import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight } from "lucide-react" +import { toast } from "sonner" +import { api } from "@/lib/api" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" + +const NAV = [ + { to: "/", label: "数据概览", icon: BarChart3, group: "工作台", end: true }, + { to: "/users", label: "用户管理", icon: Users, group: "空间管理" }, + { to: "/couples", label: "情侣管理", icon: Heart, group: "空间管理" }, + { to: "/contents", label: "内容管理", icon: FileText, group: "空间管理" }, + { to: "/unbind", label: "解绑管理", icon: Unlink, group: "空间管理" }, + { to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" }, + { to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" }, + { to: "/settings", label: "系统设置", icon: Settings, group: "系统" }, +] +function NavItems({ onNavigate }: { onNavigate?: () => void }) { + return +} +export default function Layout({ username }: { username: string }) { + const navigate = useNavigate(), location = useLocation(), qc = useQueryClient() + const [open, setOpen] = useState(false) + const fetching = useIsFetching() + const current = NAV.find(item => item.to === location.pathname)?.label || "管理后台" + async function logout() { + try { await api("adminLogout", null, "GET") } + catch { toast.error("退出失败,请重试"); return } + qc.clear(); toast.success("已退出"); navigate("/login", {replace:true}) + } + const brand =
我们 past让陪伴有迹可循
+ const account =
{username.slice(0,1).toUpperCase()}
{username}管理员
+ return
+ +
+
管理导航{brand}setOpen(false)}/>{account}管理后台{current}
{username}
+
+
我们 past · 共同记录每一天
+
+
+} diff --git a/past-go/web/admin/src/components/Pagination.tsx b/past-go/web/admin/src/components/Pagination.tsx new file mode 100644 index 0000000..44c6fc6 --- /dev/null +++ b/past-go/web/admin/src/components/Pagination.tsx @@ -0,0 +1,43 @@ +import { useState, type FormEvent } from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export const PAGE_SIZE = 10 + +// 后端分页响应的通用结构(admin_users / admin_couples / admin_unbind_requests / admin_pets / admin_contents) +export type Paged = { items: T[]; total: number; page: number; page_size: number; total_pages: number } + +// 分页条:上一页/下一页、页码窗口、跳转输入 +export function Pagination({ page, totalPages, total, onChange }: { page: number; totalPages: number; total?: number; onChange: (p: number) => void }) { + const [jump, setJump] = useState("") + if (!total && totalPages <= 1) return null + const pages: number[] = [] + for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) pages.push(i) + const go = (p: number) => onChange(Math.min(Math.max(1, totalPages), Math.max(1, p))) + const submitJump = (e: FormEvent) => { + e.preventDefault() + const n = parseInt(jump, 10) + if (!isNaN(n)) go(n) + setJump("") + } + return ( +
+ + {total !== undefined && <>共 {total} 条 · }第 {page} / {Math.max(1, totalPages)} 页 + +
+ + {pages[0] > 1 && (<>{pages[0] > 2 && …})} + {pages.map((i) => )} + {pages.length > 0 && pages[pages.length - 1] < totalPages && (<>{pages[pages.length - 1] < totalPages - 1 && …})} + +
+ 跳至 + setJump(e.target.value.replace(/\D/g, ""))} placeholder="页码" /> + +
+
+
+ ) +} diff --git a/past-go/web/admin/src/components/UserDialogs.tsx b/past-go/web/admin/src/components/UserDialogs.tsx new file mode 100644 index 0000000..07f3eda --- /dev/null +++ b/past-go/web/admin/src/components/UserDialogs.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { api, fmtTime, parseImages } from "@/lib/api" +import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview" +import { Empty, Loading, ErrorBox, RecordItem, genderText } from "@/components/common" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" + +// ---------- 用户详情(含编辑资料、打卡/日记/相册记录)---------- + +export function UserDetailDialog({ userId, onClose, onDelete }: { userId: number | null; onClose: () => void; onDelete: (u: { id: number; name: string; hasPartner: boolean }) => void }) { + const qc = useQueryClient() + const q = useQuery({ + queryKey: ["admin_user_detail", userId], + queryFn: () => api("adminUserDetail", { user_id: userId }, "GET"), + enabled: !!userId, + }) + const d = q.data + const [form, setForm] = useState({ nickname: "", gender: "", age: "", birthday: "" }) + useEffect(() => { + if (d) setForm({ nickname: d.nickname || "", gender: d.gender || "", age: d.age ? String(d.age) : "", birthday: d.birthday || "" }) + }, [d]) + + async function save() { + if (!userId) return + try { + await api("adminUpdateUser", { + user_id: userId, + nickname: form.nickname, + gender: form.gender, + age: form.age === "" ? undefined : Number(form.age), + birthday: form.birthday, + }) + toast.success("资料已更新 💕") + qc.invalidateQueries({ queryKey: ["admin_user_detail", userId] }) + qc.invalidateQueries({ queryKey: ["admin_users"] }) + } catch (e) { + toast.error((e as Error).message) + } + } + + const logs = d?.checkin_logs || [] + const checkins = d?.checkins || [] + const diaries = d?.diaries || [] + const albums = d?.albums || [] + + return ( + !o && onClose()}> + + + 👤 用户详情 + 查看用户信息和记录 + + {q.isPending ? : q.isError ? : ( +
+
+ +
+
+ {d.nickname || "-"} + + #{d.user_id || "-"} · {d.openid ? "微信用户" : "手机用户"}{d.openid ? ` · 微信ID: ${d.openid}` : ""} + +
+
{genderText(d.gender)} · {d.age > 0 ? `${d.age}岁` : "-"} · 🎂{d.birthday || "-"}
+
+ 📍 登录地址:{d.last_login_location || d.location || "未记录"}{d.last_login_at ? ` · 🕒 ${fmtTime(d.last_login_at)}` : ""} +
+
+
+ +
+
✏️ 编辑资料
+
+
setForm({ ...form, nickname: e.target.value })} />
+
+ + +
+
setForm({ ...form, age: e.target.value })} />
+
setForm({ ...form, birthday: e.target.value })} />
+
+
+ + +
+
+ + + + ✅ 打卡 + 📝 日记 + 📷 相册 + + + {!logs.length && !checkins.length ? : ( +
+ {logs.length > 0 && ( +
+
📋 打卡记录 ({logs.length}条)
+ {logs.map((l: any) => ( +
+ {fmtTime(l.created_at)} + {l.checkin_title && {l.checkin_icon || "✅"} {l.checkin_title}} + {l.note && 📝{l.note}} + {l.photo &&
} +
+ ))} +
+ )} + {checkins.length > 0 && ( +
+
📌 打卡项 ({checkins.length}项)
+ {checkins.map((c: any) => )} +
+ )} +
+ )} +
+ + {!diaries.length ? : diaries.map((dd: any) => ( + {dd.diary_date}} footer={}> + {dd.title || "一篇日记"} + + ))} + + + {!albums.length ? : albums.map((a: any) => ( + {a.title} + ))} + +
+
+ )} +
+
+ ) +} + +function CheckinItem({ c }: { c: any }) { + const [open, setOpen] = useState(false) + const logs = c.logs || [] + return ( +
+
setOpen(!open)}> + ✅ + {c.title || "打卡"} + ({logs.length}条记录) + {c.is_completed ? "已完成" : "待完成"} + {open ? "▲" : "▼"} +
+ {open && ( +
+ {!logs.length ?
暂无打卡记录
: logs.map((l: any) => ( +
+ {fmtTime(l.created_at)} + {l.note && 📝{l.note}} + {l.photo &&
} +
+ ))} +
+ )} +
+ ) +} + +// ---------- 重置密码 ---------- + +export function ResetPasswordDialog({ user, onClose }: { user: { id: number; name: string } | null; onClose: () => void }) { + const [p1, setP1] = useState("") + const [p2, setP2] = useState("") + useEffect(() => { setP1(""); setP2("") }, [user]) + async function submit() { + if (!user) return + if (p1.length < 6) return toast.error("新密码至少6位") + if (p1 !== p2) return toast.error("两次输入的密码不一致") + try { + await api("adminResetUserPassword", { user_id: user.id, new_password: p1 }) + toast.success("密码重置成功 🔑") + onClose() + } catch (e) { + toast.error((e as Error).message) + } + } + return ( + !o && onClose()}> + + + 🔑 重置密码 + 为 {user?.name} 设置新密码 + +
+
setP1(e.target.value)} placeholder="6位以上" />
+
setP2(e.target.value)} />
+ +
+
+
+ ) +} + +// ---------- 删除用户确认 ---------- + +export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: number; name: string; hasPartner: boolean } | null; onClose: () => void; onDone?: () => void }) { + const qc = useQueryClient() + async function confirm() { + if (!user) return + try { + const r = await api("adminDeleteUser", { user_id: user.id }) + toast.success(r.msg || "已删除") + qc.invalidateQueries({ queryKey: ["admin_users"] }) + qc.invalidateQueries({ queryKey: ["admin_couples"] }) + qc.invalidateQueries({ queryKey: ["admin_stats"] }) + onClose() + onDone?.() + } catch (e) { + toast.error((e as Error).message) + } + } + return ( + !o && onClose()}> + + + ⚠️ 确认删除用户「{user?.name}」 + + {user?.hasPartner + ? "该用户已绑定情侣:将同时删除其伴侣账号以及全部情侣数据(日记、相册、照片、打卡等)。此操作不可恢复!" + : "将删除该用户账号及其邀请记录。此操作不可恢复!"} + + + + 取消 + 确认删除 + + + + ) +} + +// ---------- 删除内容确认(情侣详情 / 内容管理共用)---------- + +export type DeleteContentTarget = { type: string; id: number; coupleId: number; label: string } + +export function DeleteContentDialog({ target, onClose, onDone }: { target: DeleteContentTarget | null; onClose: () => void; onDone?: () => void }) { + async function confirm() { + if (!target) return + try { + await api("adminDeleteContent", { type: target.type, id: target.id, couple_id: target.coupleId }) + toast.success("已删除 🗑️") + onClose() + onDone?.() + } catch (e) { + toast.error((e as Error).message) + } + } + return ( + !o && onClose()}> + + + ⚠️ 删除内容 + 确定要删除「{target?.label}」吗?删除后无法恢复! + + + 取消 + 确认删除 + + + + ) +} diff --git a/past-go/web/admin/src/components/common.tsx b/past-go/web/admin/src/components/common.tsx new file mode 100644 index 0000000..cd969e3 --- /dev/null +++ b/past-go/web/admin/src/components/common.tsx @@ -0,0 +1,73 @@ +import { Inbox, AlertCircle } from "lucide-react" +import type { ReactNode } from "react" +import { cn } from "@/lib/utils" +import { Skeleton } from "@/components/ui/skeleton" + +// 页面标题(右侧可放计数) +export function PageTitle({ children, count }: { children: ReactNode; count?: ReactNode }) { + return ( +

+ {children} + {count !== undefined && {count}} +

+ ) +} + +export function Empty({ icon = "📋", text = "暂无数据", className }: { icon?: string; text?: string; className?: string }) { + return ( +
+
{icon === "❌" ? : }
+

{text}

+
+ ) +} + +export function Loading({ rows = 4 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ) +} + +export function ErrorBox({ error }: { error: unknown }) { + return +} + +// 统计卡片 +export function StatCard({ label, value, tone = "pink" }: { label: string; value: ReactNode; tone?: "pink" | "violet" | "green" | "blue" | "amber" | "neutral" }) { + const tones: Record = { + pink: "from-pink-50 to-pink-100 text-pink-600 dark:from-pink-950/40 dark:to-pink-900/30 dark:text-pink-300", + violet: "from-violet-50 to-violet-100 text-violet-600 dark:from-violet-950/40 dark:to-violet-900/30 dark:text-violet-300", + green: "from-emerald-50 to-emerald-100 text-emerald-600 dark:from-emerald-950/40 dark:to-emerald-900/30 dark:text-emerald-300", + blue: "from-sky-50 to-sky-100 text-sky-600 dark:from-sky-950/40 dark:to-sky-900/30 dark:text-sky-300", + amber: "from-amber-50 to-amber-100 text-amber-600 dark:from-amber-950/40 dark:to-amber-900/30 dark:text-amber-300", + neutral: "from-muted to-muted text-foreground", + } + return ( +
+
{value}
+
{label}
+
+ ) +} + +// 记录列表项(对应旧版 .record-item) +export function RecordItem({ icon, children, right, footer, onClick }: { icon?: ReactNode; children: ReactNode; right?: ReactNode; footer?: ReactNode; onClick?: () => void }) { + return ( +
+
+ {icon && {icon}} +
{children}
+ {right} +
+ {footer &&
{footer}
} +
+ ) +} + +export function genderText(g?: string | null) { + return g === "male" ? "♂ 男" : g === "female" ? "♀ 女" : "-" +} diff --git a/past-go/web/admin/src/components/ui/alert-dialog.tsx b/past-go/web/admin/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6715c6d --- /dev/null +++ b/past-go/web/admin/src/components/ui/alert-dialog.tsx @@ -0,0 +1,197 @@ +import * as React from "react" +import { cn } from "cn" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/past-go/web/admin/src/components/ui/badge.tsx b/past-go/web/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..837196c --- /dev/null +++ b/past-go/web/admin/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/past-go/web/admin/src/components/ui/button.tsx b/past-go/web/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..18407c9 --- /dev/null +++ b/past-go/web/admin/src/components/ui/button.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/past-go/web/admin/src/components/ui/card.tsx b/past-go/web/admin/src/components/ui/card.tsx new file mode 100644 index 0000000..cf02f48 --- /dev/null +++ b/past-go/web/admin/src/components/ui/card.tsx @@ -0,0 +1,102 @@ +import * as React from "react" +import { cn } from "cn" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/past-go/web/admin/src/components/ui/dialog.tsx b/past-go/web/admin/src/components/ui/dialog.tsx new file mode 100644 index 0000000..db53f21 --- /dev/null +++ b/past-go/web/admin/src/components/ui/dialog.tsx @@ -0,0 +1,166 @@ +import * as React from "react" +import { cn } from "cn" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/past-go/web/admin/src/components/ui/dropdown-menu.tsx b/past-go/web/admin/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a789efd --- /dev/null +++ b/past-go/web/admin/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/past-go/web/admin/src/components/ui/input.tsx b/past-go/web/admin/src/components/ui/input.tsx new file mode 100644 index 0000000..7aa671b --- /dev/null +++ b/past-go/web/admin/src/components/ui/input.tsx @@ -0,0 +1,18 @@ +import * as React from "react" +import { cn } from "cn" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/past-go/web/admin/src/components/ui/label.tsx b/past-go/web/admin/src/components/ui/label.tsx new file mode 100644 index 0000000..425b3b5 --- /dev/null +++ b/past-go/web/admin/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import { cn } from "cn" +import { Label as LabelPrimitive } from "radix-ui" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/past-go/web/admin/src/components/ui/scroll-area.tsx b/past-go/web/admin/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..6e76811 --- /dev/null +++ b/past-go/web/admin/src/components/ui/scroll-area.tsx @@ -0,0 +1,54 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/past-go/web/admin/src/components/ui/select.tsx b/past-go/web/admin/src/components/ui/select.tsx new file mode 100644 index 0000000..cdd962b --- /dev/null +++ b/past-go/web/admin/src/components/ui/select.tsx @@ -0,0 +1,191 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" +import { Select as SelectPrimitive } from "radix-ui" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/past-go/web/admin/src/components/ui/separator.tsx b/past-go/web/admin/src/components/ui/separator.tsx new file mode 100644 index 0000000..54d208f --- /dev/null +++ b/past-go/web/admin/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import { cn } from "cn" +import { Separator as SeparatorPrimitive } from "radix-ui" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/past-go/web/admin/src/components/ui/sheet.tsx b/past-go/web/admin/src/components/ui/sheet.tsx new file mode 100644 index 0000000..aed4489 --- /dev/null +++ b/past-go/web/admin/src/components/ui/sheet.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/past-go/web/admin/src/components/ui/skeleton.tsx b/past-go/web/admin/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..b7f61b3 --- /dev/null +++ b/past-go/web/admin/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "cn" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/past-go/web/admin/src/components/ui/table.tsx b/past-go/web/admin/src/components/ui/table.tsx new file mode 100644 index 0000000..d0baee6 --- /dev/null +++ b/past-go/web/admin/src/components/ui/table.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { cn } from "cn" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/past-go/web/admin/src/components/ui/tabs.tsx b/past-go/web/admin/src/components/ui/tabs.tsx new file mode 100644 index 0000000..b1705f8 --- /dev/null +++ b/past-go/web/admin/src/components/ui/tabs.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Tabs as TabsPrimitive } from "radix-ui" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/past-go/web/admin/src/components/ui/textarea.tsx b/past-go/web/admin/src/components/ui/textarea.tsx new file mode 100644 index 0000000..5141de7 --- /dev/null +++ b/past-go/web/admin/src/components/ui/textarea.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { cn } from "cn" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + + + + + + + TA的资料 + + {{editPartnerNameInitial || '?'}} + TA的ID: {{partnerDisplayId}} + {{editPartnerNickname || '未设置'}} + {{editPartnerGenderText || '-'}} + {{editPartnerBirthday || '-'}} + {{tempLoveDate || '未设置'}} + {{tempLoveStory || '未设置'}} + + + + + + + + 💕 绑定情侣 + + + + + + 你的ID + {{myDisplayId}} + --- + 将此ID告诉TA + + + + + + + + + + + 或 + + + + + + + 收到的邀请 + 发出的邀请 + + + + + + + {{item.from_initial}} + + {{item.from_nickname || item.from_username}} + {{item.short_date}} + + {{item.status == 'accepted' ? '已接受' : '待处理'}} + + 💌 {{item.message}} + + + + + + + + 暂无收到的邀请 💭 + + + + + + + + {{item.to_initial}} + + {{item.to_nickname || item.to_username || item.to_phone}} + {{item.short_date}} + + {{item.status == 'accepted' ? '已接受' : '待处理'}} + + + + + 暂无发出的邀请 💭 + + + + + + + + + + + 💌 + 情侣邀请 + 有人想和你绑定为情侣 + 需要对方同意绑定后才能使用 + + + + ID: {{shareInviterId}} + 想要和你绑定为情侣 💕 + + + + + + + 😅 + 需要对方同意绑定后才能使用本应用 + + + + + + diff --git a/weapp/pages/profile/profile.wxss b/weapp/pages/profile/profile.wxss new file mode 100644 index 0000000..efb5247 --- /dev/null +++ b/weapp/pages/profile/profile.wxss @@ -0,0 +1,308 @@ +/* pages/profile/profile.wxss */ +@import '../../styles/common.wxss'; +page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } +.container { padding-top: 16rpx; padding-bottom: 300rpx; } +.page-root { height: 100vh; overflow: hidden; } +scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } + +/* 编辑卡全屏 */ +.edit-root { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + z-index: 1000; + background: linear-gradient(180deg, #FFF0F5 0%, #F4E2E3 40%, #FAFAFA 100%); + display: flex; + flex-direction: column; +} + +.edit-nav { + display: flex; + align-items: center; + padding: 100rpx 32rpx 24rpx; +} + +.edit-body { + flex: 1; + padding: 0 56rpx; + overflow-y: auto; +} + +.edit-avatar-wrap { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 24rpx; +} +.edit-avatar-img, +.edit-avatar-placeholder { + width: 120rpx; height: 120rpx; + border-radius: 50%; + border: 4rpx solid #FFB4C8; +} +.edit-avatar-placeholder { + background: linear-gradient(135deg, #FF6B81, #B388FF); + display: flex; align-items: center; justify-content: center; + font-size: 40rpx; font-weight: 700; color: white; +} +.edit-avatar-hint { + font-size: 22rpx; color: #B74768; margin-top: 8rpx; +} + +.edit-id { + font-size: 24rpx; color: #bbb; + text-align: center; display: block; + margin-bottom: 24rpx; +} + +.input-group { margin-bottom: 20rpx; } +.input-group label { + font-size: 28rpx; font-weight: 500; color: #555; + display: block; margin-bottom: 8rpx; +} +.input { + padding: 20rpx 28rpx; font-size: 30rpx; + background: white; + border-radius: 16rpx; + border: 2rpx solid #F0E6FF; +} +.input-group.readonly .input.readonly-text { + background: #F8F4FF; + color: #666; + border-color: #E8DCF5; + padding: 20rpx 28rpx; + font-size: 30rpx; + border-radius: 16rpx; + border: 2rpx solid #E8DCF5; + line-height: 1.5; + min-height: 44rpx; + display: flex; + align-items: center; +} + +.edit-footer { + padding: 20rpx 56rpx; + padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); +} + +/* ===== 邀请绑定面板 ===== */ +.invite-root { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + z-index: 1000; + background: #FFF5F5; + display: flex; + flex-direction: column; +} + +.invite-nav { + position: fixed; + top: 0; left: 0; right: 0; + z-index: 1001; + height: 176rpx; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding-bottom: 16rpx; + background: linear-gradient(to bottom, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 100%); +} + +.invite-nav-title { + font-size: 32rpx; + font-weight: 600; + color: #333; +} + +.invite-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +/* 邀请页标签 */ +.invite-tabs { + display: flex; + background: white; + border-radius: 100rpx; + padding: 8rpx; + margin-bottom: 48rpx; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05); +} + +.invite-tab { + flex: 1; + padding: 24rpx; + text-align: center; + border-radius: 100rpx; + font-size: 28rpx; + font-weight: 600; + transition: all 0.3s; + color: #888; +} + +.invite-tab.active { + background: linear-gradient(135deg, #B74768, #B388FF); + color: white; +} + +.invite-list { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.invite-card { + background: white; + border-radius: 48rpx; + padding: 40rpx; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05); +} + +.invite-card-header { + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 24rpx; +} + +.invite-avatar { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + background: linear-gradient(135deg, #B388FF, #82B1FF); + display: flex; + align-items: center; + justify-content: center; + font-size: 36rpx; + font-weight: 700; + color: white; + flex-shrink: 0; +} + +.invite-info { + flex: 1; +} + +.invite-name { + font-size: 30rpx; + font-weight: 600; + display: block; +} + +.invite-time { + font-size: 26rpx; + color: #888; + display: block; + margin-top: 4rpx; +} + +.invite-status { + padding: 8rpx 20rpx; + border-radius: 40rpx; + font-size: 24rpx; + flex-shrink: 0; +} + +.invite-status.accepted { + background: #E8F5E9; + color: #4CAF50; +} + +.invite-status.pending { + background: #FFF3E0; + color: #FF9800; +} + +.invite-message { + font-size: 28rpx; + color: #4A4A4A; + padding: 24rpx; + background: #FFF8E7; + border-radius: 24rpx; + margin-bottom: 24rpx; +} + +.invite-actions { + display: flex; + gap: 24rpx; +} + +.invite-actions .btn { + flex: 1; + padding: 24rpx; + font-size: 28rpx; +} + +.empty-tip { + text-align: center; + padding: 80rpx 0; + color: #888; + font-size: 26rpx; +} + +/* 分享绑定弹窗 */ +.auth-header { + text-align: center; + margin-bottom: 64rpx; +} + +.auth-icon { + font-size: 112rpx; + margin-bottom: 32rpx; + display: block; + animation: bounce 2s ease-in-out infinite; +} + +.auth-title { + font-size: 52rpx; + font-weight: 700; + color: #4A4A4A; + margin-bottom: 16rpx; + display: block; + background: linear-gradient(135deg, #B74768, #B388FF); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.auth-desc { + color: #888; + font-size: 28rpx; + display: block; +} + +.btn-sm { + padding: 24rpx 32rpx; + font-size: 28rpx; +} + +.btn-secondary { + background: white; + color: #B74768; + border: 4rpx solid #B74768; +} + +.btn-secondary:active { + background: #F4E2E3; +} + +.btn-outline { + background: transparent; + color: #B74768; + border: 4rpx solid #FFB4C8; +} + +.btn-outline:active { + background: #F4E2E3; +} + +@import '../../styles/design.wxss'; + +@import '../../styles/journal.wxss'; + +/* TA 的资料仅供查看,不预留编辑操作区。 */ +.partner-view .edit-body { min-height: 0; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); } +.partner-view .edit-nav { position: relative; } +.partner-nav-title { position: absolute; left: 120rpx; right: 120rpx; text-align: center; font-size: 32rpx; font-weight: 600; color: #342F2B; pointer-events: none; } +.partner-view .readonly-text { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/weapp/pages/publish/publish.js b/weapp/pages/publish/publish.js new file mode 100644 index 0000000..7620210 --- /dev/null +++ b/weapp/pages/publish/publish.js @@ -0,0 +1,530 @@ +const { getLayout } = require('../../utils/layout'); +// pages/publish/publish.js - 统一发布页(增强版编辑器) +const { api, API_BASE, uploadFileCompat } = require('../../utils/request'); +const { createSwipeBack } = require('../../utils/util'); +const { requestSubscribeAfterAction } = require('../../utils/subscribe'); + +var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌']; +var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙']; + +var TYPE_CONFIG = { + timeline: { title: '添加记忆日', btn: '保存记忆日', api: 'timelineAdd' }, + diary: { title: '写日记', btn: '发布日记', api: 'diaryAdd' }, + album: { title: '创建相册', btn: '创建相册', api: 'albumAdd' }, + song: { title: '收藏歌曲', btn: '保存歌曲', api: 'songsAdd' }, + checkin: { title: '添加打卡', btn: '保存打卡', api: 'checkinAdd' } +}; + +Page({ + onResize: function() { this.setData(getLayout()); }, + data: { + type: '', + titleText: '', + btnText: '', + form: {}, + tempImages: [], + coverPreview: '', + coverPath: '', + submitting: false, editLoading:false, editLoadError:false, + checkinTypes: ['双方打卡', '第一视角(我)', '她打卡'], + checkinTypeIdx: 0, + checkinTypeMap: ['both', 'first', 'her'], + checkinFreqs: ['每天打卡', '每周打卡', '每月打卡', '不限次数'], + checkinFreqIdx: 0, + checkinFreqMap: ['daily', 'weekly', 'monthly', 'unlimited'], + repeatOptions: ['无重复', '每周重复', '每月重复', '每年重复'], + repeatOptionIdx: 0, + repeatOptionMap: ['', 'week', 'month', 'year'], + editId: 0, + // 背景图 + bgImageUrl: '', + bgImagePath: '', + // 心情/天气 + moodOptions: MOOD_OPTIONS, + weatherOptions: WEATHER_OPTIONS, + contentLength: 0 + }, + + onLoad: function(options) { + this.setData(getLayout()); + var type = options.type || 'timeline'; + var editId = options.edit ? parseInt(options.edit) : 0; + var cfg = TYPE_CONFIG[type] || TYPE_CONFIG['timeline']; + var titleText = editId ? '编辑事件' : cfg.title; + var btnText = editId ? '保存修改' : cfg.btn; + this.setData({ type: type, titleText: titleText, btnText: btnText, editId: editId }); + + var self = this; + var swipe = createSwipeBack(function() { self.goBack(); }); + this.onTouchStart = swipe.onTouchStart; + this.onTouchEnd = swipe.onTouchEnd; + + // 初始化表单 + this.resetForm(type); + + // 非编辑模式:优先恢复本地草稿(防误退出丢失内容) + if (!editId) { + this.loadDraft(type); + } + + // 如果是编辑模式,加载已有数据 + if (editId && type === 'timeline') { + this.loadEditData(editId); + } + + // 如果是打卡页,加载情侣昵称 + if (type === 'checkin') { + this.loadCoupleNames(); + } + }, + + // ===== 草稿缓存 ===== + // 读取本地草稿并填充表单 + loadDraft: function(type) { + try { + var draftKey = 'publishDraft_' + type; + var draft = wx.getStorageSync(draftKey); + if (!draft) return; + var data = JSON.parse(draft); + var patch = {}; + if (data.form) { + var form = this.data.form; + for (var k in data.form) { + if (data.form.hasOwnProperty(k) && form.hasOwnProperty(k)) { + form[k] = data.form[k]; + } + } + patch.form = form; + patch.contentLength = (form.content || '').length; + } + if (typeof data.repeatOptionIdx === 'number') patch.repeatOptionIdx = data.repeatOptionIdx; + if (typeof data.checkinTypeIdx === 'number') patch.checkinTypeIdx = data.checkinTypeIdx; + if (typeof data.checkinFreqIdx === 'number') patch.checkinFreqIdx = data.checkinFreqIdx; + if (data.bgImageUrl) patch.bgImageUrl = data.bgImageUrl; + if (data.coverPreview) { + patch.coverPreview = data.coverPreview; + patch.coverPath = data.coverPath || data.coverPreview; + } + this.setData(patch); + } catch(e) {} + }, + + // 保存草稿到本地(输入时自动触发) + _saveDraft: function() { + try { + var type = this.data.type; + if (this.data.editId) return; // 编辑模式不保存草稿 + var draftKey = 'publishDraft_' + type; + var data = { + form: this.data.form, + repeatOptionIdx: this.data.repeatOptionIdx, + checkinTypeIdx: this.data.checkinTypeIdx, + checkinFreqIdx: this.data.checkinFreqIdx, + bgImageUrl: this.data.bgImageUrl, + coverPreview: this.data.coverPreview, + coverPath: this.data.coverPath, + time: Date.now() + }; + wx.setStorageSync(draftKey, JSON.stringify(data)); + } catch(e) {} + }, + + // 发布成功后清除草稿 + _clearDraft: function() { + try { + var type = this.data.type; + wx.removeStorageSync('publishDraft_' + type); + } catch(e) {} + }, + + retryEdit: function() { this.loadEditData(this.data.editId); }, + loadEditData: async function(id) { + this.setData({editLoading:true,editLoadError:false}); + try { + var item = await api.timelineDetail(id); + if (item) { + var form = { + title: item.title || '', + date: item.event_date || '', + location: item.location || '', + content: item.content || '', + repeat_interval: parseInt(item.repeat_interval) || 0, + repeat_unit: item.repeat_unit || '' + }; + // 加载背景图 + var bgPath = item.bg_image || ''; + var bgUrl = bgPath ? require('../../utils/request').getImageUrl(bgPath) : ''; + var repeatOptionIdx = 0; + if (form.repeat_interval > 0 && form.repeat_unit) { + var opts = this.data.repeatOptionMap; + for (var i = 0; i < opts.length; i++) { + if (opts[i] === form.repeat_unit) { + repeatOptionIdx = i; + break; + } + } + } + this.setData({ + form: form, + repeatOptionIdx: repeatOptionIdx, + bgImageUrl: bgUrl, + bgImagePath: bgPath + }); + } + } catch(e) { + console.error('加载编辑数据失败', e); + this.setData({editLoadError:true}); + } finally { this.setData({editLoading:false}); } + }, + + loadCoupleNames: async function() { + try { + var info = await api.coupleInfo(); + var myUid = wx.getStorageSync('userId'); + var p1 = info.partner1 || {}, p2 = info.partner2 || {}; + var me = (p1.id == myUid) ? p1 : p2; + var partner = (p1.id == myUid) ? p2 : p1; + var myName = me.nickname || '我'; + var partnerName = partner.nickname || 'TA'; + this.setData({ + checkinTypes: ['双方打卡', myName, partnerName] + }); + } catch(e) { + console.error('获取情侣信息失败', e); + } + }, + + resetForm: function(type) { + var form = {}; + if (type === 'diary') { + form = { title: '', content: '', mood: '😊', weather: '☀️', lat: 0, lng: 0, location: '' }; + } else if (type === 'song') { + form = { song_name: '', artist: '', note: '' }; + } else if (type === 'album') { + form = { title: '', desc: '', lat: 0, lng: 0, location: '' }; + this.setData({ coverPreview: '', coverPath: '' }); + } else if (type === 'checkin') { + form = { title: '', type: 'both', frequency: 'daily' }; + this.setData({ checkinTypeIdx: 0, checkinFreqIdx: 0 }); + } else { + form = { title: '', date: '', location: '', content: '', repeat_interval: 0, repeat_unit: '' }; + } + this.setData({ form: form, tempImages: [], repeatOptionIdx: 0, contentLength: (form.content || '').length }); + }, + + // ===== 自动定位(日记 / 相册发布时自动获取经纬度)===== + _autoLocate: function() { + var self = this; + return new Promise(function(resolve) { + // 已有位置则直接使用,避免重复定位 + if (self.data.form && self.data.form.lat && self.data.form.lng) { + resolve({ lat: self.data.form.lat, lng: self.data.form.lng, location: self.data.form.location || '' }); + return; + } + wx.getFuzzyLocation({ + success: function(res) { + resolve({ lat: res.latitude, lng: res.longitude, location: '' }); + }, + fail: function() { + // 定位失败不阻塞发布,经纬度置 0 + resolve({ lat: 0, lng: 0, location: '' }); + } + }); + }); + }, + + // ===== 通用输入(自动保存草稿)===== + onTitleInput: function(e) { this.setData({ 'form.title': e.detail.value }); this._saveDraft(); }, + onDateInput: function(e) { this.setData({ 'form.date': e.detail.value }); this._saveDraft(); }, + onContentInput: function(e){ + var val = e.detail.value || ''; + this.setData({ 'form.content': val, contentLength: val.length }); + this._saveDraft(); + }, + onLocationInput: function(e){ this.setData({ 'form.location': e.detail.value }); this._saveDraft(); }, + onDescInput: function(e) { this.setData({ 'form.desc': e.detail.value }); this._saveDraft(); }, + onArtistInput: function(e) { this.setData({ 'form.artist': e.detail.value }); this._saveDraft(); }, + onNoteInput: function(e) { this.setData({ 'form.note': e.detail.value }); this._saveDraft(); }, + + onCheckinTypeChange: function(e) { + var idx = parseInt(e.detail.value); + this.setData({ checkinTypeIdx: idx, 'form.type': this.data.checkinTypeMap[idx] }); + this._saveDraft(); + }, + onCheckinFreqChange: function(e) { + var idx = parseInt(e.detail.value); + this.setData({ checkinFreqIdx: idx, 'form.frequency': this.data.checkinFreqMap[idx] }); + this._saveDraft(); + }, + onRepeatChange: function(e) { + var idx = parseInt(e.detail.value); + var unit = this.data.repeatOptionMap[idx]; + this.setData({ + repeatOptionIdx: idx, + 'form.repeat_interval': unit ? 1 : 0, + 'form.repeat_unit': unit || '' + }); + this._saveDraft(); + }, + + // ===== 封面图片选择(相册)===== + chooseCover: function() { + var self = this; + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + self.setData({ coverPreview: res.tempFilePaths[0], coverPath: res.tempFilePaths[0] }); + self._saveDraft(); + } + }); + }, + + // ===== 图片选择(日记)===== + chooseImages: function() { + var self = this; + wx.chooseImage({ + count: 9 - this.data.tempImages.length, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) }); + self._saveDraft(); + } + }); + }, + + // ===== 删除单张图片 ===== + removeImage: function(e) { + var index = parseInt(e.currentTarget.dataset.index); + var images = this.data.tempImages; + images.splice(index, 1); + this.setData({ tempImages: images }); + this._saveDraft(); + }, + + // ===== 提交 ===== + submit: async function() { + if (this.data.editLoading || this.data.editLoadError) return; + if (this.data.submitting) return; + this.setData({ submitting: true }); + var editId = this.data.editId; + + try { + var type = this.data.type; + var form = this.data.form; + + // 先校验表单 + if (type === 'timeline') { + if (!form.title) { throw new Error('请填写事件内容'); } + if (!form.date) { throw new Error('请填写事件日期'); } + } else if (type === 'diary') { + if (!form.content || !form.content.trim()) { throw new Error('请填写日记内容'); } + } else if (type === 'album') { + if (!form.title) { throw new Error('请填写相册名称'); } + } else if (type === 'song') { + if (!form.song_name || !form.artist) { throw new Error('请填写歌曲名和歌手名'); } + } else if (type === 'checkin') { + if (!form.title) { throw new Error('请填写打卡标题'); } + } + + // ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范) + // 订阅弹窗不阻塞后续异步操作,用户拒绝也不影响发布 + var sceneMap = { + diary: 'diary', album: 'photo', checkin: 'checkin', + timeline: 'timeline', song: 'song' + }; + var scene = sceneMap[type]; + if (scene) { + requestSubscribeAfterAction(scene); + } + + // 再执行异步操作(上传/API调用) + if (type === 'timeline') { + var editId = this.data.editId; + // 上传背景图 + var bgImagePath = this.data.bgImagePath; + if (this.data.bgImageUrl && !bgImagePath) { + wx.showLoading({ title: '上传背景图...' }); + try { + var uploadRes = await uploadFileCompat({ + url: API_BASE + '/upload', + filePath: this.data.bgImageUrl, + name: 'file', + formData: { sub_dir: 'timeline_bg' } + }); + bgImagePath = uploadRes && (uploadRes.path || uploadRes.url || ''); + } catch(e) {} + wx.hideLoading(); + } + var result; + if (editId) { + result = await api.timelineUpdate({ id: editId, title: form.title, event_date: form.date, location: form.location || '', content: form.content || '', repeat_interval: form.repeat_interval || 0, repeat_unit: form.repeat_unit || '', bg_image: bgImagePath || '' }); + } else { + result = await api.timelineAdd({ title: form.title, event_date: form.date, location: form.location || '', content: form.content || '', repeat_interval: form.repeat_interval || 0, repeat_unit: form.repeat_unit || '', bg_image: bgImagePath || '' }); + } + } else if (type === 'diary') { + var today = new Date(); + var ds = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); + var uploadPaths = []; + if (this.data.tempImages.length > 0) { + wx.showLoading({ title: '上传图片中...' }); + for (var i = 0; i < this.data.tempImages.length; i++) { + var res = await this._uploadDiaryImage(this.data.tempImages[i]); + if (res) uploadPaths.push(res); + } + wx.hideLoading(); + } + // 从缓存获取用户地区 + var region = wx.getStorageSync('region') || ''; + // 自动定位获取经纬度(发布后自动显示地理位置) + var loc = await this._autoLocate(); + await api.diaryAdd({ + title: form.title || '', + diary_date: ds, + content: form.content, + images: uploadPaths, + region: region, + lat: loc.lat, + lng: loc.lng, + location: loc.location + }); + } else if (type === 'album') { + var coverUrl = ''; + if (this.data.coverPath) { + wx.showLoading({ title: '上传封面...' }); + coverUrl = await this._uploadAlbumCover(this.data.coverPath); + wx.hideLoading(); + } + // 自动定位获取经纬度(发布后自动显示地理位置) + var albumLoc = await this._autoLocate(); + await api.albumAdd({ title: form.title, description: form.desc || '', cover: coverUrl, lat: albumLoc.lat, lng: albumLoc.lng, location: albumLoc.location }); + } else if (type === 'song') { + await api.songsAdd({ song_name: form.song_name, artist: form.artist, note: form.note || '' }); + } else if (type === 'checkin') { + await api.checkinAdd({ title: form.title, type: form.type || 'dual', frequency: form.frequency || 'daily' }); + } + + // 发布/修改成功后清除草稿 + this._clearDraft(); + + wx.showToast({ title: editId ? '修改成功 ✨' : '发布成功 ✨', icon: 'success' }); + // 延迟返回页面,给订阅弹窗留出时间 + setTimeout(function() { wx.navigateBack(); }, 1500); + } catch (e) { + wx.hideLoading(); + wx.showToast({ title: (e && (e.error || e.message || e.errMsg)) || '发布失败', icon: 'none', duration: 2000 }); + } finally { + this.setData({ submitting: false }); + } + }, + + _uploadAlbumCover: async function(filePath) { + try { + var data = await uploadFileCompat({ + url: API_BASE + '/upload', + filePath: filePath, + name: 'file', + formData: { sub_dir: 'albums' } + }); + // 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'} + var path = data && (data.path || data.url || ''); + if (!path) { + // 内容安全检测失败:抛出错误提示用户 + if (data && data.error) { + throw data; + } + console.error('封面上传返回无路径', JSON.stringify(data), '错误码:', data && data.code); + // 尝试重新上传一次 + try { + var retryData = await uploadFileCompat({ + url: API_BASE + '/upload', + filePath: filePath, + name: 'file', + formData: { sub_dir: 'albums' } + }); + var retryPath = retryData && (retryData.path || retryData.url || ''); + if (retryPath) { + console.log('封面上传重试成功'); + return retryPath; + } + } catch(retryErr) { + console.error('封面上传重试也失败', retryErr, '错误码:', retryErr && retryErr.code); + } + } + return path; + } catch(e) { + // 内容安全违规错误:直接抛出,中断发布并提示用户 + if (e && e.error) { + throw e; + } + console.error('封面上传失败', e, '错误码:', e && e.code); + return ''; + } + }, + + _uploadDiaryImage: async function(filePath) { + try { + var data = await uploadFileCompat({ + url: API_BASE + '/upload', + filePath: filePath, + name: 'file', + formData: { sub_dir: 'diaries' } + }); + // 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'} + var path = data && (data.path || data.url || ''); + if (!path) { + // 内容安全检测失败:抛出错误提示用户 + if (data && data.error) { + throw data; + } + console.error('日记图片上传返回无路径', JSON.stringify(data), '错误码:', data && data.code); + // 尝试重新上传一次 + try { + var retryData = await uploadFileCompat({ + url: API_BASE + '/upload', + filePath: filePath, + name: 'file', + formData: { sub_dir: 'diaries' } + }); + var retryPath = retryData && (retryData.path || retryData.url || ''); + if (retryPath) { + console.log('日记图片重试上传成功'); + return retryPath; + } + } catch(retryErr) { + console.error('日记图片重试上传也失败', retryErr, '错误码:', retryErr && retryErr.code); + } + } + return path; + } catch(e) { + // 内容安全违规错误:直接抛出,中断发布并提示用户 + if (e && e.error) { + throw e; + } + console.error('日记图片上传失败', e, '错误码:', e && e.code); + return ''; + } + }, + + // ===== 背景图 ===== + chooseBgImage: function() { + var self = this; + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + var filePath = res.tempFilePaths[0]; + self.setData({ bgImageUrl: filePath, bgImagePath: '' }); + } + }); + }, + + removeBgImage: function() { + this.setData({ bgImageUrl: '', bgImagePath: '' }); + }, + + goBack: function() { wx.navigateBack(); } +}); diff --git a/weapp/pages/publish/publish.json b/weapp/pages/publish/publish.json new file mode 100644 index 0000000..d26df5a --- /dev/null +++ b/weapp/pages/publish/publish.json @@ -0,0 +1,8 @@ +{ + "disableScroll": true, + "navigationStyle": "custom", + "usingComponents": { + "app-nav": "/components/app-nav/app-nav", + "page-state": "/components/page-state/page-state" + } +} diff --git a/weapp/pages/publish/publish.wxml b/weapp/pages/publish/publish.wxml new file mode 100644 index 0000000..6eee76f --- /dev/null +++ b/weapp/pages/publish/publish.wxml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + {{checkinTypes[checkinTypeIdx] || '请选择'}} + + + + + + {{checkinFreqs[checkinFreqIdx] || '请选择'}} + + 每天/每周/每月:周期内打卡一次即完成 | 不限:有记录即算完成 + + + + + + + + + {{form.date || '请选择事件日期'}} + + + + + + + + + + + + +