Add 卓米会 mini program and admin server.
Initial commit of the WeChat mini program, Express backend, and admin console. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 合法域名。
|
||||||
|
- 修改管理后台默认密码;数据量大时换成正式数据库。
|
||||||
|
- 在小程序后台填写《用户隐私保护指引》(昵称、头像、手机号、位置)。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"component": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<view class="board" wx:if="{{activity}}">
|
||||||
|
<view class="banner">
|
||||||
|
<video
|
||||||
|
wx:if="{{video}}"
|
||||||
|
class="banner-media"
|
||||||
|
src="{{video}}"
|
||||||
|
controls
|
||||||
|
show-center-play-btn
|
||||||
|
object-fit="cover"
|
||||||
|
poster="{{poster}}"
|
||||||
|
/>
|
||||||
|
<block wx:else>
|
||||||
|
<image wx:if="{{poster}}" class="banner-media" src="{{poster}}" mode="aspectFill" />
|
||||||
|
<view class="banner-shade"></view>
|
||||||
|
<text class="banner-title">{{activity.title}}</text>
|
||||||
|
</block>
|
||||||
|
<view class="badge" wx:if="{{activity.isCurrent}}">当前活动</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="brand">{{activity.brandName}}</text>
|
||||||
|
<text wx:if="{{activity.brandDesc}}" class="desc">{{activity.brandDesc}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card info">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">活动名称</text>
|
||||||
|
<text class="value">{{activity.title}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">活动时间</text>
|
||||||
|
<text class="value">{{activity.startAt || '待定'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row last">
|
||||||
|
<text class="label">会场地址</text>
|
||||||
|
<text class="value">{{activity.address || '待定'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn green" hover-class="btn-pressed" bindtap="onMap">📍 一键导航</view>
|
||||||
|
<view
|
||||||
|
class="btn teal {{ended ? 'disabled' : ''}}"
|
||||||
|
hover-class="{{ended ? 'none' : 'btn-pressed'}}"
|
||||||
|
bindtap="onSignup"
|
||||||
|
>{{ended ? '活动已结束' : '立即报名'}}</view>
|
||||||
|
|
||||||
|
<view wx:if="{{showPast}}" class="more" bindtap="onPast">查看往期活动 ›</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** 后端地址。上线改为 HTTPS 域名,并加入小程序 request / downloadFile 合法域名。 */
|
||||||
|
const BASE = 'http://127.0.0.1:3000'
|
||||||
|
|
||||||
|
module.exports = { BASE }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "卓米会",
|
||||||
|
"usingComponents": {
|
||||||
|
"activity-board": "/components/activity-board/index"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<activity-board
|
||||||
|
wx:if="{{activity}}"
|
||||||
|
activity="{{activity}}"
|
||||||
|
show-past="{{false}}"
|
||||||
|
bind:map="openMap"
|
||||||
|
bind:signup="goSignup"
|
||||||
|
/>
|
||||||
|
<view wx:elif="{{failed}}" class="empty">
|
||||||
|
<text>活动不存在或已下线</text>
|
||||||
|
<view class="home" bindtap="goHome">回到首页</view>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="empty">加载中</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "卓米会",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"usingComponents": {
|
||||||
|
"activity-board": "/components/activity-board/index"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<activity-board
|
||||||
|
activity="{{activity}}"
|
||||||
|
show-past="{{true}}"
|
||||||
|
bind:map="openMap"
|
||||||
|
bind:signup="goSignup"
|
||||||
|
bind:past="goPast"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
page {
|
||||||
|
background: #f3f5f7;
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "我的"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="card profile" hover-class="pressed" bindtap="goProfile">
|
||||||
|
<image wx:if="{{avatarUrl}}" class="avatar" src="{{avatarUrl}}" mode="aspectFill" />
|
||||||
|
<view wx:else class="avatar letter">{{letter}}</view>
|
||||||
|
<view class="who">
|
||||||
|
<text class="name">{{nickName || '微信用户'}}</text>
|
||||||
|
<text class="edit">点击编辑资料</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card block">
|
||||||
|
<text class="heading">我的报名</text>
|
||||||
|
<view wx:if="{{!signups.length}}" class="empty">暂无报名记录,去首页报名吧</view>
|
||||||
|
<view wx:for="{{signups}}" wx:key="id" class="item">
|
||||||
|
<view class="item-main">
|
||||||
|
<text class="item-title">{{item.activityTitle}}</text>
|
||||||
|
<text class="item-sub">{{nickName || item.name}} · {{item.phone}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="tag {{item.checkedIn ? 'on' : ''}}">{{item.checkedIn ? '已签到' : '未签到'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card block">
|
||||||
|
<text class="heading">签到记录</text>
|
||||||
|
<view wx:if="{{!checkins.length}}" class="empty">暂无签到记录</view>
|
||||||
|
<view wx:for="{{checkins}}" wx:key="id" class="item">
|
||||||
|
<view class="item-main">
|
||||||
|
<text class="item-title">{{item.activityTitle}}</text>
|
||||||
|
<text class="item-sub">已签到</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="scan" hover-class="scan-pressed" bindtap="checkin">模拟现场扫码签到</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "新增往期"
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="card">
|
||||||
|
<text class="label">活动名称</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="例如:卓米会城市沙龙"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{title}}"
|
||||||
|
maxlength="40"
|
||||||
|
bindinput="onTitle"
|
||||||
|
/>
|
||||||
|
<text class="label">活动时间</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="例如:2026-05-22 19:00"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{startAt}}"
|
||||||
|
maxlength="32"
|
||||||
|
bindinput="onTime"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text class="label">封面图片</text>
|
||||||
|
<view class="media-row">
|
||||||
|
<image wx:if="{{cover}}" class="preview" src="{{coverPreview}}" mode="aspectFill" />
|
||||||
|
<view class="media-btn" bindtap="pickCover">{{cover ? '更换图片' : '选择图片'}}</view>
|
||||||
|
<view wx:if="{{cover}}" class="media-btn ghost" bindtap="clearCover">清除</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="label">活动视频</text>
|
||||||
|
<view class="media-row">
|
||||||
|
<text class="media-name">{{video ? '已选择视频' : '未选择'}}</text>
|
||||||
|
<view class="media-btn" bindtap="pickVideo">{{video ? '更换视频' : '选择视频'}}</view>
|
||||||
|
<view wx:if="{{video}}" class="media-btn ghost" bindtap="clearVideo">清除</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="pin-row" bindtap="togglePinned">
|
||||||
|
<view class="check {{pinned ? 'on' : ''}}"></view>
|
||||||
|
<text>创建后置顶(可同时置顶多个)</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="save {{saving ? 'busy' : ''}}" bindtap="save">{{saving ? '保存中…' : '保存'}}</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "往期活动",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view wx:if="{{isAdmin}}" class="actions">
|
||||||
|
<view class="btn add" bindtap="onAdd">增加</view>
|
||||||
|
<view class="btn del {{deleting ? 'active' : ''}}" bindtap="onDelete">{{deleting ? '完成' : '删除'}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loaded && !list.length}}" class="empty">还没有往期活动</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="card {{item.pinned ? 'pinned' : ''}}"
|
||||||
|
wx:for="{{list}}"
|
||||||
|
wx:key="id"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
bindtap="openDetail"
|
||||||
|
>
|
||||||
|
<view class="cover theme-{{item.theme}}">
|
||||||
|
<image wx:if="{{item.coverUrl}}" class="cover-image" src="{{item.coverUrl}}" mode="aspectFill" lazy-load />
|
||||||
|
<view wx:if="{{item.coverUrl}}" class="cover-shade"></view>
|
||||||
|
<text wx:if="{{item.pinned}}" class="pin-badge">置顶</text>
|
||||||
|
<text wx:if="{{item.video}}" class="video-badge">▶ 视频</text>
|
||||||
|
<text class="cover-title">{{item.title}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="body">
|
||||||
|
<view class="title-row">
|
||||||
|
<text class="name">{{item.title}}</text>
|
||||||
|
<view wx:if="{{isAdmin}}" class="ops">
|
||||||
|
<text
|
||||||
|
wx:if="{{!deleting}}"
|
||||||
|
class="pin-btn"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
data-pinned="{{item.pinned ? 1 : 0}}"
|
||||||
|
catchtap="togglePin"
|
||||||
|
>{{item.pinned ? '取消置顶' : '置顶'}}</text>
|
||||||
|
<text
|
||||||
|
wx:else
|
||||||
|
class="remove"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
data-title="{{item.title}}"
|
||||||
|
catchtap="remove"
|
||||||
|
>删除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="time">{{item.startAt}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "编辑资料"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="card avatar-card">
|
||||||
|
<view class="avatar-btn" bindtap="pickAvatar">
|
||||||
|
<image wx:if="{{avatarPreview}}" class="avatar" src="{{avatarPreview}}" mode="aspectFill" />
|
||||||
|
<view wx:else class="avatar letter">{{letter}}</view>
|
||||||
|
</view>
|
||||||
|
<text class="hint">{{uploading ? '头像上传中…' : '点击更换头像'}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="label">昵称</text>
|
||||||
|
<input class="input" placeholder="填写昵称" placeholder-class="placeholder" value="{{nickName}}" maxlength="32" data-field="nickName" bindinput="onInput" />
|
||||||
|
<text class="label">姓名</text>
|
||||||
|
<input class="input" placeholder="真实姓名" placeholder-class="placeholder" value="{{name}}" maxlength="20" data-field="name" bindinput="onInput" />
|
||||||
|
<text class="label">手机号</text>
|
||||||
|
<input class="input" type="number" placeholder="11 位手机号" placeholder-class="placeholder" value="{{phone}}" maxlength="11" data-field="phone" bindinput="onInput" />
|
||||||
|
<text class="label">公司</text>
|
||||||
|
<input class="input" placeholder="公司 / 机构" placeholder-class="placeholder" value="{{company}}" maxlength="40" data-field="company" bindinput="onInput" />
|
||||||
|
<text class="label">职位</text>
|
||||||
|
<input class="input" placeholder="职位" placeholder-class="placeholder" value="{{position}}" maxlength="40" data-field="position" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="save" loading="{{saving}}" disabled="{{saving}}" bindtap="save">保存资料</button>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "立即报名"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<view class="page">
|
||||||
|
<view class="card">
|
||||||
|
<text class="label">姓名</text>
|
||||||
|
<input class="input" placeholder="怎么称呼你" placeholder-class="placeholder" value="{{name}}" maxlength="20" bindinput="onName" />
|
||||||
|
<text class="label">手机号</text>
|
||||||
|
<input class="input" type="number" placeholder="用于活动通知" placeholder-class="placeholder" value="{{phone}}" maxlength="11" bindinput="onPhone" />
|
||||||
|
<text class="label">公司 / 机构</text>
|
||||||
|
<input class="input" placeholder="选填" placeholder-class="placeholder" value="{{company}}" maxlength="40" bindinput="onCompany" />
|
||||||
|
</view>
|
||||||
|
<view class="save {{submitting ? 'busy' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交报名'}}</view>
|
||||||
|
<text class="hint">手机号仅用于本次活动通知。</text>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [{ "action": "allow", "page": "*" }]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
Generated
+1182
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>卓米会管理后台</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #f3f5f7; color: #1f2937; }
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||||
|
.panel { width: min(420px, 100%); background: #fff; border-radius: 16px; padding: 28px; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||||
|
.muted { color: #6b7280; font-size: 13px; }
|
||||||
|
label { display: block; margin: 14px 0 6px; font-size: 13px; color: #6b7280; }
|
||||||
|
input, select, textarea { width: 100%; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 12px; background: #fff; }
|
||||||
|
textarea { min-height: 72px; resize: vertical; }
|
||||||
|
.primary, .ghost, .danger { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; }
|
||||||
|
.primary { background: #128a80; color: #fff; }
|
||||||
|
.ghost { background: #fff; color: #128a80; border: 1px solid #b7e0dc; }
|
||||||
|
.danger { background: #fff; color: #dc2626; border: 1px solid #fecaca; }
|
||||||
|
.app { display: none; min-height: 100vh; }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #fff; }
|
||||||
|
nav { display: flex; gap: 8px; padding: 12px 24px; }
|
||||||
|
nav button { background: transparent; border: 0; padding: 8px 12px; border-radius: 999px; cursor: pointer; color: #4b5563; }
|
||||||
|
nav button.on { background: #128a80; color: #fff; }
|
||||||
|
main { padding: 8px 24px 40px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 12px; }
|
||||||
|
.stat { font-size: 28px; font-weight: 700; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 14px; overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #f3f4f6; font-size: 14px; vertical-align: top; }
|
||||||
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
|
||||||
|
.error { color: #dc2626; min-height: 1.2em; font-size: 13px; }
|
||||||
|
img.qr { width: 140px; height: 140px; }
|
||||||
|
@media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login" id="loginView">
|
||||||
|
<form class="panel" id="loginForm">
|
||||||
|
<h1>卓米会后台</h1>
|
||||||
|
<p class="muted">默认账号 admin / 123456,上线前请修改密码。</p>
|
||||||
|
<label>账号</label>
|
||||||
|
<input name="username" value="admin" autocomplete="username" />
|
||||||
|
<label>密码</label>
|
||||||
|
<input name="password" type="password" value="123456" autocomplete="current-password" />
|
||||||
|
<p class="error" id="loginError"></p>
|
||||||
|
<button class="primary" type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app" id="appView">
|
||||||
|
<header>
|
||||||
|
<strong>卓米会</strong>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" id="passwordBtn" type="button">修改密码</button>
|
||||||
|
<button class="ghost" id="logoutBtn" type="button">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<button type="button" data-tab="stats" class="on">数据统计</button>
|
||||||
|
<button type="button" data-tab="activities">活动</button>
|
||||||
|
<button type="button" data-tab="posters">海报</button>
|
||||||
|
<button type="button" data-tab="signups">报名</button>
|
||||||
|
<button type="button" data-tab="checkin">签到码</button>
|
||||||
|
<button type="button" data-tab="staff">管理员</button>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<section id="stats"></section>
|
||||||
|
<section id="activities" hidden></section>
|
||||||
|
<section id="posters" hidden></section>
|
||||||
|
<section id="signups" hidden></section>
|
||||||
|
<section id="checkin" hidden></section>
|
||||||
|
<section id="staff" hidden></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const state = { token: localStorage.getItem('zhuomi_admin') || '', activities: [] }
|
||||||
|
const loginView = document.getElementById('loginView')
|
||||||
|
const appView = document.getElementById('appView')
|
||||||
|
|
||||||
|
function showApp(on) {
|
||||||
|
loginView.style.display = on ? 'none' : 'flex'
|
||||||
|
appView.style.display = on ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
const headers = { ...(options.headers || {}) }
|
||||||
|
if (state.token) headers.Authorization = state.token
|
||||||
|
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
||||||
|
const res = await fetch(url, { ...options, headers })
|
||||||
|
if (options.raw) return res
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (res.status === 401 && url !== '/api/admin/login') {
|
||||||
|
state.token = ''
|
||||||
|
localStorage.removeItem('zhuomi_admin')
|
||||||
|
showApp(false)
|
||||||
|
}
|
||||||
|
if (!res.ok || data.ok === false) throw new Error(data.message || '请求失败')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMedia(file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api('/api/admin/upload-file', { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaFields(value) {
|
||||||
|
return `
|
||||||
|
<label>封面图片地址</label>
|
||||||
|
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
|
||||||
|
<label>上传封面图片</label>
|
||||||
|
<input type="file" id="coverFile" accept="image/png,image/jpeg,image/webp" />
|
||||||
|
<label>视频地址</label>
|
||||||
|
<input name="video" id="videoUrl" value="${escapeHtml(value.video || '')}" placeholder="/static/uploads/..." />
|
||||||
|
<label>上传视频</label>
|
||||||
|
<input type="file" id="videoFile" accept="video/mp4,video/webm,video/quicktime" />
|
||||||
|
<div class="muted" id="mediaHint" style="margin-top:8px"></div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindMediaPickers(scope) {
|
||||||
|
const root = scope || document
|
||||||
|
const coverFile = root.querySelector('#coverFile')
|
||||||
|
const videoFile = root.querySelector('#videoFile')
|
||||||
|
const hint = root.querySelector('#mediaHint')
|
||||||
|
if (coverFile) {
|
||||||
|
coverFile.onchange = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
if (hint) hint.textContent = '图片上传中...'
|
||||||
|
const uploaded = await uploadMedia(file)
|
||||||
|
root.querySelector('#coverUrl').value = uploaded.url
|
||||||
|
if (hint) hint.textContent = '图片已上传'
|
||||||
|
} catch (error) {
|
||||||
|
if (hint) hint.textContent = error.message
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (videoFile) {
|
||||||
|
videoFile.onchange = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
if (hint) hint.textContent = '视频上传中...'
|
||||||
|
const uploaded = await uploadMedia(file)
|
||||||
|
root.querySelector('#videoUrl').value = uploaded.url
|
||||||
|
if (hint) hint.textContent = '视频已上传'
|
||||||
|
} catch (error) {
|
||||||
|
if (hint) hint.textContent = error.message
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = new FormData(event.target)
|
||||||
|
try {
|
||||||
|
const data = await api('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username: form.get('username'), password: form.get('password') })
|
||||||
|
})
|
||||||
|
state.token = data.token
|
||||||
|
localStorage.setItem('zhuomi_admin', data.token)
|
||||||
|
document.getElementById('loginError').textContent = ''
|
||||||
|
boot()
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('loginError').textContent = error.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').onclick = () => {
|
||||||
|
state.token = ''
|
||||||
|
localStorage.removeItem('zhuomi_admin')
|
||||||
|
showApp(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('passwordBtn').onclick = async () => {
|
||||||
|
const oldPassword = prompt('原密码')
|
||||||
|
const newPassword = prompt('新密码(至少 6 位)')
|
||||||
|
if (!oldPassword || !newPassword) return
|
||||||
|
try {
|
||||||
|
await api('/api/admin/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) })
|
||||||
|
state.token = ''
|
||||||
|
localStorage.removeItem('zhuomi_admin')
|
||||||
|
showApp(false)
|
||||||
|
alert('密码已更新,请重新登录')
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('nav button').forEach((button) => {
|
||||||
|
button.onclick = () => {
|
||||||
|
document.querySelectorAll('nav button').forEach((item) => item.classList.remove('on'))
|
||||||
|
button.classList.add('on')
|
||||||
|
document.querySelectorAll('main section').forEach((section) => {
|
||||||
|
section.hidden = section.id !== button.dataset.tab
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
showApp(true)
|
||||||
|
await loadActivities()
|
||||||
|
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const stats = await api('/api/admin/stats')
|
||||||
|
const rows = stats.bySharer.map((item) => `<tr><td>${escapeHtml(item.sharer)}</td><td>${item.count}</td></tr>`).join('')
|
||||||
|
document.getElementById('stats').innerHTML = `
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="muted">活动</div><div class="stat">${stats.activityCount}</div></div>
|
||||||
|
<div class="card"><div class="muted">报名</div><div class="stat">${stats.signupCount}</div></div>
|
||||||
|
<div class="card"><div class="muted">签到</div><div class="stat">${stats.checkinCount}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><strong>推荐来源</strong>
|
||||||
|
<table><thead><tr><th>分享人</th><th>报名数</th></tr></thead><tbody>${rows || '<tr><td colspan="2">暂无报名</td></tr>'}</tbody></table>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityForm(activity) {
|
||||||
|
const value = activity || { status: 'draft', isCurrent: false, brandName: '卓米会 ZHUOMI' }
|
||||||
|
return `
|
||||||
|
<form id="activityForm" class="card">
|
||||||
|
<strong>${value.id ? '编辑活动' : '新建活动'}</strong>
|
||||||
|
<p class="muted">状态选 past 即为往期;往期可置顶,并支持封面图 / 视频。</p>
|
||||||
|
<input type="hidden" name="id" value="${value.id || ''}" />
|
||||||
|
<label>名称</label><input name="title" value="${escapeHtml(value.title || '')}" />
|
||||||
|
<label>品牌名</label><input name="brandName" value="${escapeHtml(value.brandName || '')}" />
|
||||||
|
<label>简介</label><textarea name="brandDesc">${escapeHtml(value.brandDesc || '')}</textarea>
|
||||||
|
<label>时间</label><input name="startAt" value="${escapeHtml(value.startAt || '')}" />
|
||||||
|
<label>地址</label><input name="address" value="${escapeHtml(value.address || '')}" />
|
||||||
|
<label>纬度</label><input name="latitude" value="${value.latitude ?? ''}" />
|
||||||
|
<label>经度</label><input name="longitude" value="${value.longitude ?? ''}" />
|
||||||
|
${mediaFields(value)}
|
||||||
|
<label>状态</label>
|
||||||
|
<select name="status" id="activityStatus">
|
||||||
|
<option value="current" ${value.status === 'current' ? 'selected' : ''}>current(当前)</option>
|
||||||
|
<option value="past" ${value.status === 'past' ? 'selected' : ''}>past(往期)</option>
|
||||||
|
<option value="draft" ${value.status === 'draft' ? 'selected' : ''}>draft(草稿)</option>
|
||||||
|
</select>
|
||||||
|
<label><input type="checkbox" name="isCurrent" ${value.isCurrent ? 'checked' : ''} style="width:auto" /> 设为当前活动</label>
|
||||||
|
<label><input type="checkbox" name="pinned" ${value.pinned ? 'checked' : ''} style="width:auto" /> 往期置顶(仅 past 生效)</label>
|
||||||
|
<div class="row"><button class="primary" type="submit">保存</button></div>
|
||||||
|
</form>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActivities() {
|
||||||
|
state.activities = await api('/api/admin/activities')
|
||||||
|
const list = state.activities.slice().sort((a, b) => {
|
||||||
|
const rank = (item) => (item.isCurrent ? 0 : item.status === 'current' ? 1 : item.status === 'past' ? 2 : 3)
|
||||||
|
const ra = rank(a)
|
||||||
|
const rb = rank(b)
|
||||||
|
if (ra !== rb) return ra - rb
|
||||||
|
if (a.status === 'past' && b.status === 'past') {
|
||||||
|
const ap = a.pinned ? 1 : 0
|
||||||
|
const bp = b.pinned ? 1 : 0
|
||||||
|
if (ap !== bp) return bp - ap
|
||||||
|
}
|
||||||
|
return String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
|
||||||
|
})
|
||||||
|
const rows = list.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
${escapeHtml(item.title)}
|
||||||
|
<div class="muted">
|
||||||
|
${item.status}${item.isCurrent ? ' · 当前' : ''}${item.pinned ? ' · 置顶' : ''}${item.cover ? ' · 图' : ''}${item.video ? ' · 视频' : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(item.startAt || '')}</td>
|
||||||
|
<td class="row">
|
||||||
|
${item.status === 'past'
|
||||||
|
? `<button class="ghost" data-pin="${item.id}" data-pinned="${item.pinned ? 1 : 0}" type="button">${item.pinned ? '取消置顶' : '置顶'}</button>`
|
||||||
|
: ''}
|
||||||
|
<button class="ghost" data-edit="${item.id}" type="button">编辑</button>
|
||||||
|
<button class="danger" data-del="${item.id}" type="button">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')
|
||||||
|
document.getElementById('activities').innerHTML = `
|
||||||
|
<div class="split">
|
||||||
|
<table><thead><tr><th>活动</th><th>时间</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无活动</td></tr>'}</tbody></table>
|
||||||
|
<div id="activityEditor">${activityForm()}</div>
|
||||||
|
</div>`
|
||||||
|
bindActivityEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindActivityEditor() {
|
||||||
|
const form = document.getElementById('activityForm')
|
||||||
|
bindMediaPickers(form)
|
||||||
|
form.onsubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const body = Object.fromEntries(formData.entries())
|
||||||
|
body.isCurrent = formData.get('isCurrent') === 'on'
|
||||||
|
body.pinned = formData.get('pinned') === 'on'
|
||||||
|
const id = body.id
|
||||||
|
delete body.id
|
||||||
|
if (id) await api(`/api/admin/activities/${id}`, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
else await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
loadActivities()
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#activities [data-edit]').forEach((button) => {
|
||||||
|
button.onclick = () => {
|
||||||
|
const activity = state.activities.find((item) => item.id === button.dataset.edit)
|
||||||
|
document.getElementById('activityEditor').innerHTML = activityForm(activity)
|
||||||
|
bindActivityEditor()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.querySelectorAll('#activities [data-del]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
if (!confirm('删除这场活动?')) return
|
||||||
|
await api(`/api/admin/activities/${button.dataset.del}`, { method: 'DELETE' })
|
||||||
|
loadActivities()
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.querySelectorAll('#activities [data-pin]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
const next = String(button.dataset.pinned) !== '1'
|
||||||
|
await api(`/api/admin/activities/${button.dataset.pin}/pin`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pinned: next })
|
||||||
|
})
|
||||||
|
loadActivities()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPosters() {
|
||||||
|
const posters = await api('/api/admin/posters')
|
||||||
|
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
|
||||||
|
const rows = posters.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(item.title || item.image)}</td>
|
||||||
|
<td>${item.enabled ? '启用' : '停用'}</td>
|
||||||
|
<td><button class="danger" data-poster="${item.id}" type="button">删除</button></td>
|
||||||
|
</tr>`).join('')
|
||||||
|
document.getElementById('posters').innerHTML = `
|
||||||
|
<form id="posterForm" class="card">
|
||||||
|
<strong>新增海报</strong>
|
||||||
|
<label>活动</label><select name="activityId">${options}</select>
|
||||||
|
<label>标题</label><input name="title" />
|
||||||
|
<label>图片地址</label><input name="image" id="posterImage" placeholder="/static/uploads/..." />
|
||||||
|
<label>或上传图片</label><input type="file" id="posterFile" accept="image/png,image/jpeg,image/webp" />
|
||||||
|
<div class="row" style="margin-top:12px"><button class="primary" type="submit">保存海报</button></div>
|
||||||
|
</form>
|
||||||
|
<table><thead><tr><th>海报</th><th>状态</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无海报</td></tr>'}</tbody></table>`
|
||||||
|
document.getElementById('posterFile').onchange = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadMedia(file)
|
||||||
|
document.getElementById('posterImage').value = uploaded.url
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('posterForm').onsubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const body = Object.fromEntries(new FormData(event.target).entries())
|
||||||
|
await api('/api/admin/posters', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
loadPosters()
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-poster]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
await api(`/api/admin/posters/${button.dataset.poster}`, { method: 'DELETE' })
|
||||||
|
loadPosters()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSignups() {
|
||||||
|
const signups = await api('/api/admin/signups')
|
||||||
|
const rows = signups.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(item.name)}<div class="muted">${escapeHtml(item.phone)}</div></td>
|
||||||
|
<td>${escapeHtml(item.activityTitle || '')}</td>
|
||||||
|
<td>${escapeHtml(item.sourceSharer || '直接访问')}</td>
|
||||||
|
<td>${item.checkedIn ? '已签到' : '未签到'}</td>
|
||||||
|
<td><button class="danger" data-signup="${item.id}" type="button">删除</button></td>
|
||||||
|
</tr>`).join('')
|
||||||
|
document.getElementById('signups').innerHTML = `
|
||||||
|
<div class="row" style="margin-bottom:12px">
|
||||||
|
<a class="primary" style="text-decoration:none" href="/api/admin/signups/export?token=${encodeURIComponent(state.token)}">导出 CSV</a>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th>报名人</th><th>活动</th><th>分享人</th><th>签到</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="5">暂无报名</td></tr>'}</tbody></table>`
|
||||||
|
document.querySelectorAll('[data-signup]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
await api(`/api/admin/signups/${button.dataset.signup}`, { method: 'DELETE' })
|
||||||
|
loadSignups()
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCodes() {
|
||||||
|
const codes = await api('/api/admin/checkin-codes')
|
||||||
|
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
|
||||||
|
const cards = codes.map((item) => `
|
||||||
|
<div class="card">
|
||||||
|
<img class="qr" src="${item.qrDataUrl}" alt="签到码" />
|
||||||
|
<div class="muted">${escapeHtml(item.token)}</div>
|
||||||
|
<div class="muted">有效至 ${escapeHtml(item.expiresAt)}</div>
|
||||||
|
</div>`).join('')
|
||||||
|
document.getElementById('checkin').innerHTML = `
|
||||||
|
<form id="codeForm" class="card row">
|
||||||
|
<select name="activityId">${options}</select>
|
||||||
|
<input name="ttlHours" type="number" value="12" style="width:120px" />
|
||||||
|
<button class="primary" type="submit">生成签到码</button>
|
||||||
|
</form>
|
||||||
|
<div class="grid">${cards || '<div class="card">还没有签到码</div>'}</div>`
|
||||||
|
document.getElementById('codeForm').onsubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const body = Object.fromEntries(new FormData(event.target).entries())
|
||||||
|
await api('/api/admin/checkin-codes', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
loadCodes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStaff() {
|
||||||
|
const users = await api('/api/admin/users')
|
||||||
|
const rows = users.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(item.nickName || item.name || '未命名')}<div class="muted">${escapeHtml(item.phone || '')}</div></td>
|
||||||
|
<td style="font-size:12px;word-break:break-all">${escapeHtml(item.openid)}</td>
|
||||||
|
<td>${item.allowed ? '管理员' : '普通用户'}</td>
|
||||||
|
<td>
|
||||||
|
${item.allowed
|
||||||
|
? `<button class="danger" data-remove="${encodeURIComponent(item.openid)}" type="button">取消管理员</button>`
|
||||||
|
: `<button class="ghost" data-add="${escapeHtml(item.openid)}" type="button">设为管理员</button>`}
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')
|
||||||
|
document.getElementById('staff').innerHTML = `
|
||||||
|
<div class="card"><strong>小程序管理员</strong>
|
||||||
|
<p class="muted">在后台设为管理员后,该用户打开小程序「往期」会直接看到增加、删除、置顶,界面上没有「管理员登录」入口。</p>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>用户</th><th>openid</th><th>状态</th><th></th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="4">暂无用户,请先在小程序登录一次</td></tr>'}</tbody>
|
||||||
|
</table>`
|
||||||
|
document.querySelectorAll('[data-add]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
await api('/api/admin/allowlist', { method: 'POST', body: JSON.stringify({ openid: button.dataset.add }) })
|
||||||
|
loadStaff()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.querySelectorAll('[data-remove]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
await api(`/api/admin/allowlist/${button.dataset.remove}`, { method: 'DELETE' })
|
||||||
|
loadStaff()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[char]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.token) {
|
||||||
|
api('/api/admin/stats').then(boot).catch(() => showApp(false))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,小程序登录走演示模式')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user