commit cd05113755e9d720eed206d25ef736b8f4989c6c Author: Michelle Zan Date: Tue Sep 29 19:01:36 2026 +0800 Add 卓米会 mini program and admin server. Initial commit of the WeChat mini program, Express backend, and admin console. Co-authored-by: Cursor 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 0000000..7c6c861 Binary files /dev/null and b/miniprogram/assets/tab-home-on.png differ diff --git a/miniprogram/assets/tab-home.png b/miniprogram/assets/tab-home.png new file mode 100644 index 0000000..7c6c861 Binary files /dev/null and b/miniprogram/assets/tab-home.png differ diff --git a/miniprogram/assets/tab-mine-on.png b/miniprogram/assets/tab-mine-on.png new file mode 100644 index 0000000..a6a93f1 Binary files /dev/null and b/miniprogram/assets/tab-mine-on.png differ diff --git a/miniprogram/assets/tab-mine.png b/miniprogram/assets/tab-mine.png new file mode 100644 index 0000000..a6a93f1 Binary files /dev/null and b/miniprogram/assets/tab-mine.png differ diff --git a/miniprogram/assets/tab-past-on.png b/miniprogram/assets/tab-past-on.png new file mode 100644 index 0000000..ac5e03b Binary files /dev/null and b/miniprogram/assets/tab-past-on.png differ diff --git a/miniprogram/assets/tab-past.png b/miniprogram/assets/tab-past.png new file mode 100644 index 0000000..ac5e03b Binary files /dev/null and b/miniprogram/assets/tab-past.png differ 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) +})