From cd05113755e9d720eed206d25ef736b8f4989c6c Mon Sep 17 00:00:00 2001 From: Michelle Zan Date: Tue, 29 Sep 2026 19:01:36 +0800 Subject: [PATCH] =?UTF-8?q?Add=20=E5=8D=93=E7=B1=B3=E4=BC=9A=20mini=20prog?= =?UTF-8?q?ram=20and=20admin=20server.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial commit of the WeChat mini program, Express backend, and admin console. Co-authored-by: Cursor --- .gitignore | 8 + README.md | 66 + miniprogram/app.js | 11 + miniprogram/app.json | 47 + miniprogram/app.wxss | 11 + miniprogram/assets/tab-home-on.png | Bin 0 -> 8649 bytes miniprogram/assets/tab-home.png | Bin 0 -> 8649 bytes miniprogram/assets/tab-mine-on.png | Bin 0 -> 4986 bytes miniprogram/assets/tab-mine.png | Bin 0 -> 4986 bytes miniprogram/assets/tab-past-on.png | Bin 0 -> 6958 bytes miniprogram/assets/tab-past.png | Bin 0 -> 6958 bytes .../components/activity-board/index.js | 41 + .../components/activity-board/index.json | 3 + .../components/activity-board/index.wxml | 48 + .../components/activity-board/index.wxss | 139 ++ miniprogram/config.js | 4 + miniprogram/pages/detail/detail.js | 50 + miniprogram/pages/detail/detail.json | 6 + miniprogram/pages/detail/detail.wxml | 12 + miniprogram/pages/detail/detail.wxss | 17 + miniprogram/pages/index/index.js | 48 + miniprogram/pages/index/index.json | 7 + miniprogram/pages/index/index.wxml | 7 + miniprogram/pages/index/index.wxss | 3 + miniprogram/pages/mine/mine.js | 56 + miniprogram/pages/mine/mine.json | 3 + miniprogram/pages/mine/mine.wxml | 35 + miniprogram/pages/mine/mine.wxss | 131 ++ miniprogram/pages/past-edit/past-edit.js | 121 ++ miniprogram/pages/past-edit/past-edit.json | 3 + miniprogram/pages/past-edit/past-edit.wxml | 42 + miniprogram/pages/past-edit/past-edit.wxss | 119 ++ miniprogram/pages/past/past.js | 91 ++ miniprogram/pages/past/past.json | 4 + miniprogram/pages/past/past.wxml | 46 + miniprogram/pages/past/past.wxss | 163 +++ miniprogram/pages/profile/profile.js | 97 ++ miniprogram/pages/profile/profile.json | 3 + miniprogram/pages/profile/profile.wxml | 24 + miniprogram/pages/profile/profile.wxss | 94 ++ miniprogram/pages/signup/signup.js | 71 + miniprogram/pages/signup/signup.json | 3 + miniprogram/pages/signup/signup.wxml | 12 + miniprogram/pages/signup/signup.wxss | 52 + miniprogram/project.config.json | 56 + miniprogram/sitemap.json | 4 + miniprogram/utils/activity.js | 44 + miniprogram/utils/api.js | 42 + miniprogram/utils/auth.js | 75 ++ miniprogram/utils/fallback.js | 15 + miniprogram/utils/media.js | 9 + miniprogram/utils/request.js | 75 ++ server/app.js | 31 + server/lib/auth.js | 37 + server/lib/db.js | 109 ++ server/lib/http.js | 42 + server/lib/passwords.js | 23 + server/lib/serialize.js | 132 ++ server/lib/upload.js | 52 + server/lib/wechat.js | 34 + server/package-lock.json | 1182 +++++++++++++++++ server/package.json | 19 + server/public/admin.html | 477 +++++++ server/routes/admin.js | 296 +++++ server/routes/public.js | 223 ++++ server/server.js | 14 + server/test/api.test.js | 156 +++ 67 files changed, 4845 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 miniprogram/app.js create mode 100644 miniprogram/app.json create mode 100644 miniprogram/app.wxss create mode 100644 miniprogram/assets/tab-home-on.png create mode 100644 miniprogram/assets/tab-home.png create mode 100644 miniprogram/assets/tab-mine-on.png create mode 100644 miniprogram/assets/tab-mine.png create mode 100644 miniprogram/assets/tab-past-on.png create mode 100644 miniprogram/assets/tab-past.png create mode 100644 miniprogram/components/activity-board/index.js create mode 100644 miniprogram/components/activity-board/index.json create mode 100644 miniprogram/components/activity-board/index.wxml create mode 100644 miniprogram/components/activity-board/index.wxss create mode 100644 miniprogram/config.js create mode 100644 miniprogram/pages/detail/detail.js create mode 100644 miniprogram/pages/detail/detail.json create mode 100644 miniprogram/pages/detail/detail.wxml create mode 100644 miniprogram/pages/detail/detail.wxss create mode 100644 miniprogram/pages/index/index.js create mode 100644 miniprogram/pages/index/index.json create mode 100644 miniprogram/pages/index/index.wxml create mode 100644 miniprogram/pages/index/index.wxss create mode 100644 miniprogram/pages/mine/mine.js create mode 100644 miniprogram/pages/mine/mine.json create mode 100644 miniprogram/pages/mine/mine.wxml create mode 100644 miniprogram/pages/mine/mine.wxss create mode 100644 miniprogram/pages/past-edit/past-edit.js create mode 100644 miniprogram/pages/past-edit/past-edit.json create mode 100644 miniprogram/pages/past-edit/past-edit.wxml create mode 100644 miniprogram/pages/past-edit/past-edit.wxss create mode 100644 miniprogram/pages/past/past.js create mode 100644 miniprogram/pages/past/past.json create mode 100644 miniprogram/pages/past/past.wxml create mode 100644 miniprogram/pages/past/past.wxss create mode 100644 miniprogram/pages/profile/profile.js create mode 100644 miniprogram/pages/profile/profile.json create mode 100644 miniprogram/pages/profile/profile.wxml create mode 100644 miniprogram/pages/profile/profile.wxss create mode 100644 miniprogram/pages/signup/signup.js create mode 100644 miniprogram/pages/signup/signup.json create mode 100644 miniprogram/pages/signup/signup.wxml create mode 100644 miniprogram/pages/signup/signup.wxss create mode 100644 miniprogram/project.config.json create mode 100644 miniprogram/sitemap.json create mode 100644 miniprogram/utils/activity.js create mode 100644 miniprogram/utils/api.js create mode 100644 miniprogram/utils/auth.js create mode 100644 miniprogram/utils/fallback.js create mode 100644 miniprogram/utils/media.js create mode 100644 miniprogram/utils/request.js create mode 100644 server/app.js create mode 100644 server/lib/auth.js create mode 100644 server/lib/db.js create mode 100644 server/lib/http.js create mode 100644 server/lib/passwords.js create mode 100644 server/lib/serialize.js create mode 100644 server/lib/upload.js create mode 100644 server/lib/wechat.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/public/admin.html create mode 100644 server/routes/admin.js create mode 100644 server/routes/public.js create mode 100644 server/server.js create mode 100644 server/test/api.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4002c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +project.private.config.json +miniprogram/project.private.config.json +server/public/uploads/ +server/data/db.json +server/data/*.tmp +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0da53c5 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# 卓米会小程序 + +微信原生小程序 + Node.js 后端(含管理后台)。 + +``` +miniprogram/ 小程序(WXML / WXSS / JS),用微信开发者工具导入 +server/ Express 后端 + 管理后台,数据存 server/data/db.json +``` + +## 快速开始 + +```bash +cd server +npm install +npm start # http://localhost:3000,管理后台 /admin +npm test # 接口冒烟测试(使用临时数据库,不影响真实数据) +``` + +1. 微信开发者工具导入 `miniprogram/`,详情 → 本地设置 → 勾选「不校验合法域名」。 +2. 小程序请求地址在 `miniprogram/config.js`(默认 `http://127.0.0.1:3000`)。 +3. 管理后台默认账号 `admin / 123456`,上线前务必在后台修改密码。 + +## 环境变量 + +| 变量 | 说明 | +| --- | --- | +| `PORT` | 端口,默认 3000 | +| `WX_APPID` / `WX_SECRET` | 配置后走真实 `code2session` 登录;不配置则为演示登录 | +| `DB_FILE` | 数据文件路径,默认 `server/data/db.json` | +| `UPLOAD_DIR` | 上传目录,默认 `server/public/uploads` | + +## 功能对照 + +- **可转发**:活动页 `onShareAppMessage` 分享到详情页,链接带分享人 openid,报名时记录来源。 +- **可编辑**:后台增删改活动(含往期、置顶、封面图、视频)、海报、报名、签到码。 +- **可数据统计**:后台「数据统计」看报名/签到/分享来源,报名可导出 CSV。 +- **可微信登录**:`wx.login` → `/api/login` → `code2session`。 + +小程序管理员只在后台「管理员」页设置;管理员在「往期」页直接看到增加、删除、置顶,界面上没有管理员登录入口。 + +## 目录说明 + +``` +server/ + app.js Express 应用(路由挂载、静态资源、统一错误处理) + server.js 启动入口 + routes/public.js 小程序接口 /api/* + routes/admin.js 管理后台接口 /api/admin/* + lib/db.js JSON 存储:内存缓存 + 原子写入 + 旧数据迁移 + lib/auth.js 用户 / 管理员鉴权 + lib/passwords.js 管理员密码 scrypt 哈希 + lib/serialize.js 活动 / 用户数据整形 + lib/upload.js 图片视频上传(multer) + lib/wechat.js code2session + public/admin.html 管理后台单页 +miniprogram/ + utils/api.js 所有接口封装(自动登录、token 过期自动重登) + utils/activity.js 导航、报名跳转、分享参数 + components/activity-board 首页 / 详情页共用的活动卡片 +``` + +## 上线前 + +- 服务器配置 HTTPS 域名,并在小程序后台添加 request / uploadFile / downloadFile 合法域名。 +- 修改管理后台默认密码;数据量大时换成正式数据库。 +- 在小程序后台填写《用户隐私保护指引》(昵称、头像、手机号、位置)。 diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..aa15b96 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,11 @@ +const authUtil = require('./utils/auth') + +App({ + onLaunch(options) { + authUtil.captureSharer(options && options.query) + authUtil.ensureLogin().catch(() => {}) + }, + onShow(options) { + authUtil.captureSharer(options && options.query) + } +}) diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..180669b --- /dev/null +++ b/miniprogram/app.json @@ -0,0 +1,47 @@ +{ + "pages": [ + "pages/index/index", + "pages/past/past", + "pages/mine/mine", + "pages/signup/signup", + "pages/profile/profile", + "pages/past-edit/past-edit", + "pages/detail/detail" + ], + "window": { + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTitleText": "卓米会", + "navigationBarTextStyle": "black", + "backgroundColor": "#F3F5F7", + "backgroundTextStyle": "dark" + }, + "tabBar": { + "color": "#9CA3AF", + "selectedColor": "#128A80", + "backgroundColor": "#FFFFFF", + "borderStyle": "white", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "assets/tab-home.png", + "selectedIconPath": "assets/tab-home-on.png" + }, + { + "pagePath": "pages/past/past", + "text": "往期", + "iconPath": "assets/tab-past.png", + "selectedIconPath": "assets/tab-past-on.png" + }, + { + "pagePath": "pages/mine/mine", + "text": "我的", + "iconPath": "assets/tab-mine.png", + "selectedIconPath": "assets/tab-mine-on.png" + } + ] + }, + "style": "v2", + "sitemapLocation": "sitemap.json", + "lazyCodeLoading": "requiredComponents" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..391985e --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1,11 @@ +page { + background: #f3f5f7; + color: #1f2937; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; + font-size: 28rpx; + line-height: 1.5; +} + +button::after { + border: none; +} diff --git a/miniprogram/assets/tab-home-on.png b/miniprogram/assets/tab-home-on.png new file mode 100644 index 0000000000000000000000000000000000000000..7c6c861fbfb35249a47d7f566c0d97510dfbcb65 GIT binary patch literal 8649 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 Ef{AIl`2YX_ literal 0 HcmV?d00001 diff --git a/miniprogram/assets/tab-mine.png b/miniprogram/assets/tab-mine.png new file mode 100644 index 0000000000000000000000000000000000000000..a6a93f163fb8f36e7d324b621a895a31c4495260 GIT binary patch literal 4986 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 Ef{AIl`2YX_ literal 0 HcmV?d00001 diff --git a/miniprogram/assets/tab-past-on.png b/miniprogram/assets/tab-past-on.png new file mode 100644 index 0000000000000000000000000000000000000000..ac5e03b110723161261a38d126908868e6a0c4f3 GIT binary patch literal 6958 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 AW&i*H literal 0 HcmV?d00001 diff --git a/miniprogram/assets/tab-past.png b/miniprogram/assets/tab-past.png new file mode 100644 index 0000000000000000000000000000000000000000..ac5e03b110723161261a38d126908868e6a0c4f3 GIT binary patch literal 6958 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 AW&i*H literal 0 HcmV?d00001 diff --git a/miniprogram/components/activity-board/index.js b/miniprogram/components/activity-board/index.js new file mode 100644 index 0000000..938b096 --- /dev/null +++ b/miniprogram/components/activity-board/index.js @@ -0,0 +1,41 @@ +const media = require('../../utils/media') + +Component({ + properties: { + activity: { + type: Object, + value: null + }, + showPast: { + type: Boolean, + value: false + } + }, + data: { + poster: '', + video: '', + ended: false + }, + observers: { + activity(activity) { + const item = activity || {} + this.setData({ + poster: media.absUrl(item.poster || item.cover), + video: media.absUrl(item.video), + ended: item.status === 'past' + }) + } + }, + methods: { + onMap() { + this.triggerEvent('map') + }, + onSignup() { + if (!this.data.activity || this.data.ended) return + this.triggerEvent('signup') + }, + onPast() { + this.triggerEvent('past') + } + } +}) diff --git a/miniprogram/components/activity-board/index.json b/miniprogram/components/activity-board/index.json new file mode 100644 index 0000000..467ce29 --- /dev/null +++ b/miniprogram/components/activity-board/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/miniprogram/components/activity-board/index.wxml b/miniprogram/components/activity-board/index.wxml new file mode 100644 index 0000000..437c46e --- /dev/null +++ b/miniprogram/components/activity-board/index.wxml @@ -0,0 +1,48 @@ + + + + + {{activity.brandName}} + {{activity.brandDesc}} + + + + + 活动名称 + {{activity.title}} + + + 活动时间 + {{activity.startAt || '待定'}} + + + 会场地址 + {{activity.address || '待定'}} + + + + 📍 一键导航 + {{ended ? '活动已结束' : '立即报名'}} + + 查看往期活动 › + diff --git a/miniprogram/components/activity-board/index.wxss b/miniprogram/components/activity-board/index.wxss new file mode 100644 index 0000000..49e4acd --- /dev/null +++ b/miniprogram/components/activity-board/index.wxss @@ -0,0 +1,139 @@ +.board { + padding: 28rpx 32rpx 48rpx; +} + +.banner { + position: relative; + height: 320rpx; + border-radius: 28rpx; + overflow: hidden; + background: linear-gradient(180deg, #d5dbe3 0%, #8e98a6 100%); +} + +.banner-media, +.banner-shade { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} + +.banner-shade { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 45%, rgba(0, 0, 0, 0.28) 100%); +} + +.badge { + position: absolute; + left: 24rpx; + top: 24rpx; + z-index: 2; + height: 48rpx; + padding: 0 20rpx; + border-radius: 24rpx; + background: rgba(55, 65, 81, 0.45); + color: #ffffff; + font-size: 24rpx; + line-height: 48rpx; +} + +.banner-title { + position: absolute; + left: 32rpx; + right: 32rpx; + bottom: 32rpx; + z-index: 1; + color: #ffffff; + font-size: 40rpx; + font-weight: 700; + line-height: 1.35; +} + +.card { + margin-top: 24rpx; + padding: 32rpx; + background: #ffffff; + border-radius: 28rpx; +} + +.brand { + display: block; + color: #128a80; + font-size: 36rpx; + font-weight: 700; +} + +.desc { + display: block; + margin-top: 16rpx; + color: #6b7280; + font-size: 28rpx; + line-height: 1.65; +} + +.info { + padding: 8rpx 32rpx; +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24rpx; + padding: 28rpx 0; + border-bottom: 1rpx dashed #e5e7eb; +} + +.row.last { + border-bottom: none; +} + +.label { + flex-shrink: 0; + color: #9ca3af; + font-size: 28rpx; +} + +.value { + flex: 1; + color: #111827; + font-size: 28rpx; + text-align: right; + line-height: 1.5; +} + +.btn { + height: 96rpx; + margin-top: 24rpx; + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 32rpx; + font-weight: 600; +} + +.btn-pressed { + opacity: 0.85; +} + +.green { + background: #07c160; +} + +.teal { + background: #0d8b87; +} + +.disabled { + background: #cbd5e1; +} + +.more { + margin-top: 36rpx; + padding: 8rpx 0; + text-align: center; + color: #9ca3af; + font-size: 28rpx; +} diff --git a/miniprogram/config.js b/miniprogram/config.js new file mode 100644 index 0000000..35c15b6 --- /dev/null +++ b/miniprogram/config.js @@ -0,0 +1,4 @@ +/** 后端地址。上线改为 HTTPS 域名,并加入小程序 request / downloadFile 合法域名。 */ +const BASE = 'http://127.0.0.1:3000' + +module.exports = { BASE } diff --git a/miniprogram/pages/detail/detail.js b/miniprogram/pages/detail/detail.js new file mode 100644 index 0000000..15ded1f --- /dev/null +++ b/miniprogram/pages/detail/detail.js @@ -0,0 +1,50 @@ +const api = require('../../utils/api') +const authUtil = require('../../utils/auth') +const activityUtil = require('../../utils/activity') + +Page({ + data: { + activity: null, + failed: false + }, + + onLoad(query) { + authUtil.captureSharer(query) + this.activityId = (query && query.id) || '' + wx.showShareMenu({ menus: ['shareAppMessage'] }) + this.loadActivity() + }, + + loadActivity() { + if (!this.activityId) { + this.setData({ failed: true }) + return + } + api + .activity(this.activityId) + .then((activity) => { + this.setData({ activity: activity, failed: false }) + if (activity && activity.title) wx.setNavigationBarTitle({ title: activity.title }) + }) + .catch((error) => { + this.setData({ failed: true }) + activityUtil.toast(error) + }) + }, + + openMap() { + activityUtil.openMap(this.data.activity) + }, + + goSignup() { + activityUtil.goSignup(this.data.activity) + }, + + goHome() { + wx.switchTab({ url: '/pages/index/index' }) + }, + + onShareAppMessage() { + return activityUtil.shareMessage(this.data.activity, this.activityId) + } +}) diff --git a/miniprogram/pages/detail/detail.json b/miniprogram/pages/detail/detail.json new file mode 100644 index 0000000..265e0d3 --- /dev/null +++ b/miniprogram/pages/detail/detail.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "卓米会", + "usingComponents": { + "activity-board": "/components/activity-board/index" + } +} diff --git a/miniprogram/pages/detail/detail.wxml b/miniprogram/pages/detail/detail.wxml new file mode 100644 index 0000000..d317b1f --- /dev/null +++ b/miniprogram/pages/detail/detail.wxml @@ -0,0 +1,12 @@ + + + 活动不存在或已下线 + 回到首页 + +加载中 diff --git a/miniprogram/pages/detail/detail.wxss b/miniprogram/pages/detail/detail.wxss new file mode 100644 index 0000000..7dcd58d --- /dev/null +++ b/miniprogram/pages/detail/detail.wxss @@ -0,0 +1,17 @@ +.empty { + padding: 160rpx 32rpx; + text-align: center; + color: #9ca3af; + font-size: 28rpx; +} + +.home { + display: inline-block; + margin-top: 32rpx; + padding: 0 40rpx; + height: 76rpx; + line-height: 76rpx; + border-radius: 38rpx; + border: 2rpx solid #128a80; + color: #128a80; +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js new file mode 100644 index 0000000..a6883c9 --- /dev/null +++ b/miniprogram/pages/index/index.js @@ -0,0 +1,48 @@ +const api = require('../../utils/api') +const authUtil = require('../../utils/auth') +const activityUtil = require('../../utils/activity') +const fallback = require('../../utils/fallback') + +Page({ + data: { + activity: fallback.CURRENT + }, + + onLoad(query) { + authUtil.captureSharer(query) + wx.showShareMenu({ menus: ['shareAppMessage'] }) + }, + + onShow() { + this.loadActivity() + }, + + onPullDownRefresh() { + this.loadActivity().then(() => wx.stopPullDownRefresh()) + }, + + loadActivity() { + return api + .currentActivity() + .then((activity) => { + if (activity) this.setData({ activity: activity }) + }) + .catch(() => {}) + }, + + openMap() { + activityUtil.openMap(this.data.activity) + }, + + goSignup() { + activityUtil.goSignup(this.data.activity) + }, + + goPast() { + wx.switchTab({ url: '/pages/past/past' }) + }, + + onShareAppMessage() { + return activityUtil.shareMessage(this.data.activity) + } +}) diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..fe6bbba --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "卓米会", + "enablePullDownRefresh": true, + "usingComponents": { + "activity-board": "/components/activity-board/index" + } +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..8a5f313 --- /dev/null +++ b/miniprogram/pages/index/index.wxml @@ -0,0 +1,7 @@ + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..ff761e9 --- /dev/null +++ b/miniprogram/pages/index/index.wxss @@ -0,0 +1,3 @@ +page { + background: #f3f5f7; +} diff --git a/miniprogram/pages/mine/mine.js b/miniprogram/pages/mine/mine.js new file mode 100644 index 0000000..4b27989 --- /dev/null +++ b/miniprogram/pages/mine/mine.js @@ -0,0 +1,56 @@ +const api = require('../../utils/api') +const media = require('../../utils/media') +const activityUtil = require('../../utils/activity') + +Page({ + data: { + nickName: '', + avatarUrl: '', + letter: '微', + signups: [], + checkins: [], + checking: false + }, + + onShow() { + this.loadAll() + }, + + onPullDownRefresh() { + this.loadAll().then(() => wx.stopPullDownRefresh()) + }, + + loadAll() { + return Promise.all([api.profile(), api.mySignups()]) + .then((results) => { + const profile = results[0] || {} + const mine = results[1] || {} + const nickName = profile.nickName || '' + this.setData({ + nickName: nickName, + avatarUrl: media.absUrl(profile.avatarUrl), + letter: (nickName || '微').slice(0, 1), + signups: mine.signups || [], + checkins: mine.checkins || [] + }) + }) + .catch(() => {}) + }, + + goProfile() { + wx.navigateTo({ url: '/pages/profile/profile' }) + }, + + checkin() { + if (this.data.checking) return + this.setData({ checking: true }) + api + .checkin({ simulate: true }) + .then(() => { + wx.showToast({ title: '签到成功', icon: 'success' }) + return this.loadAll() + }) + .catch(activityUtil.toast) + .then(() => this.setData({ checking: false })) + } +}) diff --git a/miniprogram/pages/mine/mine.json b/miniprogram/pages/mine/mine.json new file mode 100644 index 0000000..4ec55be --- /dev/null +++ b/miniprogram/pages/mine/mine.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的" +} diff --git a/miniprogram/pages/mine/mine.wxml b/miniprogram/pages/mine/mine.wxml new file mode 100644 index 0000000..fc50fae --- /dev/null +++ b/miniprogram/pages/mine/mine.wxml @@ -0,0 +1,35 @@ + + + + {{letter}} + + {{nickName || '微信用户'}} + 点击编辑资料 + + + + + 我的报名 + 暂无报名记录,去首页报名吧 + + + {{item.activityTitle}} + {{nickName || item.name}} · {{item.phone}} + + {{item.checkedIn ? '已签到' : '未签到'}} + + + + + 签到记录 + 暂无签到记录 + + + {{item.activityTitle}} + 已签到 + + + + + 模拟现场扫码签到 + diff --git a/miniprogram/pages/mine/mine.wxss b/miniprogram/pages/mine/mine.wxss new file mode 100644 index 0000000..fa67fb0 --- /dev/null +++ b/miniprogram/pages/mine/mine.wxss @@ -0,0 +1,131 @@ +.page { + min-height: 100vh; + box-sizing: border-box; + padding: 28rpx 32rpx 48rpx; +} + +.card { + background: #ffffff; + border-radius: 28rpx; + margin-bottom: 24rpx; +} + +.profile { + display: flex; + align-items: center; + gap: 28rpx; + padding: 36rpx 32rpx; +} + +.pressed { + opacity: 0.85; +} + +.avatar { + flex-shrink: 0; + width: 112rpx; + height: 112rpx; + border-radius: 56rpx; +} + +.letter { + background: #128a80; + color: #ffffff; + font-size: 42rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.who { + flex: 1; + min-width: 0; +} + +.name { + display: block; + font-size: 36rpx; + font-weight: 700; + color: #111827; +} + +.edit { + display: block; + margin-top: 10rpx; + color: #9ca3af; + font-size: 24rpx; +} + +.block { + padding: 32rpx; +} + +.heading { + display: block; + font-size: 28rpx; + font-weight: 500; + color: #9ca3af; +} + +.empty { + padding: 56rpx 0 28rpx; + text-align: center; + color: #c5cdd6; + font-size: 28rpx; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; + padding-top: 28rpx; +} + +.item-main { + flex: 1; + min-width: 0; +} + +.item-title { + display: block; + font-size: 30rpx; + font-weight: 600; + color: #111827; +} + +.item-sub { + display: block; + margin-top: 6rpx; + color: #9ca3af; + font-size: 24rpx; +} + +.tag { + flex-shrink: 0; + color: #9ca3af; + font-size: 24rpx; +} + +.tag.on { + color: #128a80; +} + +.scan { + height: 96rpx; + margin-top: 8rpx; + border-radius: 20rpx; + border: 2rpx solid #128a80; + color: #128a80; + font-size: 32rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + background: #ffffff; +} + +.scan-pressed { + background: #f0faf9; +} diff --git a/miniprogram/pages/past-edit/past-edit.js b/miniprogram/pages/past-edit/past-edit.js new file mode 100644 index 0000000..9f74eb5 --- /dev/null +++ b/miniprogram/pages/past-edit/past-edit.js @@ -0,0 +1,121 @@ +const api = require('../../utils/api') +const media = require('../../utils/media') +const activityUtil = require('../../utils/activity') + +function chooseOne(mediaType) { + return new Promise((resolve, reject) => { + wx.chooseMedia({ + count: 1, + mediaType: [mediaType], + sourceType: ['album', 'camera'], + maxDuration: 60, + success: (res) => { + const file = res.tempFiles && res.tempFiles[0] + if (file && file.tempFilePath) resolve(file.tempFilePath) + }, + fail: reject + }) + }) +} + +function uploadWithLoading(filePath) { + wx.showLoading({ title: '上传中', mask: true }) + return api.uploadMedia(filePath).then( + (data) => { + wx.hideLoading() + return data + }, + (error) => { + wx.hideLoading() + throw error + } + ) +} + +Page({ + data: { + title: '', + startAt: '', + pinned: false, + cover: '', + coverPreview: '', + video: '', + saving: false + }, + + onLoad() { + api + .profile() + .then((profile) => { + if (profile && profile.isAdmin) return + wx.showToast({ title: '仅管理员可操作', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 400) + }) + .catch((error) => { + activityUtil.toast(error) + setTimeout(() => wx.navigateBack(), 400) + }) + }, + + onTitle(event) { + this.setData({ title: event.detail.value }) + }, + + onTime(event) { + this.setData({ startAt: event.detail.value }) + }, + + togglePinned() { + this.setData({ pinned: !this.data.pinned }) + }, + + pickCover() { + chooseOne('image') + .then(uploadWithLoading) + .then((data) => this.setData({ cover: data.url, coverPreview: media.absUrl(data.url) })) + .catch((error) => { + if (error && error.message) activityUtil.toast(error) + }) + }, + + clearCover() { + this.setData({ cover: '', coverPreview: '' }) + }, + + pickVideo() { + chooseOne('video') + .then(uploadWithLoading) + .then((data) => this.setData({ video: data.url })) + .catch((error) => { + if (error && error.message) activityUtil.toast(error) + }) + }, + + clearVideo() { + this.setData({ video: '' }) + }, + + save() { + if (this.data.saving) return + const title = this.data.title.trim() + const startAt = this.data.startAt.trim() + if (!title || !startAt) { + wx.showToast({ title: '请填写名称和时间', icon: 'none' }) + return + } + this.setData({ saving: true }) + api + .createPast({ + title: title, + startAt: startAt, + pinned: this.data.pinned, + cover: this.data.cover, + video: this.data.video + }) + .then(() => wx.navigateBack()) + .catch((error) => { + this.setData({ saving: false }) + activityUtil.toast(error) + }) + } +}) diff --git a/miniprogram/pages/past-edit/past-edit.json b/miniprogram/pages/past-edit/past-edit.json new file mode 100644 index 0000000..bb84551 --- /dev/null +++ b/miniprogram/pages/past-edit/past-edit.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "新增往期" +} diff --git a/miniprogram/pages/past-edit/past-edit.wxml b/miniprogram/pages/past-edit/past-edit.wxml new file mode 100644 index 0000000..894f7c8 --- /dev/null +++ b/miniprogram/pages/past-edit/past-edit.wxml @@ -0,0 +1,42 @@ + + + 活动名称 + + 活动时间 + + + 封面图片 + + + {{cover ? '更换图片' : '选择图片'}} + 清除 + + + 活动视频 + + {{video ? '已选择视频' : '未选择'}} + {{video ? '更换视频' : '选择视频'}} + 清除 + + + + + 创建后置顶(可同时置顶多个) + + + {{saving ? '保存中…' : '保存'}} + diff --git a/miniprogram/pages/past-edit/past-edit.wxss b/miniprogram/pages/past-edit/past-edit.wxss new file mode 100644 index 0000000..debea6a --- /dev/null +++ b/miniprogram/pages/past-edit/past-edit.wxss @@ -0,0 +1,119 @@ +.page { + padding: 28rpx 32rpx 48rpx; +} + +.card { + padding: 12rpx 32rpx 28rpx; + background: #ffffff; + border-radius: 28rpx; +} + +.label { + display: block; + margin-top: 28rpx; + color: #6b7280; + font-size: 26rpx; +} + +.input { + height: 88rpx; + margin-top: 8rpx; + border-bottom: 1rpx solid #eef0f3; + font-size: 32rpx; + color: #111827; +} + +.placeholder { + color: #c4c9d1; +} + +.pin-row { + display: flex; + align-items: center; + gap: 16rpx; + margin-top: 32rpx; + padding-bottom: 8rpx; + color: #374151; + font-size: 28rpx; +} + +.media-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16rpx; + margin-top: 16rpx; +} + +.preview { + width: 120rpx; + height: 120rpx; + border-radius: 16rpx; + background: #eef0f3; +} + +.media-name { + color: #6b7280; + font-size: 26rpx; +} + +.media-btn { + height: 64rpx; + padding: 0 24rpx; + border-radius: 32rpx; + background: #128a80; + color: #ffffff; + font-size: 26rpx; + display: flex; + align-items: center; +} + +.media-btn.ghost { + background: #ffffff; + color: #6b7280; + border: 2rpx solid #e5e7eb; +} + +.check { + width: 36rpx; + height: 36rpx; + border-radius: 8rpx; + border: 2rpx solid #cbd5e1; + box-sizing: border-box; +} + +.check.on { + background: #128a80; + border-color: #128a80; + position: relative; +} + +.check.on::after { + content: ''; + position: absolute; + left: 10rpx; + top: 4rpx; + width: 10rpx; + height: 18rpx; + border: 4rpx solid #ffffff; + border-top: 0; + border-left: 0; + transform: rotate(45deg); +} + +.save { + height: 96rpx; + margin-top: 36rpx; + border-radius: 20rpx; + background: #128a80; + color: #ffffff; + font-size: 32rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.save.busy { + opacity: 0.7; +} diff --git a/miniprogram/pages/past/past.js b/miniprogram/pages/past/past.js new file mode 100644 index 0000000..647beb3 --- /dev/null +++ b/miniprogram/pages/past/past.js @@ -0,0 +1,91 @@ +const api = require('../../utils/api') +const media = require('../../utils/media') +const activityUtil = require('../../utils/activity') + +function decorate(item) { + const cover = item.cover || item.poster || '' + return Object.assign({}, item, { coverUrl: cover ? media.absUrl(cover) : '' }) +} + +Page({ + data: { + list: [], + loaded: false, + deleting: false, + isAdmin: false + }, + + onShow() { + this.loadPage() + }, + + onPullDownRefresh() { + this.loadPage().then(() => wx.stopPullDownRefresh()) + }, + + loadPage() { + const profile = api.profile().catch(() => null) + return Promise.all([api.pastActivities(), profile]) + .then((results) => { + const isAdmin = !!(results[1] && results[1].isAdmin) + this.setData({ + list: (results[0] || []).map(decorate), + loaded: true, + isAdmin: isAdmin, + deleting: isAdmin && this.data.deleting + }) + }) + .catch((error) => { + this.setData({ loaded: true }) + activityUtil.toast(error) + }) + }, + + openDetail(event) { + if (this.data.deleting) return + wx.navigateTo({ url: '/pages/detail/detail?id=' + event.currentTarget.dataset.id }) + }, + + onAdd() { + if (!this.data.isAdmin) return + wx.navigateTo({ url: '/pages/past-edit/past-edit' }) + }, + + onDelete() { + if (!this.data.isAdmin) return + if (!this.data.list.length) { + wx.showToast({ title: '还没有可以删除的活动', icon: 'none' }) + return + } + this.setData({ deleting: !this.data.deleting }) + }, + + remove(event) { + if (!this.data.isAdmin) return + const id = event.currentTarget.dataset.id + wx.showModal({ + title: '删除这场活动?', + content: event.currentTarget.dataset.title, + confirmColor: '#DC2626', + success: (res) => { + if (!res.confirm) return + api + .removePast(id) + .then(() => { + const list = this.data.list.filter((item) => item.id !== id) + this.setData({ list: list, deleting: list.length > 0 && this.data.deleting }) + }) + .catch(activityUtil.toast) + } + }) + }, + + togglePin(event) { + if (!this.data.isAdmin) return + const dataset = event.currentTarget.dataset + api + .pinPast(dataset.id, String(dataset.pinned) !== '1') + .then(() => this.loadPage()) + .catch(activityUtil.toast) + } +}) diff --git a/miniprogram/pages/past/past.json b/miniprogram/pages/past/past.json new file mode 100644 index 0000000..ee45d90 --- /dev/null +++ b/miniprogram/pages/past/past.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "往期活动", + "enablePullDownRefresh": true +} diff --git a/miniprogram/pages/past/past.wxml b/miniprogram/pages/past/past.wxml new file mode 100644 index 0000000..3eb2e57 --- /dev/null +++ b/miniprogram/pages/past/past.wxml @@ -0,0 +1,46 @@ + + + 增加 + {{deleting ? '完成' : '删除'}} + + + 还没有往期活动 + + + + + + 置顶 + ▶ 视频 + {{item.title}} + + + + {{item.title}} + + {{item.pinned ? '取消置顶' : '置顶'}} + 删除 + + + {{item.startAt}} + + + diff --git a/miniprogram/pages/past/past.wxss b/miniprogram/pages/past/past.wxss new file mode 100644 index 0000000..0da9867 --- /dev/null +++ b/miniprogram/pages/past/past.wxss @@ -0,0 +1,163 @@ +.page { + padding: 28rpx 32rpx 48rpx; +} + +.actions { + display: flex; + gap: 20rpx; + margin-bottom: 4rpx; +} + +.btn { + flex: 1; + height: 84rpx; + border-radius: 18rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 30rpx; + font-weight: 600; +} + +.add { + background: #128a80; + color: #ffffff; +} + +.del { + background: #ffffff; + color: #dc2626; + border: 2rpx solid #fecaca; +} + +.del.active { + background: #fff1f2; +} + +.empty { + margin-top: 160rpx; + text-align: center; + color: #9ca3af; + font-size: 28rpx; +} + +.card { + margin-top: 28rpx; + background: #ffffff; + border-radius: 28rpx; + overflow: hidden; + box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.04); +} + +.card:first-child { + margin-top: 0; +} + +.card.pinned { + box-shadow: 0 8rpx 24rpx rgba(18, 138, 128, 0.14); +} + +.cover { + position: relative; + height: 272rpx; + padding: 32rpx; + display: flex; + align-items: flex-end; + box-sizing: border-box; + overflow: hidden; +} + +.cover-image, +.cover-shade { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} + +.cover-shade { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%); +} + +.cover-title { + position: relative; + z-index: 1; + color: #ffffff; + font-size: 36rpx; + font-weight: 700; + line-height: 1.4; +} + +.pin-badge, +.video-badge { + position: absolute; + top: 20rpx; + height: 44rpx; + padding: 0 16rpx; + border-radius: 22rpx; + background: rgba(0, 0, 0, 0.3); + color: #ffffff; + font-size: 22rpx; + font-weight: 600; + line-height: 44rpx; + z-index: 1; +} + +.pin-badge { + left: 20rpx; +} + +.video-badge { + right: 20rpx; +} + +.theme-0 { + background: linear-gradient(135deg, #0b6b66 0%, #13857c 100%); +} + +.theme-1 { + background: linear-gradient(135deg, #1aa39a 0%, #4fd1c5 100%); +} + +.body { + padding: 28rpx 32rpx 32rpx; +} + +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} + +.name { + flex: 1; + min-width: 0; + font-size: 32rpx; + font-weight: 700; + color: #111827; +} + +.ops { + flex-shrink: 0; +} + +.time { + display: block; + margin-top: 12rpx; + color: #9ca3af; + font-size: 26rpx; +} + +.pin-btn { + color: #128a80; + font-size: 26rpx; + font-weight: 600; +} + +.remove { + color: #dc2626; + font-size: 26rpx; + font-weight: 600; +} diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js new file mode 100644 index 0000000..2465ca8 --- /dev/null +++ b/miniprogram/pages/profile/profile.js @@ -0,0 +1,97 @@ +const api = require('../../utils/api') +const media = require('../../utils/media') +const activityUtil = require('../../utils/activity') + +const FIELDS = ['nickName', 'name', 'phone', 'company', 'position'] + +Page({ + data: { + avatarUrl: '', + avatarPreview: '', + nickName: '', + name: '', + phone: '', + company: '', + position: '', + letter: '微', + uploading: false, + saving: false + }, + + onLoad() { + api + .profile() + .then((profile) => { + const next = { + avatarUrl: profile.avatarUrl || '', + avatarPreview: media.absUrl(profile.avatarUrl), + letter: (profile.nickName || profile.name || '微').slice(0, 1) + } + FIELDS.forEach((key) => { + next[key] = profile[key] || '' + }) + this.setData(next) + }) + .catch(activityUtil.toast) + }, + + pickAvatar() { + if (this.data.uploading) return + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'], + success: (res) => { + const file = res.tempFiles && res.tempFiles[0] + if (!file || !file.tempFilePath) return + this.setData({ uploading: true, avatarPreview: file.tempFilePath }) + api + .uploadAvatar(file.tempFilePath) + .then((data) => this.setData({ avatarUrl: data.url, uploading: false })) + .catch((error) => { + this.setData({ uploading: false, avatarPreview: media.absUrl(this.data.avatarUrl) }) + activityUtil.toast(error) + }) + } + }) + }, + + onInput(event) { + const next = {} + next[event.currentTarget.dataset.field] = event.detail.value + this.setData(next) + }, + + save() { + if (this.data.saving) return + if (this.data.uploading) { + wx.showToast({ title: '头像上传中,请稍候', icon: 'none' }) + return + } + const payload = { avatarUrl: this.data.avatarUrl } + FIELDS.forEach((key) => { + payload[key] = (this.data[key] || '').trim() + }) + if (!payload.name && !payload.nickName) { + wx.showToast({ title: '请至少填写昵称或姓名', icon: 'none' }) + return + } + if (payload.phone && !/^1\d{10}$/.test(payload.phone)) { + wx.showToast({ title: '请填写正确的手机号', icon: 'none' }) + return + } + + this.setData({ saving: true }) + api + .saveProfile(payload) + .then(() => { + wx.showToast({ title: '已保存', icon: 'success' }) + setTimeout(() => wx.navigateBack(), 500) + }) + .catch((error) => { + this.setData({ saving: false }) + activityUtil.toast(error) + }) + } +}) diff --git a/miniprogram/pages/profile/profile.json b/miniprogram/pages/profile/profile.json new file mode 100644 index 0000000..c51587e --- /dev/null +++ b/miniprogram/pages/profile/profile.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "编辑资料" +} diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml new file mode 100644 index 0000000..8114277 --- /dev/null +++ b/miniprogram/pages/profile/profile.wxml @@ -0,0 +1,24 @@ + + + + + {{letter}} + + {{uploading ? '头像上传中…' : '点击更换头像'}} + + + + 昵称 + + 姓名 + + 手机号 + + 公司 + + 职位 + + + + + diff --git a/miniprogram/pages/profile/profile.wxss b/miniprogram/pages/profile/profile.wxss new file mode 100644 index 0000000..7cdfb15 --- /dev/null +++ b/miniprogram/pages/profile/profile.wxss @@ -0,0 +1,94 @@ +.page { + min-height: 100vh; + box-sizing: border-box; + padding: 28rpx 32rpx 48rpx; + background: #f3f5f7; +} + +.card { + background: #ffffff; + border-radius: 28rpx; + padding: 12rpx 32rpx 28rpx; + margin-bottom: 24rpx; +} + +.avatar-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 36rpx 32rpx; +} + +.avatar-btn { + width: 128rpx; + height: 128rpx; + border-radius: 64rpx; + overflow: hidden; +} + +.avatar, +.letter { + width: 128rpx; + height: 128rpx; + border-radius: 64rpx; +} + +.letter { + background: #128a80; + color: #ffffff; + font-size: 44rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.hint { + margin-top: 16rpx; + color: #9ca3af; + font-size: 24rpx; +} + +.label { + display: block; + margin-top: 28rpx; + color: #6b7280; + font-size: 26rpx; +} + +.input { + height: 88rpx; + margin-top: 8rpx; + border-bottom: 1rpx solid #eef0f3; + font-size: 32rpx; + color: #111827; +} + +.placeholder { + color: #c4c9d1; +} + +.save { + width: 100% !important; + height: 96rpx; + margin: 12rpx 0 0; + padding: 0; + border-radius: 20rpx; + background: #128a80; + color: #ffffff; + font-size: 32rpx; + font-weight: 600; + line-height: 96rpx; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.save::after { + border: none; +} + +.save[disabled] { + opacity: 0.7; +} diff --git a/miniprogram/pages/signup/signup.js b/miniprogram/pages/signup/signup.js new file mode 100644 index 0000000..c022b9e --- /dev/null +++ b/miniprogram/pages/signup/signup.js @@ -0,0 +1,71 @@ +const api = require('../../utils/api') +const authUtil = require('../../utils/auth') +const activityUtil = require('../../utils/activity') + +Page({ + data: { + name: '', + phone: '', + company: '', + submitting: false + }, + + onLoad(query) { + this.activityId = (query && query.id) || '' + api + .profile() + .then((profile) => { + if (!profile) return + this.setData({ + name: this.data.name || profile.name || '', + phone: this.data.phone || profile.phone || '', + company: this.data.company || profile.company || '' + }) + }) + .catch(() => {}) + }, + + onName(event) { + this.setData({ name: event.detail.value }) + }, + + onPhone(event) { + this.setData({ phone: event.detail.value }) + }, + + onCompany(event) { + this.setData({ company: event.detail.value }) + }, + + submit() { + if (this.data.submitting) return + const name = this.data.name.trim() + const phone = this.data.phone.trim() + const company = this.data.company.trim() + if (!name || !phone) { + wx.showToast({ title: '请填写姓名和手机号', icon: 'none' }) + return + } + if (!/^1\d{10}$/.test(phone)) { + wx.showToast({ title: '请填写正确的手机号', icon: 'none' }) + return + } + this.setData({ submitting: true }) + api + .signup({ + activityId: this.activityId, + name: name, + phone: phone, + company: company, + sourceSharer: authUtil.sourceSharer() + }) + .then(() => { + wx.showToast({ title: '报名成功', icon: 'success' }) + setTimeout(() => wx.navigateBack(), 600) + }) + .catch((error) => { + this.setData({ submitting: false }) + activityUtil.toast(error) + }) + } +}) diff --git a/miniprogram/pages/signup/signup.json b/miniprogram/pages/signup/signup.json new file mode 100644 index 0000000..79115fd --- /dev/null +++ b/miniprogram/pages/signup/signup.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "立即报名" +} diff --git a/miniprogram/pages/signup/signup.wxml b/miniprogram/pages/signup/signup.wxml new file mode 100644 index 0000000..622227a --- /dev/null +++ b/miniprogram/pages/signup/signup.wxml @@ -0,0 +1,12 @@ + + + 姓名 + + 手机号 + + 公司 / 机构 + + + {{submitting ? '提交中…' : '提交报名'}} + 手机号仅用于本次活动通知。 + diff --git a/miniprogram/pages/signup/signup.wxss b/miniprogram/pages/signup/signup.wxss new file mode 100644 index 0000000..73073bc --- /dev/null +++ b/miniprogram/pages/signup/signup.wxss @@ -0,0 +1,52 @@ +.page { + padding: 28rpx 32rpx 48rpx; +} + +.card { + padding: 12rpx 32rpx 28rpx; + background: #ffffff; + border-radius: 28rpx; +} + +.label { + display: block; + margin-top: 28rpx; + color: #6b7280; + font-size: 26rpx; +} + +.input { + height: 88rpx; + margin-top: 8rpx; + border-bottom: 1rpx solid #eef0f3; + font-size: 32rpx; +} + +.placeholder { + color: #c4c9d1; +} + +.save { + height: 96rpx; + margin-top: 36rpx; + border-radius: 20rpx; + background: #128a80; + color: #ffffff; + font-size: 32rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.save.busy { + opacity: 0.7; +} + +.hint { + display: block; + margin-top: 20rpx; + text-align: center; + color: #9ca3af; + font-size: 24rpx; +} diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json new file mode 100644 index 0000000..60479af --- /dev/null +++ b/miniprogram/project.config.json @@ -0,0 +1,56 @@ +{ + "description": "卓米会小程序", + "packOptions": { + "ignore": [], + "include": [] + }, + "setting": { + "es6": true, + "enhance": false, + "postcss": true, + "minified": true, + "newFeature": false, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, + "compileHotReLoad": true, + "lazyloadPlaceholderEnable": false, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "useIsolateContext": true, + "userConfirmedBundleSwitch": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true, + "compileWorklet": false + }, + "compileType": "miniprogram", + "libVersion": "3.8.10", + "appid": "wx5f75e1013b7b5a31", + "projectname": "zhuomi-mp", + "condition": {}, + "editorSetting": { + "tabIndent": "insertSpaces", + "tabSize": 2 + }, + "simulatorPluginLibVersion": {} +} \ No newline at end of file diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 0000000..292c4ed --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,4 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ "action": "allow", "page": "*" }] +} diff --git a/miniprogram/utils/activity.js b/miniprogram/utils/activity.js new file mode 100644 index 0000000..f5e4328 --- /dev/null +++ b/miniprogram/utils/activity.js @@ -0,0 +1,44 @@ +const media = require('./media') + +function openMap(activity) { + if (!activity || activity.latitude == null || activity.longitude == null) { + wx.showToast({ title: '暂无会场坐标', icon: 'none' }) + return + } + wx.openLocation({ + latitude: Number(activity.latitude), + longitude: Number(activity.longitude), + name: activity.title, + address: activity.address, + scale: 16 + }) +} + +function goSignup(activity) { + if (!activity || !activity.id) return + wx.navigateTo({ url: '/pages/signup/signup?id=' + activity.id }) +} + +// Shared links always land on the detail page and carry the sharer's openid for attribution. +function shareMessage(activity, fallbackId) { + const item = activity || {} + const openid = wx.getStorageSync('openid') || '' + const cover = item.poster || item.cover + const message = { + title: item.title || '卓米会', + path: '/pages/detail/detail?id=' + (item.id || fallbackId || '') + '&sharer=' + openid + } + if (cover) message.imageUrl = media.absUrl(cover) + return message +} + +function toast(error) { + wx.showToast({ title: (error && error.message) || '操作失败', icon: 'none' }) +} + +module.exports = { + openMap: openMap, + goSignup: goSignup, + shareMessage: shareMessage, + toast: toast +} diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js new file mode 100644 index 0000000..2c81a7c --- /dev/null +++ b/miniprogram/utils/api.js @@ -0,0 +1,42 @@ +const requestUtil = require('./request') +const authUtil = require('./auth') + +// Runs a logged-in call; if the stored token has expired, logs in again and retries once. +function withLogin(run) { + return authUtil + .ensureLogin() + .then(run) + .catch((error) => { + if (error.statusCode !== 401) throw error + return authUtil.ensureLogin().then(run) + }) +} + +function authed(url, method, data) { + return withLogin(() => + requestUtil.request({ url: url, method: method || 'GET', data: data, auth: true }) + ) +} + +function uploadTo(url, filePath) { + return withLogin(() => requestUtil.upload({ url: url, filePath: filePath })) +} + +module.exports = { + currentActivity: () => requestUtil.request({ url: '/api/activity/current' }), + pastActivities: () => requestUtil.request({ url: '/api/activity/past' }), + activity: (id) => requestUtil.request({ url: '/api/activity/' + id }), + + profile: () => authed('/api/user/profile'), + saveProfile: (data) => authed('/api/user/profile', 'POST', data), + uploadAvatar: (filePath) => uploadTo('/api/user/avatar', filePath), + + createPast: (data) => authed('/api/activity/past', 'POST', data), + pinPast: (id, pinned) => authed('/api/activity/past/' + id + '/pin', 'POST', { pinned: pinned }), + removePast: (id) => authed('/api/activity/past/' + id, 'DELETE'), + uploadMedia: (filePath) => uploadTo('/api/upload', filePath), + + signup: (data) => authed('/api/signup', 'POST', data), + mySignups: () => authed('/api/signup/mine'), + checkin: (data) => authed('/api/checkin/scan', 'POST', data) +} diff --git a/miniprogram/utils/auth.js b/miniprogram/utils/auth.js new file mode 100644 index 0000000..ec1a425 --- /dev/null +++ b/miniprogram/utils/auth.js @@ -0,0 +1,75 @@ +const requestUtil = require('./request') + +let pending = null + +function deviceId() { + let id = wx.getStorageSync('deviceId') + if (!id) { + id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + wx.setStorageSync('deviceId', id) + } + return id +} + +function captureSharer(query) { + if (!query || !query.sharer) return + const selfId = wx.getStorageSync('openid') + if (query.sharer !== selfId) wx.setStorageSync('sharer', query.sharer) +} + +function login() { + return new Promise((resolve, reject) => { + wx.login({ + success(res) { + if (!res.code) { + reject(new Error('微信登录未返回 code')) + return + } + requestUtil + .request({ + url: '/api/login', + method: 'POST', + data: { code: res.code, deviceId: deviceId() } + }) + .then((data) => { + wx.setStorageSync('token', data.token) + wx.setStorageSync('openid', data.openid) + wx.setStorageSync('loginDemo', !!data.demo) + resolve(data) + }) + .catch(reject) + }, + fail() { + reject(new Error('微信登录失败')) + } + }) + }) +} + +function ensureLogin() { + if (wx.getStorageSync('token')) { + return Promise.resolve({ + token: wx.getStorageSync('token'), + openid: wx.getStorageSync('openid') + }) + } + if (!pending) { + pending = login().finally(() => { + pending = null + }) + } + return pending +} + +function sourceSharer() { + const sharer = wx.getStorageSync('sharer') || '' + const openid = wx.getStorageSync('openid') || '' + if (!sharer || sharer === openid) return '' + return sharer +} + +module.exports = { + captureSharer: captureSharer, + ensureLogin: ensureLogin, + sourceSharer: sourceSharer +} diff --git a/miniprogram/utils/fallback.js b/miniprogram/utils/fallback.js new file mode 100644 index 0000000..b3aadbe --- /dev/null +++ b/miniprogram/utils/fallback.js @@ -0,0 +1,15 @@ +const CURRENT = { + id: '93', + title: '2026年卓米第93期卓米会', + brandName: '卓米会 ZHUOMI', + brandDesc: '专注于品牌增长与企业服务的共创平台,连接资源、方法与同行者。', + startAt: '2026-06-18 14:00', + address: '上海市浦东新区世纪大道 100 号 卓米中心 3F', + latitude: 31.235929, + longitude: 121.506321, + status: 'current', + isCurrent: true, + poster: '' +} + +module.exports = { CURRENT } diff --git a/miniprogram/utils/media.js b/miniprogram/utils/media.js new file mode 100644 index 0000000..d68b69f --- /dev/null +++ b/miniprogram/utils/media.js @@ -0,0 +1,9 @@ +const config = require('../config') + +function absUrl(url) { + if (!url) return '' + if (/^https?:\/\//.test(url)) return url + return config.BASE + url +} + +module.exports = { absUrl: absUrl } diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js new file mode 100644 index 0000000..2169c03 --- /dev/null +++ b/miniprogram/utils/request.js @@ -0,0 +1,75 @@ +const config = require('../config') + +function networkError(err) { + const msg = (err && err.errMsg) || '' + if (msg.indexOf('url not in domain list') >= 0 || msg.indexOf('合法域名') >= 0) { + return new Error('请勾选:不校验合法域名') + } + return new Error('网络异常,请确认后端已启动') +} + +function apiError(statusCode, body) { + const error = new Error((body && body.message) || '请求失败') + error.statusCode = statusCode + return error +} + +function authHeader(header) { + const token = wx.getStorageSync('token') + if (token) header.Authorization = token + return header +} + +function handle(statusCode, body, auth, resolve, reject) { + if (statusCode === 401 && auth) wx.removeStorageSync('token') + if (statusCode >= 200 && statusCode < 300 && body && body.ok !== false) { + resolve(body.data) + return + } + reject(apiError(statusCode, body)) +} + +function request(options) { + const auth = !!options.auth + const header = { 'content-type': 'application/json' } + return new Promise((resolve, reject) => { + wx.request({ + url: config.BASE + options.url, + method: options.method || 'GET', + data: options.data, + header: auth ? authHeader(header) : header, + success(res) { + handle(res.statusCode, res.data || {}, auth, resolve, reject) + }, + fail(err) { + reject(networkError(err)) + } + }) + }) +} + +function upload(options) { + return new Promise((resolve, reject) => { + wx.uploadFile({ + url: config.BASE + options.url, + filePath: options.filePath, + name: 'file', + header: authHeader({}), + success(res) { + let body = null + try { + body = JSON.parse(res.data || '{}') + } catch (error) { + reject(new Error('上传返回异常')) + return + } + handle(res.statusCode, body, true, resolve, reject) + }, + fail(err) { + reject(networkError(err)) + } + }) + }) +} + +module.exports = { request: request, upload: upload } diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..25ec6ac --- /dev/null +++ b/server/app.js @@ -0,0 +1,31 @@ +const path = require('path') +const express = require('express') +const { uploadDir } = require('./lib/upload') +const { fail } = require('./lib/http') + +const app = express() +const publicDir = path.join(__dirname, 'public') + +app.disable('x-powered-by') +app.use(express.json({ limit: '1mb' })) +app.use('/static/uploads', express.static(uploadDir, { maxAge: '7d' })) +app.use('/static', express.static(publicDir)) + +app.use('/api/admin', require('./routes/admin')) +app.use('/api', require('./routes/public')) + +app.get('/admin', (req, res) => res.sendFile(path.join(publicDir, 'admin.html'))) +app.get('/', (req, res) => res.redirect('/admin')) + +app.use('/api', (req, res) => fail(res, 404, '接口不存在')) + +app.use((error, req, res, next) => { + if (res.headersSent) return next(error) + if (error.type === 'entity.parse.failed') return fail(res, 400, '请求格式错误') + if (error.type === 'entity.too.large') return fail(res, 413, '请求内容过大') + const status = error.status && error.status < 500 ? error.status : 500 + if (status === 500) console.error(error) + fail(res, status, status === 500 ? '服务器开小差了' : error.message) +}) + +module.exports = app diff --git a/server/lib/auth.js b/server/lib/auth.js new file mode 100644 index 0000000..ca7e5be --- /dev/null +++ b/server/lib/auth.js @@ -0,0 +1,37 @@ +const db = require('./db') +const { fail } = require('./http') + +function headerToken(req) { + return String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim() +} + +function isAdmin(openid, data) { + return (data || db.read()).adminAllowlist.includes(openid) +} + +function userAuth(req, res, next) { + const token = headerToken(req) + const user = token && db.read().users.find((item) => item.token === token) + if (!user) return fail(res, 401, '请先登录') + req.user = user + next() +} + +function requireUserAdmin(req, res, next) { + if (!req.user || !isAdmin(req.user.openid)) return fail(res, 403, '仅管理员可操作') + next() +} + +// Query tokens are only accepted where a plain link must work (CSV download). +function adminAuth(options) { + const allowQuery = !!(options && options.allowQueryToken) + return (req, res, next) => { + const token = headerToken(req) || (allowQuery ? String(req.query.token || '').trim() : '') + const admin = token && db.read().admins.find((item) => item.token === token) + if (!admin) return fail(res, 401, '请先登录管理后台') + req.admin = admin + next() + } +} + +module.exports = { userAuth, requireUserAdmin, adminAuth, isAdmin } diff --git a/server/lib/db.js b/server/lib/db.js new file mode 100644 index 0000000..17a8d42 --- /dev/null +++ b/server/lib/db.js @@ -0,0 +1,109 @@ +const fs = require('fs') +const path = require('path') +const passwords = require('./passwords') + +const file = process.env.DB_FILE + ? path.resolve(process.env.DB_FILE) + : path.join(__dirname, '../data/db.json') + +const SEED = { + users: [], + admins: [{ username: 'admin', password: '123456', token: '' }], + adminAllowlist: [], + activities: [ + { + id: '93', + title: '2026年卓米第93期卓米会', + brandName: '卓米会 ZHUOMI', + brandDesc: '专注于品牌增长与企业服务的共创平台,连接资源、方法与同行者。', + cover: '', + video: '', + startAt: '2026-06-18 14:00', + address: '上海市浦东新区世纪大道 100 号 卓米中心 3F', + latitude: 31.235929, + longitude: 121.506321, + status: 'current', + isCurrent: true, + pinned: false, + pinnedAt: '', + theme: 0, + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z' + } + ], + posters: [], + signups: [], + checkinCodes: [] +} + +const COLLECTIONS = ['users', 'admins', 'adminAllowlist', 'activities', 'posters', 'signups', 'checkinCodes'] + +let cache = null + +// Returns true when the stored shape changed and must be persisted. +function normalize(data) { + let dirty = false + COLLECTIONS.forEach((key) => { + if (!Array.isArray(data[key])) { + data[key] = [] + dirty = true + } + }) + + // The allowlist is the only source of truth for mini-program admins. + data.users.forEach((user) => { + // Client temp paths (http://tmp/..., wxfile://...) were saved by older builds and never resolve. + if (user.avatarUrl && !user.avatarUrl.startsWith('/static/uploads/')) { + user.avatarUrl = '' + dirty = true + } + if (!('role' in user)) return + if (user.role === 'admin' && !data.adminAllowlist.includes(user.openid)) { + data.adminAllowlist.push(user.openid) + } + delete user.role + dirty = true + }) + + data.admins.forEach((admin) => { + if (!passwords.isHashed(admin.password)) { + admin.password = passwords.hash(admin.password || '') + dirty = true + } + }) + return dirty +} + +function persist(data) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.${process.pid}.tmp` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)) + fs.renameSync(tmp, file) +} + +function load() { + if (cache) return cache + const data = fs.existsSync(file) + ? JSON.parse(fs.readFileSync(file, 'utf8')) + : JSON.parse(JSON.stringify(SEED)) + if (normalize(data) || !fs.existsSync(file)) persist(data) + cache = data + return cache +} + +function read() { + return load() +} + +function update(mutator) { + const data = load() + const result = mutator(data) + persist(data) + return result +} + +function reset() { + cache = null +} + +module.exports = { read, update, reset, file, SEED } diff --git a/server/lib/http.js b/server/lib/http.js new file mode 100644 index 0000000..e08699d --- /dev/null +++ b/server/lib/http.js @@ -0,0 +1,42 @@ +const crypto = require('crypto') + +class HttpError extends Error { + constructor(status, message) { + super(message) + this.status = status + } +} + +function uid(prefix) { + return `${prefix}${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}` +} + +function token() { + return crypto.randomBytes(24).toString('hex') +} + +function ok(res, data) { + res.json({ ok: true, data }) +} + +function fail(res, status, message) { + res.status(status).json({ ok: false, message }) +} + +// Express 4 does not forward rejected promises to the error handler. +function wrap(handler) { + return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next) +} + +function str(value, max) { + const text = value == null ? '' : String(value).trim() + return max ? text.slice(0, max) : text +} + +function has(body, key) { + return Object.prototype.hasOwnProperty.call(body || {}, key) +} + +const PHONE_RE = /^1\d{10}$/ + +module.exports = { HttpError, uid, token, ok, fail, wrap, str, has, PHONE_RE } diff --git a/server/lib/passwords.js b/server/lib/passwords.js new file mode 100644 index 0000000..3e33200 --- /dev/null +++ b/server/lib/passwords.js @@ -0,0 +1,23 @@ +const crypto = require('crypto') + +const PREFIX = 'scrypt$' + +function hash(plain) { + const salt = crypto.randomBytes(16).toString('hex') + const digest = crypto.scryptSync(String(plain), salt, 32).toString('hex') + return `${PREFIX}${salt}$${digest}` +} + +function isHashed(stored) { + return typeof stored === 'string' && stored.startsWith(PREFIX) +} + +function verify(plain, stored) { + if (!isHashed(stored)) return false + const parts = stored.split('$') + const expected = Buffer.from(parts[2], 'hex') + const actual = crypto.scryptSync(String(plain), parts[1], expected.length) + return crypto.timingSafeEqual(expected, actual) +} + +module.exports = { hash, verify, isHashed } diff --git a/server/lib/serialize.js b/server/lib/serialize.js new file mode 100644 index 0000000..2e08a20 --- /dev/null +++ b/server/lib/serialize.js @@ -0,0 +1,132 @@ +const { uid, str, has } = require('./http') +const { isAdmin } = require('./auth') + +const STATUSES = ['current', 'past', 'draft'] +const DEFAULT_BRAND = '卓米会 ZHUOMI' + +function publicActivity(activity, posters) { + const poster = (posters || []) + .filter((item) => item.activityId === activity.id && item.enabled && item.image) + .sort((a, b) => a.sort - b.sort)[0] + return { + id: activity.id, + title: activity.title, + brandName: activity.brandName || DEFAULT_BRAND, + brandDesc: activity.brandDesc || '', + startAt: activity.startAt || '', + address: activity.address || '', + latitude: activity.latitude, + longitude: activity.longitude, + status: activity.status, + isCurrent: !!activity.isCurrent, + pinned: !!activity.pinned, + pinnedAt: activity.pinnedAt || '', + theme: activity.theme || 0, + cover: activity.cover || '', + video: activity.video || '', + poster: (poster && poster.image) || activity.cover || '', + createdAt: activity.createdAt + } +} + +function currentActivity(data) { + return ( + data.activities.find((item) => item.isCurrent && item.status !== 'draft') || + data.activities.find((item) => item.status === 'current') || + null + ) +} + +function byPinnedThenNewest(a, b) { + const ap = a.pinned ? 1 : 0 + const bp = b.pinned ? 1 : 0 + if (ap !== bp) return bp - ap + if (ap) return String(b.pinnedAt || '').localeCompare(String(a.pinnedAt || '')) + return String(b.createdAt).localeCompare(String(a.createdAt)) +} + +function pastActivities(data) { + return data.activities.filter((item) => item.status === 'past').sort(byPinnedThenNewest) +} + +function nextPastTheme(data) { + return data.activities.filter((item) => item.status === 'past').length % 2 +} + +function setPinned(activity, pinned) { + const now = new Date().toISOString() + activity.pinned = !!pinned + activity.pinnedAt = pinned ? now : '' + activity.updatedAt = now + return activity +} + +const PROFILE_FIELDS = ['nickName', 'avatarUrl', 'name', 'phone', 'company', 'position'] + +function publicProfile(user, data) { + const profile = { openid: user.openid } + PROFILE_FIELDS.forEach((key) => { + profile[key] = user[key] || '' + }) + profile.isAdmin = isAdmin(user.openid, data) + return profile +} + +function coordinate(value) { + if (value === '' || value == null) return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function activityPayload(body, prev, data) { + const now = new Date().toISOString() + const status = STATUSES.includes(body.status) ? body.status : 'draft' + const keep = (key, fallback) => (has(body, key) ? str(body[key]) : prev ? prev[key] || fallback : fallback) + + let pinned = prev ? !!prev.pinned : false + let pinnedAt = prev ? prev.pinnedAt || '' : '' + if (status !== 'past') { + pinned = false + pinnedAt = '' + } else if (has(body, 'pinned')) { + const requested = body.pinned === true || body.pinned === 'on' || body.pinned === 'true' + if (requested !== pinned) pinnedAt = requested ? now : '' + pinned = requested + } + + let theme = Number(body.theme) + if (!has(body, 'theme') || !Number.isFinite(theme)) { + theme = prev ? prev.theme || 0 : status === 'past' ? nextPastTheme(data) : 0 + } + + return { + id: prev ? prev.id : uid('a'), + title: str(body.title, 60), + brandName: str(body.brandName) || DEFAULT_BRAND, + brandDesc: str(body.brandDesc, 300), + cover: keep('cover', ''), + video: keep('video', ''), + startAt: str(body.startAt, 32), + address: str(body.address, 120), + latitude: coordinate(body.latitude), + longitude: coordinate(body.longitude), + status, + isCurrent: !!body.isCurrent, + pinned, + pinnedAt, + theme, + createdAt: prev ? prev.createdAt : now, + updatedAt: now + } +} + +module.exports = { + publicActivity, + currentActivity, + pastActivities, + nextPastTheme, + setPinned, + publicProfile, + activityPayload, + DEFAULT_BRAND +} diff --git a/server/lib/upload.js b/server/lib/upload.js new file mode 100644 index 0000000..8ad7639 --- /dev/null +++ b/server/lib/upload.js @@ -0,0 +1,52 @@ +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const multer = require('multer') +const { ok, fail } = require('./http') + +const uploadDir = process.env.UPLOAD_DIR + ? path.resolve(process.env.UPLOAD_DIR) + : path.join(__dirname, '..', 'public', 'uploads') + +const IMAGE_RE = /^image\/(png|jpe?g|webp)$/i +const VIDEO_RE = /^video\/(mp4|quicktime|webm)$/i + +const storage = multer.diskStorage({ + destination(req, file, cb) { + fs.mkdirSync(uploadDir, { recursive: true }) + cb(null, uploadDir) + }, + filename(req, file, cb) { + const ext = path.extname(file.originalname || '').toLowerCase() || '.bin' + cb(null, `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}${ext}`) + } +}) + +function uploader(options) { + const imagesOnly = !!(options && options.imagesOnly) + const middleware = multer({ + storage, + limits: { fileSize: imagesOnly ? 5 * 1024 * 1024 : 80 * 1024 * 1024 }, + fileFilter(req, file, cb) { + const mime = file.mimetype || '' + const allowed = IMAGE_RE.test(mime) || (!imagesOnly && VIDEO_RE.test(mime)) + cb(null, allowed) + } + }).single('file') + + return (req, res) => { + middleware(req, res, (error) => { + if (error) { + const tooLarge = error.code === 'LIMIT_FILE_SIZE' + return fail(res, 400, tooLarge ? (imagesOnly ? '图片需小于 5MB' : '文件需小于 80MB') : '上传失败') + } + if (!req.file) { + return fail(res, 400, imagesOnly ? '仅支持 png/jpg/webp 图片' : '仅支持 png/jpg/webp 图片或 mp4/mov/webm 视频') + } + const type = VIDEO_RE.test(req.file.mimetype) ? 'video' : 'image' + ok(res, { url: `/static/uploads/${req.file.filename}`, type }) + }) + } +} + +module.exports = { uploader, uploadDir } diff --git a/server/lib/wechat.js b/server/lib/wechat.js new file mode 100644 index 0000000..8827ff2 --- /dev/null +++ b/server/lib/wechat.js @@ -0,0 +1,34 @@ +const crypto = require('crypto') +const { HttpError } = require('./http') + +async function code2session(code, deviceId) { + const appid = process.env.WX_APPID + const secret = process.env.WX_SECRET + if (!appid || !secret) { + const seed = deviceId || 'guest' + return { + openid: `demo_${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16)}`, + demo: true + } + } + + const url = new URL('https://api.weixin.qq.com/sns/jscode2session') + url.search = new URLSearchParams({ + appid, + secret, + js_code: code, + grant_type: 'authorization_code' + }).toString() + + let session + try { + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) + session = await res.json() + } catch (error) { + throw new HttpError(502, '微信登录服务不可用') + } + if (!session.openid) throw new HttpError(400, session.errmsg || '微信登录失败') + return { openid: session.openid, demo: false } +} + +module.exports = { code2session } diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..b5d93a0 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1182 @@ +{ + "name": "zhuomi-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zhuomi-server", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2", + "multer": "^2.4.0", + "qrcode": "^1.5.4" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.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/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/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/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "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/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/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/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/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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-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/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/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/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/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/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": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "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/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/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/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "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/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/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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.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/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..1046e1a --- /dev/null +++ b/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "zhuomi-server", + "version": "1.1.0", + "private": true, + "main": "server.js", + "engines": { + "node": ">=18" + }, + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "multer": "^2.4.0", + "qrcode": "^1.5.4" + } +} diff --git a/server/public/admin.html b/server/public/admin.html new file mode 100644 index 0000000..3e240e5 --- /dev/null +++ b/server/public/admin.html @@ -0,0 +1,477 @@ + + + + + + 卓米会管理后台 + + + +

+ +
+
+ 卓米会 +
+ + +
+
+ +
+
+ + + + + +
+
+ + + diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..72ab3b8 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,296 @@ +const crypto = require('crypto') +const express = require('express') +const QRCode = require('qrcode') +const db = require('../lib/db') +const passwords = require('../lib/passwords') +const { uploader } = require('../lib/upload') +const { adminAuth } = require('../lib/auth') +const { uid, token, ok, fail, wrap, str } = require('../lib/http') +const { activityPayload, setPinned } = require('../lib/serialize') + +const router = express.Router() +const auth = adminAuth() + +router.post('/login', (req, res) => { + const username = str(req.body && req.body.username) + const password = String((req.body && req.body.password) || '') + const session = db.update((data) => { + const admin = data.admins.find((item) => item.username === username) + if (!admin || !passwords.verify(password, admin.password)) return null + admin.token = token() + return { token: admin.token, username: admin.username } + }) + if (!session) return fail(res, 401, '账号或密码错误') + ok(res, session) +}) + +router.post('/password', auth, (req, res) => { + const oldPassword = String((req.body && req.body.oldPassword) || '') + const nextPassword = String((req.body && req.body.newPassword) || '') + if (nextPassword.length < 6) return fail(res, 400, '新密码至少 6 位') + if (!passwords.verify(oldPassword, req.admin.password)) return fail(res, 400, '原密码不正确') + db.update(() => { + req.admin.password = passwords.hash(nextPassword) + req.admin.token = '' + }) + ok(res, { relogin: true }) +}) + +router.get('/stats', auth, (req, res) => { + const data = db.read() + const byActivity = data.activities.map((activity) => { + const rows = data.signups.filter((item) => item.activityId === activity.id) + return { + id: activity.id, + title: activity.title, + signups: rows.length, + checkins: rows.filter((item) => item.checkedIn).length + } + }) + const sharerMap = {} + data.signups.forEach((item) => { + const key = item.sourceSharer || '直接访问' + sharerMap[key] = (sharerMap[key] || 0) + 1 + }) + ok(res, { + activityCount: data.activities.length, + signupCount: data.signups.length, + checkinCount: data.signups.filter((item) => item.checkedIn).length, + byActivity, + bySharer: Object.keys(sharerMap).map((sharer) => ({ sharer, count: sharerMap[sharer] })) + }) +}) + +router.get('/users', auth, (req, res) => { + const data = db.read() + ok( + res, + data.users.map((user) => ({ + openid: user.openid, + nickName: user.nickName || '', + name: user.name || '', + phone: user.phone || '', + allowed: data.adminAllowlist.includes(user.openid), + createdAt: user.createdAt || '' + })) + ) +}) + +router.get('/allowlist', auth, (req, res) => { + ok(res, db.read().adminAllowlist) +}) + +router.post('/allowlist', auth, (req, res) => { + const openid = str(req.body && req.body.openid) + if (!openid) return fail(res, 400, '缺少 openid') + ok( + res, + db.update((data) => { + if (!data.adminAllowlist.includes(openid)) data.adminAllowlist.push(openid) + return data.adminAllowlist + }) + ) +}) + +router.delete('/allowlist/:openid', auth, (req, res) => { + const openid = req.params.openid + ok( + res, + db.update((data) => { + data.adminAllowlist = data.adminAllowlist.filter((item) => item !== openid) + return data.adminAllowlist + }) + ) +}) + +function clearOtherCurrent(data, keepId) { + data.activities.forEach((item) => { + if (item.id !== keepId) item.isCurrent = false + }) +} + +router.get('/activities', auth, (req, res) => { + ok(res, db.read().activities) +}) + +router.post('/activities', auth, (req, res) => { + const created = db.update((data) => { + const payload = activityPayload(req.body || {}, null, data) + if (!payload.title) return null + if (payload.isCurrent) clearOtherCurrent(data, payload.id) + data.activities.push(payload) + return payload + }) + if (!created) return fail(res, 400, '请填写活动名称') + ok(res, created) +}) + +router.put('/activities/:id', auth, (req, res) => { + const result = db.update((data) => { + const index = data.activities.findIndex((item) => item.id === req.params.id) + if (index < 0) return { status: 404, error: '活动不存在' } + const payload = activityPayload(req.body || {}, data.activities[index], data) + if (!payload.title) return { status: 400, error: '请填写活动名称' } + if (payload.isCurrent) clearOtherCurrent(data, payload.id) + data.activities[index] = payload + return { activity: payload } + }) + if (result.error) return fail(res, result.status, result.error) + ok(res, result.activity) +}) + +router.post('/activities/:id/pin', auth, (req, res) => { + const result = db.update((data) => { + const activity = data.activities.find((item) => item.id === req.params.id) + if (!activity) return { status: 404, error: '活动不存在' } + if (activity.status !== 'past') return { status: 400, error: '只能置顶往期活动' } + return { activity: setPinned(activity, !!(req.body && req.body.pinned)) } + }) + if (result.error) return fail(res, result.status, result.error) + ok(res, result.activity) +}) + +router.delete('/activities/:id', auth, (req, res) => { + const removed = db.update((data) => { + if (!data.activities.some((item) => item.id === req.params.id)) return false + data.activities = data.activities.filter((item) => item.id !== req.params.id) + data.posters = data.posters.filter((item) => item.activityId !== req.params.id) + data.checkinCodes = data.checkinCodes.filter((item) => item.activityId !== req.params.id) + return true + }) + if (!removed) return fail(res, 404, '活动不存在') + ok(res, { id: req.params.id }) +}) + +router.get('/posters', auth, (req, res) => { + ok(res, db.read().posters) +}) + +router.post('/posters', auth, (req, res) => { + const body = req.body || {} + if (!body.activityId) return fail(res, 400, '请选择活动') + const poster = { + id: uid('m'), + activityId: str(body.activityId), + image: str(body.image), + title: str(body.title, 60), + enabled: body.enabled !== false, + sort: Number(body.sort) || 0, + createdAt: new Date().toISOString() + } + db.update((data) => data.posters.push(poster)) + ok(res, poster) +}) + +router.put('/posters/:id', auth, (req, res) => { + const body = req.body || {} + const updated = db.update((data) => { + const poster = data.posters.find((item) => item.id === req.params.id) + if (!poster) return null + if (body.activityId != null) poster.activityId = str(body.activityId) + if (body.image != null) poster.image = str(body.image) + if (body.title != null) poster.title = str(body.title, 60) + if (body.enabled != null) poster.enabled = !!body.enabled + if (body.sort != null) poster.sort = Number(body.sort) || 0 + return poster + }) + if (!updated) return fail(res, 404, '海报不存在') + ok(res, updated) +}) + +router.delete('/posters/:id', auth, (req, res) => { + const removed = db.update((data) => { + const before = data.posters.length + data.posters = data.posters.filter((item) => item.id !== req.params.id) + return data.posters.length !== before + }) + if (!removed) return fail(res, 404, '海报不存在') + ok(res, { id: req.params.id }) +}) + +router.post('/upload-file', auth, uploader()) + +function signupRows(activityId) { + const rows = db.read().signups + return activityId ? rows.filter((item) => item.activityId === activityId) : rows +} + +router.get('/signups', auth, (req, res) => { + ok(res, signupRows(str(req.query.activityId))) +}) + +function csvCell(value) { + const text = value == null ? '' : String(value) + // Prefix formula-leading cells so spreadsheets don't execute them. + const safe = /^[=+\-@]/.test(text) ? `'${text}` : text + return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe +} + +router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) => { + const header = ['姓名', '手机号', '公司', '活动', '分享人', '报名时间', '是否签到', '签到时间'] + const lines = [header.join(',')] + signupRows(str(req.query.activityId)).forEach((item) => { + lines.push( + [ + item.name, + item.phone, + item.company, + item.activityTitle, + item.sourceSharer, + item.createdAt, + item.checkedIn ? '是' : '否', + item.checkedInAt + ] + .map(csvCell) + .join(',') + ) + }) + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader('Content-Disposition', 'attachment; filename="signups.csv"') + res.send(`\uFEFF${lines.join('\n')}`) +}) + +router.delete('/signups/:id', auth, (req, res) => { + const removed = db.update((data) => { + const before = data.signups.length + data.signups = data.signups.filter((item) => item.id !== req.params.id) + return data.signups.length !== before + }) + if (!removed) return fail(res, 404, '报名不存在') + ok(res, { id: req.params.id }) +}) + +function withQr(code, width) { + return QRCode.toDataURL(code.token, { width, margin: 1 }).then((qrDataUrl) => + Object.assign({}, code, { qrDataUrl }) + ) +} + +router.get( + '/checkin-codes', + auth, + wrap(async (req, res) => { + ok(res, await Promise.all(db.read().checkinCodes.map((code) => withQr(code, 280)))) + }) +) + +router.post( + '/checkin-codes', + auth, + wrap(async (req, res) => { + const activityId = str(req.body && req.body.activityId) + const hours = Math.min(Math.max(Number(req.body && req.body.ttlHours) || 12, 1), 24 * 30) + if (!db.read().activities.some((item) => item.id === activityId)) return fail(res, 404, '活动不存在') + const code = { + id: uid('c'), + activityId, + token: crypto.randomBytes(16).toString('hex'), + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + hours * 3600 * 1000).toISOString() + } + db.update((data) => data.checkinCodes.push(code)) + ok(res, await withQr(code, 360)) + }) +) + +module.exports = router diff --git a/server/routes/public.js b/server/routes/public.js new file mode 100644 index 0000000..9cb701b --- /dev/null +++ b/server/routes/public.js @@ -0,0 +1,223 @@ +const express = require('express') +const db = require('../lib/db') +const wechat = require('../lib/wechat') +const { uploader } = require('../lib/upload') +const { userAuth, requireUserAdmin } = require('../lib/auth') +const { HttpError, uid, token, ok, fail, wrap, str, has, PHONE_RE } = require('../lib/http') +const { + publicActivity, + currentActivity, + pastActivities, + nextPastTheme, + setPinned, + publicProfile, + DEFAULT_BRAND +} = require('../lib/serialize') + +const router = express.Router() + +router.post( + '/login', + wrap(async (req, res) => { + const body = req.body || {} + const code = str(body.code) + if (!code) throw new HttpError(400, '缺少 code') + const session = await wechat.code2session(code, str(body.deviceId)) + + const profile = db.update((data) => { + let user = data.users.find((item) => item.openid === session.openid) + if (!user) { + user = { + openid: session.openid, + nickName: '', + avatarUrl: '', + name: '', + phone: '', + company: '', + position: '', + createdAt: new Date().toISOString() + } + data.users.push(user) + } + user.token = token() + return Object.assign({ token: user.token, demo: session.demo }, publicProfile(user, data)) + }) + ok(res, profile) + }) +) + +router.get('/user/profile', userAuth, (req, res) => { + ok(res, publicProfile(req.user)) +}) + +const PROFILE_LIMITS = { nickName: 32, name: 20, phone: 11, company: 40, position: 40 } + +router.post('/user/profile', userAuth, (req, res) => { + const body = req.body || {} + if (has(body, 'phone') && str(body.phone) && !PHONE_RE.test(str(body.phone))) { + return fail(res, 400, '请填写正确的手机号') + } + const profile = db.update((data) => { + const user = req.user + Object.keys(PROFILE_LIMITS).forEach((key) => { + if (has(body, key)) user[key] = str(body[key], PROFILE_LIMITS[key]) + }) + // Only server-hosted avatars are accepted; temp paths like http://tmp/... are useless to others. + const avatarUrl = str(body.avatarUrl) + if (avatarUrl && avatarUrl.startsWith('/static/uploads/')) user.avatarUrl = avatarUrl + return publicProfile(user, data) + }) + ok(res, profile) +}) + +router.post('/user/avatar', userAuth, uploader({ imagesOnly: true })) + +router.get('/activity/current', (req, res) => { + const data = db.read() + const activity = currentActivity(data) + ok(res, activity ? publicActivity(activity, data.posters) : null) +}) + +router.get('/activity/past', (req, res) => { + const data = db.read() + ok(res, pastActivities(data).map((item) => publicActivity(item, data.posters))) +}) + +router.get('/activity/:id', (req, res) => { + const data = db.read() + const activity = data.activities.find((item) => item.id === req.params.id && item.status !== 'draft') + if (!activity) return fail(res, 404, '活动不存在') + ok(res, publicActivity(activity, data.posters)) +}) + +router.post('/activity/past', userAuth, requireUserAdmin, (req, res) => { + const body = req.body || {} + const title = str(body.title, 40) + const startAt = str(body.startAt, 32) + if (!title || !startAt) return fail(res, 400, '请填写名称和时间') + const activity = db.update((data) => { + const now = new Date().toISOString() + const next = { + id: uid('p'), + title, + brandName: DEFAULT_BRAND, + brandDesc: '', + cover: str(body.cover), + video: str(body.video), + startAt, + address: '', + latitude: null, + longitude: null, + status: 'past', + isCurrent: false, + pinned: false, + pinnedAt: '', + theme: nextPastTheme(data), + createdAt: now, + updatedAt: now + } + if (body.pinned) setPinned(next, true) + data.activities.push(next) + return next + }) + ok(res, publicActivity(activity)) +}) + +function findPast(data, id) { + return data.activities.find((item) => item.id === id && item.status === 'past') +} + +router.post('/activity/past/:id/pin', userAuth, requireUserAdmin, (req, res) => { + const updated = db.update((data) => { + const activity = findPast(data, req.params.id) + return activity ? setPinned(activity, !!(req.body && req.body.pinned)) : null + }) + if (!updated) return fail(res, 404, '往期活动不存在') + ok(res, publicActivity(updated)) +}) + +router.delete('/activity/past/:id', userAuth, requireUserAdmin, (req, res) => { + const removed = db.update((data) => { + const activity = findPast(data, req.params.id) + if (!activity) return null + data.activities = data.activities.filter((item) => item.id !== activity.id) + data.posters = data.posters.filter((item) => item.activityId !== activity.id) + return activity.id + }) + if (!removed) return fail(res, 404, '往期活动不存在') + ok(res, { id: removed }) +}) + +router.post('/signup', userAuth, (req, res) => { + const body = req.body || {} + const activityId = str(body.activityId) + const name = str(body.name, 20) + const phone = str(body.phone) + let sourceSharer = str(body.sourceSharer, 64) + if (!activityId || !name || !phone) return fail(res, 400, '请填写姓名和手机号') + if (!PHONE_RE.test(phone)) return fail(res, 400, '请填写正确的手机号') + if (sourceSharer === req.user.openid) sourceSharer = '' + + const result = db.update((data) => { + const activity = data.activities.find((item) => item.id === activityId) + if (!activity || activity.status === 'draft') return { error: '活动不存在' } + if (activity.status === 'past') return { error: '这场活动已结束' } + const exists = data.signups.some((item) => item.activityId === activityId && item.openid === req.user.openid) + if (exists) return { error: '你已经报名过了' } + const signup = { + id: uid('s'), + activityId, + activityTitle: activity.title, + openid: req.user.openid, + name, + phone, + company: str(body.company, 40), + sourceSharer, + createdAt: new Date().toISOString(), + checkedIn: false, + checkedInAt: '' + } + data.signups.push(signup) + return { signup } + }) + if (result.error) return fail(res, 400, result.error) + ok(res, result.signup) +}) + +router.get('/signup/mine', userAuth, (req, res) => { + const signups = db + .read() + .signups.filter((item) => item.openid === req.user.openid) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) + ok(res, { signups, checkins: signups.filter((item) => item.checkedIn) }) +}) + +router.post('/checkin/scan', userAuth, (req, res) => { + const body = req.body || {} + const result = db.update((data) => { + let activityId = '' + if (body.simulate) { + const activity = currentActivity(data) + if (!activity) return { error: '当前没有进行中的活动' } + activityId = activity.id + } else { + const code = data.checkinCodes.find((item) => item.token === str(body.token)) + if (!code) return { error: '签到码无效' } + if (code.expiresAt && Date.parse(code.expiresAt) < Date.now()) return { error: '签到码已过期' } + activityId = code.activityId + } + const signup = data.signups.find((item) => item.activityId === activityId && item.openid === req.user.openid) + if (!signup) return { error: '请先报名再签到' } + if (!signup.checkedIn) { + signup.checkedIn = true + signup.checkedInAt = new Date().toISOString() + } + return { signup } + }) + if (result.error) return fail(res, 400, result.error) + ok(res, result.signup) +}) + +router.post('/upload', userAuth, requireUserAdmin, uploader()) + +module.exports = router diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..00224da --- /dev/null +++ b/server/server.js @@ -0,0 +1,14 @@ +const app = require('./app') +const db = require('./lib/db') + +const PORT = Number(process.env.PORT) || 3000 + +db.read() + +app.listen(PORT, () => { + console.log(`卓米会服务已启动 http://localhost:${PORT}`) + console.log(`管理后台 http://localhost:${PORT}/admin`) + if (!process.env.WX_APPID || !process.env.WX_SECRET) { + console.log('未配置 WX_APPID / WX_SECRET,小程序登录走演示模式') + } +}) diff --git a/server/test/api.test.js b/server/test/api.test.js new file mode 100644 index 0000000..6085929 --- /dev/null +++ b/server/test/api.test.js @@ -0,0 +1,156 @@ +const test = require('node:test') +const assert = require('node:assert') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zhuomi-')) +process.env.DB_FILE = path.join(tmp, 'db.json') +process.env.UPLOAD_DIR = path.join(tmp, 'uploads') +delete process.env.WX_APPID +delete process.env.WX_SECRET + +const app = require('../app') + +let base = '' +let server + +test.before(async () => { + server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + base = `http://127.0.0.1:${server.address().port}` +}) + +test.after(() => { + server.close() + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +async function call(method, url, options) { + const opts = options || {} + const headers = {} + if (opts.token) headers.Authorization = opts.token + let body + if (opts.form) body = opts.form + else if (opts.body) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(opts.body) + } + const res = await fetch(base + url, { method, headers, body }) + const type = res.headers.get('content-type') || '' + return { status: res.status, body: type.includes('json') ? await res.json() : await res.text() } +} + +async function login(deviceId) { + const res = await call('POST', '/api/login', { body: { code: 'x', deviceId } }) + assert.strictEqual(res.status, 200) + return res.body.data +} + +test('seeded admin password is stored hashed', async () => { + await call('GET', '/api/activity/current') + const data = JSON.parse(fs.readFileSync(process.env.DB_FILE, 'utf8')) + assert.match(data.admins[0].password, /^scrypt\$/) +}) + +test('full flow: admin, past activity, signup, checkin, export', async () => { + const adminLogin = await call('POST', '/api/admin/login', { body: { username: 'admin', password: '123456' } }) + const adminToken = adminLogin.body.data.token + assert.ok(adminToken) + assert.strictEqual((await call('POST', '/api/admin/login', { body: { username: 'admin', password: 'bad' } })).status, 401) + + const user = await login('device-a') + assert.strictEqual(user.isAdmin, false) + + const denied = await call('POST', '/api/activity/past', { token: user.token, body: { title: 'x', startAt: 'y' } }) + assert.strictEqual(denied.status, 403) + + await call('POST', '/api/admin/allowlist', { token: adminToken, body: { openid: user.openid } }) + const profile = await call('GET', '/api/user/profile', { token: user.token }) + assert.strictEqual(profile.body.data.isAdmin, true) + + const first = await call('POST', '/api/activity/past', { token: user.token, body: { title: '往期一', startAt: '2026-01-01' } }) + const second = await call('POST', '/api/activity/past', { token: user.token, body: { title: '往期二', startAt: '2026-02-01' } }) + assert.strictEqual(first.body.data.theme, 0) + assert.strictEqual(second.body.data.theme, 1) + + await call('POST', `/api/activity/past/${first.body.data.id}/pin`, { token: user.token, body: { pinned: true } }) + const past = await call('GET', '/api/activity/past') + assert.deepStrictEqual(past.body.data.map((item) => item.title), ['往期一', '往期二']) + + const del = await call('DELETE', `/api/activity/past/${second.body.data.id}`, { token: user.token }) + assert.strictEqual(del.status, 200) + + const bad = await call('POST', '/api/signup', { token: user.token, body: { activityId: '93', name: '张三', phone: '123' } }) + assert.strictEqual(bad.status, 400) + + const guest = await login('device-b') + const signup = await call('POST', '/api/signup', { + token: guest.token, + body: { activityId: '93', name: '张三', phone: '13800000000', company: '=cmd', sourceSharer: user.openid } + }) + assert.strictEqual(signup.status, 200) + const dup = await call('POST', '/api/signup', { token: guest.token, body: { activityId: '93', name: '张三', phone: '13800000000' } }) + assert.strictEqual(dup.status, 400) + + const checkin = await call('POST', '/api/checkin/scan', { token: guest.token, body: { simulate: true } }) + assert.strictEqual(checkin.body.data.checkedIn, true) + const mine = await call('GET', '/api/signup/mine', { token: guest.token }) + assert.strictEqual(mine.body.data.checkins.length, 1) + + const stats = await call('GET', '/api/admin/stats', { token: adminToken }) + assert.strictEqual(stats.body.data.checkinCount, 1) + + const csv = await call('GET', `/api/admin/signups/export?token=${adminToken}`) + assert.strictEqual(csv.status, 200) + assert.match(csv.body, /张三,13800000000,'=cmd/) + + const queryOnly = await call('GET', `/api/admin/stats?token=${adminToken}`) + assert.strictEqual(queryOnly.status, 401) +}) + +test('profile rejects temp avatar paths and accepts uploaded avatars', async () => { + const user = await login('device-c') + const temp = await call('POST', '/api/user/profile', { + token: user.token, + body: { nickName: '小幂', avatarUrl: 'http://tmp/abc.png' } + }) + assert.strictEqual(temp.body.data.nickName, '小幂') + assert.strictEqual(temp.body.data.avatarUrl, '') + + const form = new FormData() + form.append('file', new Blob([Buffer.from('89504e47', 'hex')], { type: 'image/png' }), 'a.png') + const upload = await call('POST', '/api/user/avatar', { token: user.token, form }) + assert.strictEqual(upload.status, 200) + assert.match(upload.body.data.url, /^\/static\/uploads\/.+\.png$/) + + const saved = await call('POST', '/api/user/profile', { token: user.token, body: { avatarUrl: upload.body.data.url } }) + assert.strictEqual(saved.body.data.avatarUrl, upload.body.data.url) + + const served = await fetch(base + upload.body.data.url) + assert.strictEqual(served.status, 200) + + const video = new FormData() + video.append('file', new Blob([Buffer.from('00')], { type: 'video/mp4' }), 'a.mp4') + const rejected = await call('POST', '/api/user/avatar', { token: user.token, form: video }) + assert.strictEqual(rejected.status, 400) +}) + +test('admin activity CRUD keeps a single current activity', async () => { + const token = (await call('POST', '/api/admin/login', { body: { username: 'admin', password: '123456' } })).body.data.token + const created = await call('POST', '/api/admin/activities', { + token, + body: { title: '第94期', status: 'current', isCurrent: true, startAt: '2026-07-01' } + }) + assert.strictEqual(created.status, 200) + const list = (await call('GET', '/api/admin/activities', { token })).body.data + assert.deepStrictEqual(list.filter((item) => item.isCurrent).map((item) => item.id), [created.body.data.id]) + + const current = await call('GET', '/api/activity/current') + assert.strictEqual(current.body.data.title, '第94期') + + const draft = await call('POST', '/api/admin/activities', { token, body: { title: '草稿', status: 'draft' } }) + assert.strictEqual((await call('GET', `/api/activity/${draft.body.data.id}`)).status, 404) + + assert.strictEqual((await call('GET', '/api/nope')).status, 404) +})