feat: build CoffeeChat mini program and backend
CI / test (push) Has been cancelled

This commit is contained in:
2026-09-21 03:38:09 +08:00
commit 5a156b2d87
101 changed files with 3752 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitea
node_modules
.env
server/uploads
project.private.config.json
cloudfunctions
docs
miniprogram
+9
View File
@@ -0,0 +1,9 @@
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://coffeechat:change-me@db:5432/coffeechat
JWT_SECRET=replace-with-at-least-32-random-characters
WECHAT_APP_ID=wx386640f47786131f
WECHAT_APP_SECRET=replace-with-wechat-app-secret
ADMIN_TOKEN=replace-with-a-long-random-admin-token
PUBLIC_BASE_URL=https://coffeechat.example.com
+11
View File
@@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: minecraft-and-affine-server
steps:
- uses: actions/checkout@v4
- name: Check and test in Node 22
run: docker run --rm -v "$PWD:/app" -w /app node:22-alpine sh -c "npm ci && npm run check && npm test"
+19
View File
@@ -0,0 +1,19 @@
name: Deploy production
on:
workflow_dispatch:
jobs:
deploy:
runs-on: minecraft-and-affine-server
concurrency: coffeechat-production
steps:
- uses: actions/checkout@v4
- name: Test
run: docker run --rm -v "$PWD:/app" -w /app node:22-alpine sh -c "npm ci && npm run check && npm test"
- name: Load production environment
run: test -f /opt/coffeechat/.env && cp /opt/coffeechat/.env .env
- name: Build and migrate
run: docker compose --env-file .env -f deploy/docker-compose.yml build --pull
- name: Start production
run: docker compose --env-file .env -f deploy/docker-compose.yml up -d --remove-orphans
- name: Verify health
run: curl --fail --retry 12 --retry-delay 5 http://127.0.0.1:${COFFEECHAT_PORT:-3100}/health
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.DS_Store
coverage/
server/uploads/
project.private.config.json
npm-debug.log*
+23
View File
@@ -0,0 +1,23 @@
# CoffeeChat
面向会展现场社交的微信小程序。用户通过微信登录后,可浏览和筛选活动、查看会展信息、加入活动、填写参与目的,并在管理员审核后查看活动微信群。
## 目录
- `miniprogram/`:原生微信小程序
- `server/`:Node.js API、管理后台和数据库迁移
- `deploy/`:Docker Compose 生产部署
- `.gitea/workflows/`:Gitea Actions 测试及部署
- `docs/`:接口、配置和上线说明
## 本地启动
1. 复制 `.env.example` 为 `.env`,填写微信 AppSecret 和安全密钥。
2. 执行 `docker compose -f deploy/docker-compose.yml up --build`。
3. 将 `miniprogram/config.js` 中的 `apiBaseUrl` 改为已加入微信后台 request 合法域名的 HTTPS 地址。
4. 使用微信开发者工具打开仓库根目录。
管理后台位于 `https://你的域名/admin/`,登录凭据为环境变量 `ADMIN_TOKEN`。
完整部署及微信平台配置见 [docs/deployment.md](docs/deployment.md)。
@@ -0,0 +1,7 @@
{
"permissions": {
"openapi": [
"wxacode.get"
]
}
}
+185
View File
@@ -0,0 +1,185 @@
const cloud = require("wx-server-sdk");
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV,
});
const db = cloud.database();
// 获取openid
const getOpenId = async () => {
// 获取基础信息
const wxContext = cloud.getWXContext();
return {
openid: wxContext.OPENID,
appid: wxContext.APPID,
unionid: wxContext.UNIONID,
};
};
// 获取小程序二维码
const getMiniProgramCode = async () => {
// 获取小程序二维码的buffer
const resp = await cloud.openapi.wxacode.get({
path: "pages/index/index",
});
const { buffer } = resp;
// 将图片上传云存储空间
const upload = await cloud.uploadFile({
cloudPath: "code.png",
fileContent: buffer,
});
return upload.fileID;
};
// 创建集合
const createCollection = async () => {
try {
// 创建集合
await db.createCollection("sales");
await db.collection("sales").add({
// data 字段表示需新增的 JSON 数据
data: {
region: "华东",
city: "上海",
sales: 11,
},
});
await db.collection("sales").add({
// data 字段表示需新增的 JSON 数据
data: {
region: "华东",
city: "南京",
sales: 11,
},
});
await db.collection("sales").add({
// data 字段表示需新增的 JSON 数据
data: {
region: "华南",
city: "广州",
sales: 22,
},
});
await db.collection("sales").add({
// data 字段表示需新增的 JSON 数据
data: {
region: "华南",
city: "深圳",
sales: 22,
},
});
return {
success: true,
};
} catch (e) {
// 这里catch到的是该collection已经存在,从业务逻辑上来说是运行成功的,所以catch返回success给前端,避免工具在前端抛出异常
return {
success: true,
data: "create collection success",
};
}
};
// 查询数据
const selectRecord = async () => {
// 返回数据库查询结果
return await db.collection("sales").get();
};
// 更新数据
const updateRecord = async (event) => {
try {
// 遍历修改数据库信息
for (let i = 0; i < event.data.length; i++) {
await db
.collection("sales")
.where({
_id: event.data[i]._id,
})
.update({
data: {
sales: event.data[i].sales,
},
});
}
return {
success: true,
data: event.data,
};
} catch (e) {
return {
success: false,
errMsg: e,
};
}
};
// 新增数据
const insertRecord = async (event) => {
try {
const insertRecord = event.data;
// 插入数据
await db.collection("sales").add({
data: {
region: insertRecord.region,
city: insertRecord.city,
sales: Number(insertRecord.sales),
},
});
return {
success: true,
data: event.data,
};
} catch (e) {
return {
success: false,
errMsg: e,
};
}
};
// 删除数据
const deleteRecord = async (event) => {
try {
await db
.collection("sales")
.where({
_id: event.data._id,
})
.remove();
return {
success: true,
};
} catch (e) {
return {
success: false,
errMsg: e,
};
}
};
// const getOpenId = require('./getOpenId/index');
// const getMiniProgramCode = require('./getMiniProgramCode/index');
// const createCollection = require('./createCollection/index');
// const selectRecord = require('./selectRecord/index');
// const updateRecord = require('./updateRecord/index');
// const fetchGoodsList = require('./fetchGoodsList/index');
// const genMpQrcode = require('./genMpQrcode/index');
// 云函数入口函数
exports.main = async (event, context) => {
switch (event.type) {
case "getOpenId":
return await getOpenId();
case "getMiniProgramCode":
return await getMiniProgramCode();
case "createCollection":
return await createCollection();
case "selectRecord":
return await selectRecord();
case "updateRecord":
return await updateRecord(event);
case "insertRecord":
return await insertRecord(event);
case "deleteRecord":
return await deleteRecord(event);
}
};
@@ -0,0 +1,14 @@
{
"name": "quickstartFunctions",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"wx-server-sdk": "~2.4.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: coffeechat
POSTGRES_USER: coffeechat
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- coffeechat-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U coffeechat"]
interval: 5s
timeout: 3s
retries: 20
migrate:
image: postgres:17-alpine
depends_on:
db: { condition: service_healthy }
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ../server/migrations:/migrations:ro
entrypoint: ["sh", "-c", "for f in /migrations/*.sql; do psql -h db -U coffeechat -d coffeechat -v ON_ERROR_STOP=1 -f \"$$f\"; done"]
restart: "no"
api:
build:
context: ..
dockerfile: server/Dockerfile
restart: unless-stopped
depends_on:
db: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgres://coffeechat:${POSTGRES_PASSWORD}@db:5432/coffeechat
JWT_SECRET: ${JWT_SECRET}
WECHAT_APP_ID: ${WECHAT_APP_ID}
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET}
ADMIN_TOKEN: ${ADMIN_TOKEN}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
UPLOAD_DIR: /app/uploads
volumes:
- coffeechat-uploads:/app/uploads
ports:
- "127.0.0.1:${COFFEECHAT_PORT:-3100}:3000"
volumes:
coffeechat-db:
name: coffeechat-db
coffeechat-uploads:
name: coffeechat-uploads
+34
View File
@@ -0,0 +1,34 @@
# 生产部署
## 1. 主机准备
`minecraft-and-affine-server` Gitea Runner 需要可执行 Docker Compose,并且 Runner 服务账号必须能读取 `/opt/coffeechat/.env`。建议将该文件权限限制为该账号可读:
```dotenv
POSTGRES_PASSWORD=随机数据库密码
JWT_SECRET=至少32位随机字符串
WECHAT_APP_ID=小程序AppID
WECHAT_APP_SECRET=小程序AppSecret
ADMIN_TOKEN=随机管理令牌
PUBLIC_BASE_URL=https://你的正式域名
COFFEECHAT_PORT=3100
```
推送 `main` 后,Runner 会构建容器、执行幂等迁移并启动服务。PostgreSQL 和上传图片分别保存在 `coffeechat-db`、`coffeechat-uploads` Docker 卷中,重新发布不会丢失。
## 2. HTTPS 反向代理
将正式域名反向代理至 `127.0.0.1:3100`,必须启用有效 HTTPS。健康检查路径为 `/health`,管理后台为 `/admin/`。
## 3. 微信公众平台
1. 在“开发管理 → 开发设置”获取 AppSecret,填入服务器环境文件,不得提交仓库。
2. 将正式 HTTPS 域名加入 `request` 和 `uploadFile` 合法域名。
3. 修改 `miniprogram/config.js` 的 `apiBaseUrl`。
4. 使用微信开发者工具完成真机测试、代码上传和体验版审核。
微信群通过二维码加入。二维码由用户提交,管理员在 Web 后台审核;审核通过前不会对其他参与者展示。微信群二维码可能过期,管理员应在审核时验证有效性。
## 4. 数据备份
建议每天备份 `coffeechat-db` 卷中的 PostgreSQL,并同步备份 `coffeechat-uploads` 卷。恢复时必须同时恢复数据库与上传目录,避免图片记录与文件不一致。
+7
View File
@@ -0,0 +1,7 @@
const api = require('./utils/api')
App({
globalData: { user: wx.getStorageSync('user') || null },
onLaunch() { this.loginPromise = this.login() },
login() { return new Promise((resolve, reject) => wx.login({ success: ({ code }) => api.request('/api/auth/wechat', { method: 'POST', data: { code } }).then(({ token, user }) => { wx.setStorageSync('token', token); wx.setStorageSync('user', user); this.globalData.user = user; resolve(user) }).catch(reject), fail: reject })) },
ensureLogin() { return this.loginPromise || this.login() }
})
+14
View File
@@ -0,0 +1,14 @@
{
"pages": ["pages/index/index", "pages/activity/index", "pages/profile/index", "pages/login/index"],
"window": {
"backgroundColor": "#F5F1EA",
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#F5F1EA",
"navigationBarTitleText": "CoffeeChat",
"navigationBarTextStyle": "black"
},
"tabBar": {"color":"#8A7D71","selectedColor":"#2E5B47","backgroundColor":"#FFFDF9","list":[{"pagePath":"pages/index/index","text":"活动","iconPath":"images/icons/home.png","selectedIconPath":"images/icons/home-active.png"},{"pagePath":"pages/profile/index","text":"我的","iconPath":"images/icons/usercenter.png","selectedIconPath":"images/icons/usercenter-active.png"}]},
"sitemapLocation": "sitemap.json",
"style": "v2",
"lazyCodeLoading": "requiredComponents"
}
+1
View File
@@ -0,0 +1 @@
.page{min-height:100vh;background:#f5f1ea;color:#241f1b;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}.safe{padding:28rpx 30rpx 60rpx}.card{background:#fffdf9;border:1rpx solid #e5dacd;border-radius:28rpx;box-shadow:0 16rpx 42rpx rgba(74,54,38,.08)}.primary{background:#2e5b47!important;color:#fff!important;border-radius:999rpx;font-weight:600}.secondary{background:#eadfd2!important;color:#5d4738!important;border-radius:999rpx}.muted{color:#8a7d71}.tag{display:inline-block;padding:8rpx 16rpx;border-radius:999rpx;background:#ebe4ff;color:#6347c5;font-size:22rpx;margin-right:10rpx}.empty{text-align:center;color:#8a7d71;padding:100rpx 0}button::after{border:none}page{background:#f5f1ea}
@@ -0,0 +1,28 @@
Component({
/**
* 页面的初始数据
*/
data: {
showTip: false,
},
properties: {
showTipProps: Boolean,
title:String,
content:String
},
observers: {
showTipProps: function(showTipProps) {
this.setData({
showTip: showTipProps
});
}
},
methods: {
onClose(){
this.setData({
showTip: !this.data.showTip
});
},
}
});
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"component": true
}
@@ -0,0 +1,10 @@
<!--miniprogram/components/cloudTipModal/index.wxml-->
<!-- wx:if="{{showUploadTip}}" -->
<view class="install_tip" wx:if="{{showTip}}">
<view class="install_tip_back"></view>
<view class="install_tip_detail">
<image class="install_tip_close" bind:tap="onClose" src="../../images/icons/close.png"/>
<view class="install_tip_detail_title">{{title}}</view>
<view class="install_tip_detail_tip">{{content}}</view>
</view>
</view>
@@ -0,0 +1,60 @@
.install_tip_back {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0,0,0,0.4);
z-index: 1;
}
.install_tip_close{
position:absolute;
right: 10rpx;
top: 10rpx;
width: 32px;
height: 32px;
/* background-color: red; */
}
.install_tip_detail {
position: fixed;
background-color: white;
right: 0;
bottom: 0;
left: 0;
border-radius: 40rpx 40rpx 0 0;
padding: 50rpx 50rpx 100rpx 50rpx;
z-index: 9;
}
.install_tip_detail_title {
font-weight: 400;
font-size: 40rpx;
text-align: center;
}
.install_tip_detail_tip {
font-size: 25rpx;
color: rgba(0,0,0,0.4);
margin-top: 20rpx;
text-align: left;
}
.install_tip_detail_buttons {
padding-top: 50rpx;
display: flex;
}
.install_tip_detail_button {
color: #07C160;
font-weight: 500;
background-color: rgba(0,0,0,0.1);
width: 40%;
text-align: center;
/* height: 90rpx; */
/* line-height: 90rpx; */
border-radius: 10rpx;
margin: 0 auto;
}
.install_tip_detail_button_primary {
background-color: #07C160;
color: #fff;
}
+2
View File
@@ -0,0 +1,2 @@
module.exports = { apiBaseUrl: 'https://coffeechat.example.com' }
+6
View File
@@ -0,0 +1,6 @@
const envList = [];
const isMac = false;
module.exports = {
envList,
isMac
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="8px" height="14px" viewBox="0 0 8 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>☀ iOS/☀ 图标/线型/icons_outlined_arrow@3x</title>
<g id="控件" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.3">
<g id="4.列表/z.覆盖层/右边/箭头" transform="translate(-334.000000, -21.000000)" fill="#000000">
<g id="☀-iOS/☀-图标/线型/icons_outlined_arrow" transform="translate(332.000000, 16.000000)">
<path d="M2.45405845,6.58064919 L3.51471863,5.51998901 L9.29361566,11.298886 C9.68374096,11.6890113 9.6872014,12.318069 9.29361566,12.7116547 L3.51471863,18.4905518 L2.45405845,17.4298916 L7.87867966,12.0052704 L2.45405845,6.58064919 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+2
View File
@@ -0,0 +1,2 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 2H15V7.5H13V4H4V13H7.5V15H2V2ZM9 9H22V22H9V9ZM11 11V20H20V11H11Z" fill="#8b8b8b" />
</svg>

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.95693 15.75H19.5V6H4.5V17.7974L6.95693 15.75ZM3.73808 20.3849C3.44499 20.6292 3 20.4208 3 20.0392V6C3 5.17157 3.67157 4.5 4.5 4.5H19.5C20.3284 4.5 21 5.17157 21 6V15.75C21 16.5784 20.3284 17.25 19.5 17.25H7.5L3.73808 20.3849Z" fill="black" fill-opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.0911 17.4C11.0911 16.9029 11.494 16.5 11.9911 16.5C12.4881 16.5 12.8911 16.9029 12.8911 17.4C12.8911 17.8971 12.4881 18.3 11.9911 18.3C11.494 18.3 11.0911 17.8971 11.0911 17.4Z" fill="black" fill-opacity="0.9"/>
<path d="M11.9911 6.00915C9.98467 6.00915 8.35363 7.64019 8.35363 9.64665H9.85363C9.85363 8.46862 10.8131 7.50915 11.9911 7.50915C13.1692 7.50915 14.1286 8.46862 14.1286 9.64665C14.1286 10.4533 13.4619 11.2621 12.5917 11.6156L12.5878 11.6172C11.7935 11.945 11.2412 12.7262 11.2412 13.6387V15H12.7412V13.6387C12.7412 13.3474 12.9142 13.106 13.1585 13.0044C14.3995 12.4993 15.6286 11.248 15.6286 9.64665C15.6286 7.64019 13.9976 6.00915 11.9911 6.00915Z" fill="black" fill-opacity="0.9"/>
<path d="M22.4912 12C22.4912 6.20101 17.7902 1.5 11.9912 1.5C6.19222 1.5 1.49121 6.20101 1.49121 12C1.49121 17.799 6.19222 22.5 11.9912 22.5C17.7902 22.5 22.4912 17.799 22.4912 12ZM20.9912 12C20.9912 16.9706 16.9618 21 11.9912 21C7.02065 21 2.99121 16.9706 2.99121 12C2.99121 7.02944 7.02065 3 11.9912 3C16.9618 3 20.9912 7.02944 20.9912 12Z" fill="black" fill-opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.5002 12C16.5002 14.4853 14.4855 16.5 12.0002 16.5C9.51497 16.5 7.50025 14.4853 7.50025 12C7.50025 9.51472 9.51497 7.5 12.0002 7.5C14.4855 7.5 16.5002 9.51472 16.5002 12ZM15.0002 12C15.0002 10.3431 13.6571 9 12.0002 9C10.3434 9 9.00025 10.3431 9.00025 12C9.00025 13.6569 10.3434 15 12.0002 15C13.6571 15 15.0002 13.6569 15.0002 12Z" fill="black" fill-opacity="0.9"/>
<path d="M12.0002 1.875L21.0935 6.9375V17.0625L12.0002 22.125L2.90698 17.0625V6.9375L12.0002 1.875ZM4.40698 7.8192V16.1808L12.0002 20.4082L19.5935 16.1808V7.8192L12.0002 3.59179L4.40698 7.8192Z" fill="black" fill-opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.43993 20.5601C3.72112 20.8413 4.10234 20.9995 4.5 21H19.5C19.8977 20.9995 20.2789 20.8413 20.5601 20.5601C20.8413 20.2789 20.9995 19.8977 21 19.5V12.75H19.5V19.5H4.5V4.5H11.25V3H4.5C4.10234 3.00054 3.72112 3.15874 3.43993 3.43993C3.15874 3.72112 3.00054 4.10234 3 4.5V19.5C3.00054 19.8977 3.15874 20.2789 3.43993 20.5601Z" fill="black" fill-opacity="0.9"/>
<path d="M13.5 4.5V3H20.25C20.6642 3 21 3.33579 21 3.75V10.5H19.5V5.5605L13.0605 12L12 10.9395L18.4395 4.5H13.5Z" fill="black" fill-opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+2
View File
@@ -0,0 +1,2 @@
const api=require('../../utils/api');const app=getApp();
Page({data:{id:'',activity:null,user:null,joined:false,purpose:'',showPurpose:false,submitting:false},onLoad({id}){this.setData({id,user:app.globalData.user||wx.getStorageSync('user')});app.ensureLogin().then(user=>{this.setData({user});this.load()}).catch(()=>this.load())},onShow(){if(this.data.activity)this.load()},async load(){try{const activity=await api.request(`/api/activities/${this.data.id}`);const mine=activity.participants.find(p=>this.data.user&&p.id===this.data.user.id);this.setData({activity,joined:!!mine,purpose:mine?mine.purpose:''})}catch(e){wx.showToast({title:e.message,icon:'none'})}},preview(e){const urls=this.data.activity.images.map(x=>x.url);wx.previewImage({current:e.currentTarget.dataset.url,urls})},editPurpose(){this.setData({showPurpose:true})},purposeInput(e){this.setData({purpose:e.detail.value})},closePurpose(){this.setData({showPurpose:false})},async savePurpose(){this.setData({submitting:true});try{await api.request(`/api/activities/${this.data.id}/participations/me`,{method:'PUT',data:{purpose:this.data.purpose}});this.setData({showPurpose:false});await this.load();wx.showToast({title:'已加入'})}catch(e){wx.showToast({title:e.message,icon:'none'})}finally{this.setData({submitting:false})}},leave(){wx.showModal({title:'退出活动?',content:'你的参与目的也会被移除',success:async r=>{if(r.confirm){await api.request(`/api/activities/${this.data.id}/participations/me`,{method:'DELETE'});this.load()}}})},openGroup(e){wx.previewImage({urls:[e.currentTarget.dataset.url]})},submitGroup(){wx.chooseMedia({count:1,mediaType:['image'],success:async r=>{wx.showLoading({title:'上传中'});try{const file=await api.upload(r.tempFiles[0].tempFilePath);await api.request(`/api/activities/${this.data.id}/participations/groups`,{method:'POST',data:{name:`${this.data.activity.title}交流群`,qrImageUrl:file.url,note:'用户提交,待管理员核验'}});wx.showToast({title:'已提交审核'})}catch(e){wx.showToast({title:e.message,icon:'none'})}finally{wx.hideLoading()}}})}})
+1
View File
@@ -0,0 +1 @@
{"navigationBarTitleText":"活动详情","backgroundColor":"#f5f1ea"}
+1
View File
@@ -0,0 +1 @@
<view class="page" wx:if="{{activity}}"><swiper wx:if="{{activity.images.length}}" class="gallery" indicator-dots circular><swiper-item wx:for="{{activity.images}}" wx:key="id"><image src="{{item.url}}" mode="aspectFill" data-url="{{item.url}}" bindtap="preview"></image></swiper-item></swiper><view wx:else class="gallery placeholder">CoffeeChat</view><view class="content"><view class="status">{{activity.status==='active'?'正在进行':'即将开始'}}</view><view class="title">{{activity.title}}</view><view class="tags"><text wx:for="{{activity.tags}}" wx:key="id" class="tag">{{item.name}}</text></view><view class="facts card"><view><text>时间</text><b>{{activity.startsAt}}</b></view><view><text>地点</text><b>{{activity.location || activity.exhibition.venue}}</b></view></view><view class="section"><view class="section-title">活动介绍</view><text class="description">{{activity.description}}</text></view><view class="expo card"><text class="overline">所属会展</text><view class="expo-title">{{activity.exhibition.name}}</view><text>{{activity.exhibition.startsAt}} 至 {{activity.exhibition.endsAt}}</text><text>{{activity.exhibition.venue}} · {{activity.exhibition.address}}</text><text class="description">{{activity.exhibition.description}}</text></view><view class="section"><view class="section-head"><view class="section-title">会遇见的人</view><text>{{activity.participants.length}} 人</text></view><view wx:if="{{!activity.participants.length}}" class="muted">成为第一个加入的人吧</view><view wx:for="{{activity.participants}}" wx:key="id" class="person"><image src="{{item.avatarUrl || '/images/avatar.png'}}"></image><view><b>{{item.nickname}}</b><text>{{item.purpose || '期待现场交流'}}</text></view></view></view><view class="section"><view class="section-head"><view class="section-title">活动微信群</view><text bindtap="submitGroup" class="link">提交群二维码</text></view><view wx:if="{{!activity.groups.length}}" class="muted">暂无已审核的群</view><view wx:for="{{activity.groups}}" wx:key="id" class="group card" data-url="{{item.qrImageUrl}}" bindtap="openGroup"><image src="{{item.qrImageUrl}}"></image><view><b>{{item.name}}</b><text>点击查看二维码</text></view></view></view></view><view class="footer"><button wx:if="{{!joined}}" class="primary" bindtap="editPurpose">加入活动</button><block wx:else><button class="secondary" bindtap="editPurpose">编辑参与目的</button><button class="text-btn" bindtap="leave">退出</button></block></view><view wx:if="{{showPurpose}}" class="mask" bindtap="closePurpose"><view class="sheet" catchtap=""><view class="section-title">你为什么想参加?</view><textarea maxlength="300" value="{{purpose}}" bindinput="purposeInput" placeholder="例如:寻找 AI 应用方向的合作伙伴"></textarea><button class="primary" loading="{{submitting}}" bindtap="savePurpose">确认加入</button></view></view></view>
+1
View File
@@ -0,0 +1 @@
.gallery{height:560rpx;background:#ddd}.gallery image{width:100%;height:100%}.placeholder{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#315c49,#9d7256);color:white;font:italic 56rpx serif}.content{padding:34rpx 30rpx 180rpx}.status{color:#a15f3c;font-size:24rpx}.title{font:700 54rpx/1.2 serif;margin:14rpx 0 20rpx}.facts{padding:12rpx 26rpx;margin:30rpx 0}.facts view{display:flex;gap:24rpx;padding:20rpx 0;border-bottom:1rpx solid #eee5db}.facts view:last-child{border:0}.facts text{color:#8a7d71;width:70rpx}.facts b{font-size:27rpx}.section{margin:46rpx 0}.section-title{font-size:34rpx;font-weight:700;margin-bottom:20rpx}.description{line-height:1.8;color:#5f534a;white-space:pre-wrap}.expo{padding:28rpx;display:flex;flex-direction:column;gap:10rpx}.overline{font-size:20rpx;letter-spacing:3rpx;color:#9b7256}.expo-title{font-size:35rpx;font-weight:700}.section-head{display:flex;justify-content:space-between;align-items:center}.link{color:#2e5b47;font-weight:600}.person{display:flex;gap:18rpx;padding:18rpx 0;border-bottom:1rpx solid #e5dbcf}.person image{width:76rpx;height:76rpx;border-radius:50%}.person view{display:flex;flex-direction:column;gap:6rpx}.person text{color:#76695e;font-size:25rpx}.group{display:flex;align-items:center;padding:18rpx;margin:12rpx 0;gap:18rpx}.group image{width:90rpx;height:90rpx;border-radius:12rpx}.group view{display:flex;flex-direction:column;gap:8rpx}.group text{font-size:23rpx;color:#807267}.footer{position:fixed;left:0;right:0;bottom:0;padding:20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));display:flex;gap:12rpx;background:#fffdf9;border-top:1rpx solid #e2d8cc}.footer button{flex:1}.footer .text-btn{flex:0 0 120rpx;color:#a74b3f}.mask{position:fixed;inset:0;background:#241f1b88;display:flex;align-items:flex-end;z-index:10}.sheet{width:100%;background:#fffdf9;border-radius:32rpx 32rpx 0 0;padding:38rpx 30rpx calc(34rpx + env(safe-area-inset-bottom))}.sheet textarea{width:100%;box-sizing:border-box;background:#f3ede6;border-radius:20rpx;padding:22rpx;margin:10rpx 0 26rpx;height:220rpx}
+606
View File
@@ -0,0 +1,606 @@
// pages/exampleDetail/index.js
Page({
data: {
type: "",
envId: "",
showTip: false,
title: "",
content: "",
haveGetOpenId: false,
openId: "",
haveGetCodeSrc: false,
codeSrc: "",
haveGetRecord: false,
record: [],
haveGetImgSrc: false,
imgSrc: "",
// ai
modelConfig: {
modelProvider: "deepseek", // 大模型服务厂商
quickResponseModel: "deepseek-v3", // 快速响应模型 (混元 turbo, gpt4 turbo版,deepseek v3等)
logo: "https://cloudcache.tencent-cloud.com/qcloud/ui/static/static_source_business/2339414f-2c0d-4537-9618-1812bd14f4af.svg", // model 头像
welcomeMsg: "我是deepseek-v3,很高兴见到你!", // model 欢迎语
},
callcbrCode: "",
initEnvCode: "",
callOpenIdCode: "",
callMiniProgramCode: "",
callFunctionCode: "",
callCreateCollectionCode: "",
callUploadFileCode: "",
showInsertModal: false,
insertRegion: "",
insertCity: "",
insertSales: "",
haveGetCallContainerRes: false,
callContainerResStr: "",
ai_page_config: `{
"usingComponents": {
"agent-ui":"/components/agent-ui/index"
},
}`,
ai_wxml_config: `&lt;agent-ui agentConfig="{{agentConfig}}" showBotAvatar="{{showBotAvatar}}" chatMode="{{chatMode}}" modelConfig="{{modelConfig}}""&gt;&lt;/agent-ui&gt;`,
ai_data_config: `data: {
chatMode: "bot", // bot 表示使用agent,model 表示使用大模型
showBotAvatar: true, // 是否在对话框左侧显示头像
agentConfig: {
botId: "your agent id", // agent id,
allowWebSearch: true, // 允许客户端选择展示联网搜索按钮
allowUploadFile: true, // 允许客户端展示上传文件按钮
allowPullRefresh: true, // 允许客户端展示下拉刷新
allowUploadImage: true, // 允许客户端展示上传图片按钮
allowMultiConversation: true, // 允许客户端展示查看会话列表/新建会话按钮
showToolCallDetail: true, // 是否展示 mcp server toolCall 细节
allowVoice: true, // 允许客户端展示语音按钮
showBotName: true, // 允许展示bot名称
},
modelConfig: {
modelProvider: "hunyuan-open", // 大模型服务厂商
quickResponseModel: "hunyuan-lite", // 大模型名称
logo: "", // model 头像
welcomeMsg: "欢迎语", // model 欢迎语
},
}`,
// AI 场景示例数据
aiScenarios: [
{
title: "💡 智能代码生成与补全",
examples: [
"帮我创建一个商品列表页面,包含图片、标题、价格和加入购物车按钮",
"帮我完善这个函数,实现商品搜索功能",
],
},
{
title: "🔧 代码优化与重构建议",
examples: [
"优化这段代码的性能,减少不必要的渲染",
"完善云函数调用的错误处理代码",
],
},
],
},
onLoad(options) {
if (
options.type === "cloudbaserunfunction" ||
options.type === "cloudbaserun"
) {
this.getCallcbrCode();
}
if (options.type === "getOpenId") {
this.getOpenIdCode();
}
if (options.type === "getMiniProgramCode") {
this.getMiniProgramCode();
}
if (options.type === "createCollection") {
this.getCreateCollectionCode();
}
if (options.type === "uploadFile") {
this.getUploadFileCode();
}
this.setData({ type: options?.type, envId: options?.envId });
},
copyUrl() {
wx.setClipboardData({
data: "https://gitee.com/TencentCloudBase/cloudbase-agent-ui/tree/main/apps/miniprogram-agent-ui/miniprogram/components/agent-ui",
success: function (res) {
wx.showToast({
title: "复制成功",
icon: "success",
});
},
});
},
copyPluginName() {
wx.setClipboardData({
data: "微信云开发 AI ToolKit",
success: function (res) {
wx.showToast({
title: "复制成功",
icon: "success",
});
},
});
},
copyPrompt(e) {
const prompt = e.currentTarget.dataset.prompt;
wx.setClipboardData({
data: prompt,
success: function (res) {
wx.showToast({
title: "复制成功",
icon: "success",
});
},
});
},
insertRecord() {
this.setData({
showInsertModal: true,
insertRegion: "",
insertCity: "",
insertSales: "",
});
},
deleteRecord(e) {
// 调用云函数删除记录
wx.showLoading({
title: "删除中...",
});
wx.cloud
.callFunction({
name: "quickstartFunctions",
data: {
type: "deleteRecord",
data: {
_id: e.currentTarget.dataset.id,
},
},
})
.then((resp) => {
wx.showToast({
title: "删除成功",
});
this.getRecord(); // 刷新列表
wx.hideLoading();
})
.catch((e) => {
wx.showToast({
title: "删除失败",
icon: "none",
});
wx.hideLoading();
});
},
// 输入框事件
onInsertRegionInput(e) {
this.setData({ insertRegion: e.detail.value });
},
onInsertCityInput(e) {
this.setData({ insertCity: e.detail.value });
},
onInsertSalesInput(e) {
this.setData({ insertSales: e.detail.value });
},
// 取消弹窗
onInsertCancel() {
this.setData({ showInsertModal: false });
},
// 确认插入
async onInsertConfirm() {
const { insertRegion, insertCity, insertSales } = this.data;
if (!insertRegion || !insertCity || !insertSales) {
wx.showToast({ title: "请填写完整信息", icon: "none" });
return;
}
wx.showLoading({ title: "插入中..." });
try {
await wx.cloud.callFunction({
name: "quickstartFunctions",
data: {
type: "insertRecord",
data: {
region: insertRegion,
city: insertCity,
sales: Number(insertSales),
},
},
});
wx.showToast({ title: "插入成功" });
this.setData({ showInsertModal: false });
this.getRecord(); // 刷新列表
} catch (e) {
wx.showToast({ title: "插入失败", icon: "none" });
console.error(e);
} finally {
wx.hideLoading();
}
},
getOpenId() {
wx.showLoading({
title: "",
});
wx.cloud
.callFunction({
name: "quickstartFunctions",
data: {
type: "getOpenId",
},
})
.then((resp) => {
this.setData({
haveGetOpenId: true,
openId: resp.result.openid,
});
wx.hideLoading();
})
.catch((e) => {
wx.hideLoading();
const { errCode, errMsg } = e;
if (errMsg.includes("Environment not found")) {
this.setData({
showTip: true,
title: "云开发环境未找到",
content:
"如果已经开通云开发,请检查环境ID与 `miniprogram/app.js` 中的 `env` 参数是否一致。",
});
return;
}
if (errMsg.includes("FunctionName parameter could not be found")) {
this.setData({
showTip: true,
title: "请上传云函数",
content:
"在'cloudfunctions/quickstartFunctions'目录右键,选择【上传并部署-云端安装依赖】,等待云函数上传完成后重试。",
});
return;
}
});
},
clearOpenId() {
this.setData({
haveGetOpenId: false,
openId: "",
});
},
clearCallContainerRes() {
this.setData({
haveGetCallContainerRes: false,
callContainerResStr: "",
});
},
getCodeSrc() {
wx.showLoading({
title: "",
});
wx.cloud
.callFunction({
name: "quickstartFunctions",
data: {
type: "getMiniProgramCode",
},
})
.then((resp) => {
this.setData({
haveGetCodeSrc: true,
codeSrc: resp.result,
});
wx.hideLoading();
})
.catch((e) => {
wx.hideLoading();
console.error(e);
const { errCode, errMsg } = e;
if (errMsg.includes("Environment not found")) {
this.setData({
showTip: true,
title: "云开发环境未找到",
content:
"如果已经开通云开发,请检查环境ID与 `miniprogram/app.js` 中的 `env` 参数是否一致。",
});
return;
}
if (errMsg.includes("FunctionName parameter could not be found")) {
this.setData({
showTip: true,
title: "请上传云函数",
content:
"在'cloudfunctions/quickstartFunctions'目录右键,选择【上传并部署-云端安装依赖】,等待云函数上传完成后重试。",
});
return;
}
});
},
clearCodeSrc() {
this.setData({
haveGetCodeSrc: false,
codeSrc: "",
});
},
bindInput(e) {
const index = e.currentTarget.dataset.index;
const record = this.data.record;
record[index].sales = Number(e.detail.value);
this.setData({
record,
});
},
getRecord() {
wx.showLoading({
title: "",
});
wx.cloud
.callFunction({
name: "quickstartFunctions",
data: {
type: "selectRecord",
},
})
.then((resp) => {
this.setData({
haveGetRecord: true,
record: resp.result.data,
});
wx.hideLoading();
})
.catch((e) => {
this.setData({
showTip: true,
});
wx.hideLoading();
console.error(e);
});
},
clearRecord() {
this.setData({
haveGetRecord: false,
record: [],
});
},
updateRecord() {
wx.showLoading({
title: "",
});
wx.cloud
.callFunction({
name: "quickstartFunctions",
data: {
type: "updateRecord",
data: this.data.record,
},
})
.then((resp) => {
wx.showToast({
title: "更新成功",
});
wx.hideLoading();
})
.catch((e) => {
console.log(e);
this.setData({
showUploadTip: true,
});
wx.hideLoading();
});
},
uploadImg() {
wx.showLoading({
title: "",
});
// 让用户选择一张图片
wx.chooseMedia({
count: 1,
success: (chooseResult) => {
// 将图片上传至云存储空间
wx.cloud
.uploadFile({
// 指定上传到的云路径
cloudPath: `my-photo-${new Date().getTime()}.png`,
// 指定要上传的文件的小程序临时文件路径
filePath: chooseResult.tempFiles[0].tempFilePath,
})
.then((res) => {
this.setData({
haveGetImgSrc: true,
imgSrc: res.fileID,
});
})
.catch((e) => {
console.log("e", e);
});
},
complete: () => {
wx.hideLoading();
},
});
},
clearImgSrc() {
this.setData({
haveGetImgSrc: false,
imgSrc: "",
});
},
goOfficialWebsite() {
const url = "https://docs.cloudbase.net/toolbox/quick-start";
wx.navigateTo({
url: `../web/index?url=${url}`,
});
},
runCallContainer: async function () {
const app = getApp();
console.log("globalData", app.globalData);
const c1 = new wx.cloud.Cloud({
resourceEnv: app.globalData.env,
});
await c1.init();
const r = await c1.callContainer({
path: "/api/users", // 填入业务自定义路径
header: {
"X-WX-SERVICE": "express-test", // 填入服务名称
},
// 其余参数同 wx.request
method: "GET",
});
console.log(r);
this.setData({
haveGetCallContainerRes: true,
callContainerResStr: `${JSON.stringify(r.data.items, null, 2)}`,
});
},
getCallcbrCode: function () {
const app = getApp();
this.setData({
callcbrCode: `const c1 = new wx.cloud.Cloud({
resourceEnv: ${app.globalData.env}
})
await c1.init()
const r = await c1.callContainer({
path: '/api/users', // 此处填入业务自定义路径, /api/users 为示例路径
header: {
'X-WX-SERVICE': 'express-test', // 填入业务服务名称,express-test 为示例服务
},
// 其余参数同 wx.request
method: 'GET',
})`,
});
},
getInitEnvCode: function () {
const app = getApp();
this.setData({
initEnvCode: `wx.cloud.init({
env: ${app.globalData.env},
traceUser: true,
});`,
});
},
getCreateCollectionCode: function () {
this.setData({
callCreateCollectionCode: `const cloud = require('wx-server-sdk');
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV
});
const db = cloud.database();
// 创建集合云函数入口函数
exports.main = async (event, context) => {
try {
// 创建集合
await db.createCollection('sales');
return {
success: true
};
} catch (e) {
return {
success: true,
data: 'create collection success'
};
}
};`,
});
},
getOpenIdCode: function () {
this.setData({
callOpenIdCode: `const cloud = require('wx-server-sdk');
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV
});
// 获取openId云函数入口函数
exports.main = async (event, context) => {
// 获取基础信息
const wxContext = cloud.getWXContext();
return {
openid: wxContext.OPENID,
appid: wxContext.APPID,
unionid: wxContext.UNIONID,
};
};`,
callFunctionCode: `wx.cloud.callFunction({
name: 'quickstartFunctions',
data: {
type: 'getOpenId'
}
}).then((resp) => console.log(resp))`,
});
},
getMiniProgramCode: function () {
this.setData({
callMiniProgramCode: `const cloud = require('wx-server-sdk');
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV
});
// 获取小程序二维码云函数入口函数
exports.main = async (event, context) => {
// 获取小程序二维码的buffer
const resp = await cloud.openapi.wxacode.get({
path: 'pages/index/index'
});
const { buffer } = resp;
// 将图片上传云存储空间
const upload = await cloud.uploadFile({
cloudPath: 'code.png',
fileContent: buffer
});
return upload.fileID;
};
`,
callFunctionCode: `wx.cloud.callFunction({
name: 'quickstartFunctions',
data: {
type: 'getMiniProgramCode'
}
}).then((resp) => console.log(resp))`,
});
},
getUploadFileCode: function () {
this.setData({
callUploadFileCode: `wx.chooseMedia({
count: 1,
success: (chooseResult) => {
// 将图片上传至云存储空间
wx.cloud
.uploadFile({
// 指定上传到的云路径
cloudPath: "my-photo.png",
// 指定要上传的文件的小程序临时文件路径
filePath: chooseResult.tempFiles[0].tempFilePath,
})
.then((res) => {
console.log(res)
})
.catch((e) => {
console.log('e', e)
});
}
});`,
});
},
});
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
"cloud-tip-modal": "/components/cloudTipModal/index"
}
}
+294
View File
@@ -0,0 +1,294 @@
<!--pages/exampleDetail/index.wxml-->
<block wx:if="{{ type === 'getOpenId' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="info">云函数无需维护鉴权机制及登录票据,仅一行代码即可获得。</view>
<view class="title">云函数获取OpenId示例</view>
<!-- <view class="info">云函数无需维护鉴权机制及登录票据,仅一行代码即可获得。</view> -->
<view class="block">
<view class="step-title"><text class="step-left">step</text> <text class="step-right">1 </text>quickStartFunctions 云函数代码</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callOpenIdCode}}</pre>" />
</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">2 </text>小程序代码段</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callFunctionCode}}</pre>" />
</view>
</view>
<view class="block">
<view class="btn-full" bind:tap="getOpenId" wx:if="{{!haveGetOpenId}}">运行示例</view>
<view class="box_text">{{ openId ? openId : 'OpenID将展示在这里' }}</view>
<cloud-tip-modal showTipProps="{{showTip}}" title="{{title}}" content="{{content}}"></cloud-tip-modal>
<view class="button_clear" bindtap="clearOpenId" wx:if="{{haveGetOpenId}}">清空</view>
</view>
</view>
</block>
<block wx:if="{{ type === 'getMiniProgramCode' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="info">可通过云函数免接口调用凭证,直接生成小程序码。</view>
<view class="title">云函数获取小程序码示例</view>
<view class="block">
<view class="step-title"><text class="step-left">step</text> <text class="step-right">1 </text>quickStartFunctions 云函数代码</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callMiniProgramCode}}</pre>" />
</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">2 </text>小程序代码段</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callFunctionCode}}</pre>" />
</view>
</view>
<view class="block">
<view class="btn-full" bind:tap="getCodeSrc" wx:if="{{!haveGetCodeSrc}}">运行示例</view>
<view class="box_text" wx:if="{{!codeSrc}}">小程序码将展示在这里</view>
<view wx:if="{{codeSrc}}" class="code_box">
<image class="code_img" src="{{codeSrc}}"></image>
</view>
<view class="button_clear" bindtap="clearCodeSrc" wx:if="{{haveGetCodeSrc}}">清空</view>
<cloud-tip-modal showTipProps="{{showTip}}" title="{{title}}" content="{{content}}"></cloud-tip-modal>
</view>
</view>
</block>
<block wx:if="{{ type === 'createCollection' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="info">集合为常用数据库中表的概念。云开发数据库支持自动备份、无损回档,并且QPS高达3千+。</view>
<view class="title">如何体验</view>
<view class="info">已自动创建名为“sales”的体验合集,可打开“云开发控制台>数据库>记录列表”中找到该集合。</view>
<image class="img" src="../../images/database.png"></image>
<view class="title">云函数代码示例</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callCreateCollectionCode}}</pre>" />
</view>
</view>
</block>
<block wx:if="{{ type === 'selectRecord' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="top_tip">体验查询记录能力,查询数据表中的销量数据。</view>
<view class="box_text" wx:if="{{!record}}">销量数据将展示在这里</view>
<view class="title">数据库操作示例</view>
<view class="top_tip">参考云函数 quickstartFunctions 示例代码</view>
<view wx:if="{{record}}" class="code_box">
<view class="code_box_title">地区销量统计</view>
<view class="code_box_record">
<view class="code_box_record_title">地域</view>
<view class="code_box_record_title">城市</view>
<view class="code_box_record_title">销量</view>
<view class="code_box_record_title">操作</view>
</view>
<view class="line"></view>
<view class="code_box_record" wx:for="{{record}}" wx:key="_id">
<view class="code_box_record_detail">{{item.region}}</view>
<view class="code_box_record_detail">{{item.city}}</view>
<!-- <view class="code_box_record_detail">{{item.sales}}</view> -->
<input style="background-color: rgba(0, 0, 0, 0.03)" class="code_box_record_detail" bindinput="bindInput" data-index="{{index}}" value="{{item.sales}}" type="number"></input>
<view class="code_box_record_detail">
<button style="font-size: 12px" bind:tap="deleteRecord" data-id="{{item._id}}">删除</button>
</view>
</view>
</view>
<view class="btn-full" bind:tap="getRecord" >查询记录</view>
<view class="btn-full" bind:tap="updateRecord" >更新记录</view>
<view class="btn-full" bind:tap="insertRecord" >新增记录</view>
<!-- <view class="button_clear" bindtap="clearRecord" wx:if="{{haveGetRecord}}">清空</view> -->
<cloud-tip-modal showTipProps="{{showTip}}"></cloud-tip-modal>
</view>
<view wx:if="{{showInsertModal}}" class="modal-mask">
<view class="modal-content">
<view class="modal-title">新增销量记录</view>
<input class="modal-input" placeholder="地域" value="{{insertRegion}}" bindinput="onInsertRegionInput"/>
<input class="modal-input" placeholder="城市" value="{{insertCity}}" bindinput="onInsertCityInput"/>
<input class="modal-input" placeholder="销量" value="{{insertSales}}" bindinput="onInsertSalesInput" type="number"/>
<view class="modal-actions">
<button bindtap="onInsertCancel">取消</button>
<button bindtap="onInsertConfirm" type="primary">确认</button>
</view>
</view>
</view>
</block>
<block wx:if="{{ type === 'uploadFile' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="top_tip">多存储类型,仅需一个云函数即可完成上传。</view>
<view class="title">文件上传示例</view>
<view class="block">
<view class="step-title">小程序代码段</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callUploadFileCode}}</pre>" />
</view>
</view>
<view class="btn-full" bind:tap="uploadImg" wx:if="{{!haveGetImgSrc}}">运行示例</view>
<view class="box_text" wx:if="{{!imgSrc}}">上传的图片将展示在这里</view>
<view wx:if="{{imgSrc}}" class="code_box">
<image class="code_img" src="{{imgSrc}}"></image>
<!-- <view class="img_info">
<view class="img_info_title">文件路径</view>
<view class="img_info_detail">{{imgSrc}}</view>
</view> -->
</view>
<view class="button_clear" bindtap="clearImgSrc" wx:if="{{haveGetImgSrc}}">清空</view>
<cloud-tip-modal showTipProps="{{showTip}}"></cloud-tip-modal>
</view>
</block>
<block wx:if="{{ type === 'model-guide'}}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="top_tip">腾讯云开发提供 AI 对话能力,支持 Agent,大模型流式对话,可通过 Agent-UI 组件快速集成 AI 能力</view>
<view class="title">集成 Agent-UI 组件指引</view>
<view class="block">
<view class="step-title"><text class="step-left">step</text> <text class="step-right">1 </text>拷贝组件源码包</view>
<view style="display: flex;align-items: center;">点击复制查看组件仓库地址 <image mode="widthFix" style="width: 20px;height: 20px" bind:tap="copyUrl" src='../../images/copy.svg'/></view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">2 </text>将组件拷贝至小程序目录中</view>
<image class="img" mode="widthFix" src="../../images/ai_example2.png"></image>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">3 </text>在页面 .json 配置文件中注册组件</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{ai_page_config}}</pre>" />
</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">4 </text>在页面 .wxml 文件中引用组件</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{ai_wxml_config}}</pre>" />
</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">4 </text>在页面 .js 文件中编写配置</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{ai_data_config}}</pre>" />
</view>
</view>
</view>
</block>
<block wx:if="{{ type === 'cloudbaserun' }}">
<view class="page-container">
<view class="title">功能介绍</view>
<view class="info">云托管 支持托管用任意语言和框架编写的容器化应用,为开发者提供高可用、自动弹性扩缩的云服务。</view>
<view class="title">小程序调用云托管示例</view>
<view class="block">
<view class="step-title"><text class="step-left">step</text> <text class="step-right">1 </text>前往云开发平台开通云托管</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">2 </text>新建容器型托管服务,等待部署完成</view>
<view class="step-text">
此处可使用 Express 示例模板进行安装,此处示例命名为 express-test
</view>
<view>
<image class="img" src="../../images/create_cbr.png" mode="aspectFill"></image>
</view>
<view class="step-title"><text class="step-left">step</text> <text class="step-right">3</text>小程序端调用</view>
<view class="code_zone">
<rich-text nodes="<pre style='overflow: scroll;'>{{callcbrCode}}</pre>" />
</view>
<view class="btn-full" bind:tap="runCallContainer" wx:if="{{!haveGetCallContainerRes}}">运行示例</view>
<view class="box_text">{{ callContainerResStr ? callContainerResStr : '云托管调用结果将展示在这里' }}</view>
<view class="button_clear" bindtap="clearCallContainerRes" wx:if="{{haveGetCallContainerRes}}">清空</view>
</view>
</view>
</block>
<block wx:if="{{ type === 'ai-assistant' }}">
<view class="page-container">
<view class="title">AI 智能开发小程序</view>
<view class="info">连接 AI 开发工具与 MCP 开发小程序</view>
<view class="block">
<view class="step-title">
<text class="step-left">step</text>
<text class="step-right">1 </text>
打开扩展面板
</view>
<view class="step-text">
在微信开发者工具中使用快捷键打开扩展面板:
</view>
<view class="notice-box">
<view class="notice-text">
<text class="notice-highlight">注意:</text>使用快捷键前需先点击编辑区,将焦点从预览区切换到编辑区。
</view>
</view>
<view class="shortcut-box">
<view class="shortcut-item">
<view class="shortcut-label">Windows/Linux:</view>
<view class="shortcut-key">Ctrl + Shift + X</view>
</view>
<view class="shortcut-item">
<view class="shortcut-label">macOS:</view>
<view class="shortcut-key">Shift + Command + X</view>
</view>
</view>
<view class="step-title">
<text class="step-left">step</text>
<text class="step-right">2 </text>
搜索并安装插件
</view>
<view class="step-text">
在扩展商店中搜索:
</view>
<view class="plugin-name-box">
<text class="plugin-name">微信云开发 AI ToolKit</text>
<view class="copy-btn" bindtap="copyPluginName">
<image class="copy-icon" src="../../images/copy.svg" mode="widthFix"></image>
<text class="copy-text">复制</text>
</view>
</view>
<view class="step-text">
找到插件后点击安装并启用
</view>
<view class="step-title">
<text class="step-left">step</text>
<text class="step-right">3 </text>
打开 AI 开发功能
</view>
<view class="step-text">
安装完成后,选中任意文件,点击左上角打开 AI 开发功能即可使用
</view>
<image class="img step-img" src="https://tcb-advanced-a656fc-1257967285.ap-shanghai.app.tcloudbase.com/imgs/wechat-ide-extension/ide-extension.png" mode="widthFix"></image>
<view class="divider"></view>
<view class="feature-section">
<view class="feature-title">✨ 场景示例</view>
<view class="feature-list">
<view class="feature-item-with-examples">
<view class="feature-main">💡 智能代码生成与补全</view>
<view class="feature-examples">
<view class="feature-example-wrapper">
<text class="feature-example-text">· 帮我创建一个商品列表页面,包含图片、标题、价格和加入购物车按钮</text>
<view class="feature-copy-btn" bindtap="copyPrompt" data-prompt="帮我创建一个商品列表页面,包含图片、标题、价格和加入购物车按钮">
<image class="feature-copy-icon" src="../../images/copy.svg" mode="widthFix"></image>
</view>
</view>
<view class="feature-example-wrapper">
<text class="feature-example-text">· 帮我完善这个函数,实现商品搜索功能</text>
<view class="feature-copy-btn" bindtap="copyPrompt" data-prompt="帮我完善这个函数,实现商品搜索功能">
<image class="feature-copy-icon" src="../../images/copy.svg" mode="widthFix"></image>
</view>
</view>
</view>
</view>
<view class="feature-item-with-examples">
<view class="feature-main">🔧 代码优化与重构建议</view>
<view class="feature-examples">
<view class="feature-example-wrapper">
<text class="feature-example-text">· 优化这个页面的间隔,提升信息密度</text>
<view class="feature-copy-btn" bindtap="copyPrompt" data-prompt="优化这个页面的间隔,提升信息密度">
<image class="feature-copy-icon" src="../../images/copy.svg" mode="widthFix"></image>
</view>
</view>
<view class="feature-example-wrapper">
<text class="feature-example-text">· 完善云函数调用的错误处理代码</text>
<view class="feature-copy-btn" bindtap="copyPrompt" data-prompt="完善云函数调用的错误处理代码">
<image class="feature-copy-icon" src="../../images/copy.svg" mode="widthFix"></image>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</block>
+393
View File
@@ -0,0 +1,393 @@
page {
background-color: white;
padding-bottom: 50px;
font-family: PingFang SC;
}
.page-container {
padding: 20rpx 40rpx;
}
.tip {
font-size: 23rpx;
color: rgba(0, 0, 0, 0.5);
width: 90%;
text-align: center;
margin: 30rpx auto 0 auto;
}
.top_tip {
font-size: 28rpx;
color: rgba(0, 0, 0, 0.5);
width: 90%;
text-align: left;
margin-top: 30rpx;
/* margin-left: 20rpx; */
}
.box_text {
background-color: white;
text-align: center;
padding: 300rpx 0;
margin-top: 30rpx;
color: rgba(0, 0, 0, 0.5);
}
.button {
width: 300rpx;
text-align: center;
margin: 250rpx auto 0 auto;
height: 80rpx;
color: white;
border-radius: 5px;
line-height: 80rpx;
background-color: #07c160;
}
.button_clear {
width: 300rpx;
text-align: center;
margin: 0 auto 0 auto;
height: 80rpx;
color: #07c160;
border-radius: 5px;
line-height: 80rpx;
background-color: rgba(0, 0, 0, 0.03);
}
.line {
height: 1rpx;
width: 100%;
background-color: rgba(0, 0, 0, 0.1);
}
.code_box {
text-align: center;
background-color: white;
margin-top: 30rpx;
padding: 17rpx;
}
.code_box_title {
color: rgba(0, 0, 0, 0.5);
font-size: 26rpx;
margin-bottom: 20rpx;
text-align: left;
}
.code_box_record {
display: flex;
}
.code_box_record_title {
width: 33%;
font-size: 26rpx;
color: rgba(0, 0, 0, 0.5);
padding: 20rpx 0;
}
.code_box_record_detail {
width: 25%;
font-size: 26rpx;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.title {
margin-top: 16px;
margin-bottom: 8px;
font-size: 36rpx;
font-weight: 500;
color: #000;
}
.info {
margin-top: 12px;
font-size: 28rpx;
font-weight: 400;
color: rgba(0, 0, 0, 0.6);
line-height: 52rpx;
}
.img {
/* margin-top: 16px; */
width: 100%;
}
.step-img {
margin-top: 20rpx;
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.code_img {
width: 360rpx;
height: 360rpx;
}
.block {
font-size: 16px;
line-height: 40px;
}
.step-left {
color: #FFF;
background-color: #1aad19;
border: #1aad19 solid 1px;
padding: 0px 6px;
}
.step-right {
color: #1aad19;
background-color: #FFF;
border: #1aad19 solid 1px;
padding: 0px 6px;
margin-right: 10px;
}
.code_zone {
background-color: #0E190E;
color: rgba(255, 255, 255, 0.7);
border-radius: 12rpx;
padding: 16rpx 32rpx;
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
position: relative;
margin-bottom: 24rpx;
}
.btn-full {
height: 40px;
border-radius: 4px;
line-height: 40px;
color: #fff;
background-color: #1aad19;
text-align: center;
margin-bottom: 5px;
}
.step-title {
line-height: 37px;
font-size: 16px;
}
.step-text{
font-size: 14px;
line-height: 24px;
padding: 10px 0px;
text-align: justify;
}
.modal-mask {
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3); z-index: 1000; display: flex; align-items: center; justify-content: center;
}
.modal-content {
background: #fff; padding: 24px; border-radius: 8px; width: 80%;
}
.modal-title { font-size: 18px; margin-bottom: 16px; }
.modal-input { margin-bottom: 12px; border: 1px solid #eee; padding: 8px; border-radius: 4px; width: 100%; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; }
/* AI助手相关样式 */
.notice-box {
background: linear-gradient(135deg, #fff8e6 0%, #fff3d9 100%);
border-left: 4rpx solid #ff9800;
border-radius: 8rpx;
padding: 20rpx 24rpx;
margin: 20rpx 0;
box-shadow: 0 2rpx 8rpx rgba(255, 152, 0, 0.1);
}
.notice-text {
font-size: 26rpx;
color: rgba(0, 0, 0, 0.75);
line-height: 40rpx;
}
.notice-highlight {
color: #ff6f00;
font-weight: 600;
}
.shortcut-box {
background-color: rgba(0, 0, 0, 0.03);
border-radius: 8rpx;
padding: 24rpx;
margin: 20rpx 0;
}
.shortcut-item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.shortcut-item:last-child {
margin-bottom: 0;
}
.shortcut-label {
font-size: 28rpx;
color: rgba(0, 0, 0, 0.7);
width: 240rpx;
flex-shrink: 0;
}
.shortcut-key {
font-size: 28rpx;
color: #1aad19;
font-weight: 500;
background-color: rgba(26, 173, 25, 0.1);
padding: 4rpx 12rpx;
border-radius: 4rpx;
white-space: nowrap;
}
.plugin-name-box {
background-color: rgba(26, 173, 25, 0.05);
border-left: 4rpx solid #1aad19;
padding: 20rpx;
margin: 20rpx 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.plugin-name {
font-size: 32rpx;
color: #1aad19;
font-weight: 500;
flex: 1;
}
.copy-btn {
display: flex;
align-items: center;
padding: 6rpx 16rpx;
background-color: rgba(26, 173, 25, 0.1);
border: 1rpx solid rgba(26, 173, 25, 0.3);
border-radius: 8rpx;
margin-left: 20rpx;
transition: all 0.2s;
}
.copy-btn:active {
background-color: rgba(26, 173, 25, 0.2);
}
.copy-icon {
width: 24rpx;
height: 24rpx;
margin-right: 6rpx;
filter: invert(52%) sepia(73%) saturate(2034%) hue-rotate(90deg) brightness(92%) contrast(84%);
}
.copy-text {
font-size: 24rpx;
color: #1aad19;
font-weight: 500;
}
.divider {
height: 1rpx;
width: 100%;
background-color: rgba(0, 0, 0, 0.1);
margin: 40rpx 0;
}
.feature-section {
margin-top: 20rpx;
}
.feature-title {
font-size: 32rpx;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 20rpx;
}
.feature-list {
padding-left: 0;
}
.feature-item {
font-size: 28rpx;
color: rgba(0, 0, 0, 0.65);
line-height: 48rpx;
margin-bottom: 12rpx;
}
/* 带示例的功能项 */
.feature-item-with-examples {
margin-bottom: 20rpx;
padding: 20rpx;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 12rpx;
}
.feature-item-with-examples:last-child {
margin-bottom: 0;
}
.feature-main {
font-size: 30rpx;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12rpx;
padding-bottom: 8rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.08);
}
.feature-examples {
padding-left: 0;
}
.feature-example {
font-size: 26rpx;
color: rgba(0, 0, 0, 0.55);
line-height: 44rpx;
margin-bottom: 8rpx;
}
.feature-example-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4rpx;
padding: 8rpx 12rpx;
}
.feature-example-text {
flex: 1;
font-size: 26rpx;
color: rgba(0, 0, 0, 0.55);
line-height: 40rpx;
}
.feature-copy-btn {
padding: 12rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 12rpx;
flex-shrink: 0;
transition: all 0.2s;
}
.feature-copy-btn:active {
background-color: rgba(0, 0, 0, 0.08);
}
.feature-copy-icon {
width: 32rpx;
height: 32rpx;
opacity: 0.5;
transition: opacity 0.2s;
}
.feature-copy-btn:active .feature-copy-icon {
opacity: 0.8;
}
+2
View File
@@ -0,0 +1,2 @@
const api = require('../../utils/api')
Page({data:{activities:[],exhibitions:[],tags:[],exhibitionIndex:0,selectedTag:'',time:'available',loading:true},onLoad(){this.loadMeta()},onShow(){if(this.data.exhibitions.length)this.loadActivities()},onPullDownRefresh(){this.loadActivities().finally(wx.stopPullDownRefresh)},async loadMeta(){try{const data=await api.request('/api/activities/meta');this.setData({exhibitions:[{id:'',name:'全部会展'}].concat(data.exhibitions),tags:data.tags});await this.loadActivities()}catch(e){this.fail(e)}},async loadActivities(){this.setData({loading:true});const exhibition=this.data.exhibitions[this.data.exhibitionIndex];const qs=[`time=${this.data.time}`];if(exhibition&&exhibition.id)qs.push(`exhibitionId=${exhibition.id}`);if(this.data.selectedTag)qs.push(`tagId=${this.data.selectedTag}`);try{const activities=await api.request(`/api/activities?${qs.join('&')}`);this.setData({activities,loading:false})}catch(e){this.fail(e)}},changeExhibition(e){this.setData({exhibitionIndex:Number(e.detail.value)});this.loadActivities()},selectTag(e){this.setData({selectedTag:this.data.selectedTag===e.currentTarget.dataset.id?'':e.currentTarget.dataset.id});this.loadActivities()},setTime(e){this.setData({time:e.currentTarget.dataset.value});this.loadActivities()},open(e){wx.navigateTo({url:`/pages/activity/index?id=${e.currentTarget.dataset.id}`})},fail(e){this.setData({loading:false});wx.showToast({title:e.message||'加载失败',icon:'none'})}})
+5
View File
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "活动广场",
"enablePullDownRefresh": true,
"backgroundColor": "#f5f1ea"
}
+1
View File
@@ -0,0 +1 @@
<view class="page safe"><view class="hero"><text class="eyebrow">MEET · TALK · CONNECT</text><view class="headline">下一场好对话,\n从这里开始</view><text class="intro">发现会展期间值得加入的活动和有趣的人</text></view><view class="filters"><picker mode="selector" range="{{exhibitions}}" range-key="name" value="{{exhibitionIndex}}" bindchange="changeExhibition"><view class="select">{{exhibitions[exhibitionIndex].name || '全部会展'}}⌄</view></picker><view class="time-switch"><view class="{{time==='upcoming'?'on':''}}" data-value="upcoming" bindtap="setTime">未开始</view><view class="{{time==='available'?'on':''}}" data-value="available" bindtap="setTime">可参加</view></view></view><scroll-view scroll-x class="tags" enable-flex><view class="tag-pill {{!selectedTag?'active':''}}" data-id="" bindtap="selectTag">全部</view><view wx:for="{{tags}}" wx:key="id" class="tag-pill {{selectedTag===item.id?'active':''}}" data-id="{{item.id}}" bindtap="selectTag">{{item.name}}</view></scroll-view><view wx:if="{{loading}}" class="empty">正在寻找好活动…</view><view wx:elif="{{!activities.length}}" class="empty">这个筛选下暂时没有活动</view><view class="feed"><view wx:for="{{activities}}" wx:key="id" class="activity card" data-id="{{item.id}}" bindtap="open"><image wx:if="{{item.images[0]}}" class="cover" src="{{item.images[0].url}}" mode="aspectFill"></image><view wx:else class="cover placeholder">CoffeeChat</view><view class="body"><view class="date">{{item.status==='active'?'正在进行':'即将开始'}} · {{item.startsAt}}</view><view class="title">{{item.title}}</view><view class="expo">{{item.exhibition.name}} · {{item.location || item.exhibition.venue}}</view><view class="tag-row"><text wx:for="{{item.tags}}" wx:key="id" class="tag">{{item.name}}</text><text class="people">{{item.participantCount}} 人参加</text></view></view></view></view></view>
+1
View File
@@ -0,0 +1 @@
.hero{padding:36rpx 6rpx 42rpx}.eyebrow{font-size:20rpx;letter-spacing:5rpx;color:#8e6c55}.headline{white-space:pre-line;font-family:serif;font-weight:700;font-size:64rpx;line-height:1.13;margin:16rpx 0}.intro{color:#7a6e63;font-size:27rpx}.filters{display:flex;justify-content:space-between;align-items:center;margin-bottom:22rpx}.select{padding:18rpx 24rpx;background:#fffdf9;border-radius:999rpx;font-size:25rpx;max-width:320rpx;overflow:hidden;white-space:nowrap}.time-switch{display:flex;padding:5rpx;background:#e5ddd3;border-radius:999rpx}.time-switch view{padding:13rpx 20rpx;font-size:23rpx;color:#74665b;border-radius:999rpx}.time-switch .on{background:#2e5b47;color:#fff}.tags{white-space:nowrap;margin-bottom:30rpx}.tag-pill{display:inline-block;padding:13rpx 25rpx;border:1rpx solid #d9ccbd;border-radius:999rpx;margin-right:12rpx;color:#756558;font-size:24rpx}.tag-pill.active{background:#8b6248;border-color:#8b6248;color:white}.feed{display:flex;flex-direction:column;gap:26rpx}.activity{overflow:hidden}.cover{width:100%;height:370rpx;display:block}.placeholder{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#315c49,#9d7256);color:white;font:italic 52rpx serif;letter-spacing:3rpx}.body{padding:26rpx}.date{font-size:22rpx;color:#a15f3c}.title{font-size:36rpx;font-weight:700;margin:12rpx 0}.expo{color:#75685d;font-size:25rpx}.tag-row{display:flex;align-items:center;margin-top:22rpx}.people{font-size:23rpx;color:#7f7165;margin-left:auto}
+1
View File
@@ -0,0 +1 @@
Page({retry(){getApp().login().then(()=>wx.switchTab({url:'/pages/index/index'})).catch(e=>wx.showToast({title:e.message,icon:'none'}))}})
+1
View File
@@ -0,0 +1 @@
{"navigationBarTitleText":"登录"}
+1
View File
@@ -0,0 +1 @@
<view class="page login"><view class="mark">CoffeeChat</view><view class="title">与值得认识的人,聊杯咖啡</view><text>微信登录仅用于识别你的活动身份</text><button class="primary" bindtap="retry">微信登录</button></view>
+1
View File
@@ -0,0 +1 @@
.login{padding:180rpx 60rpx;text-align:center}.mark{font:italic 34rpx serif;color:#9b7256}.title{font:700 58rpx/1.3 serif;margin:50rpx 0 24rpx}.login text{color:#817367}.login button{margin-top:70rpx}
+1
View File
@@ -0,0 +1 @@
const api=require('../../utils/api');const app=getApp();Page({data:{user:null,editing:false,nickname:''},onShow(){app.ensureLogin().then(user=>this.setData({user,nickname:user.nickname})).catch(()=>{})},edit(){this.setData({editing:true})},input(e){this.setData({nickname:e.detail.value})},cancel(){this.setData({editing:false})},async save(){try{const user=await api.request('/api/auth/me',{method:'PATCH',data:{nickname:this.data.nickname,avatarUrl:this.data.user.avatarUrl||''}});wx.setStorageSync('user',user);app.globalData.user=user;this.setData({user,editing:false});wx.showToast({title:'已保存'})}catch(e){wx.showToast({title:e.message,icon:'none'})}},chooseAvatar(e){const avatarUrl=e.detail.avatarUrl;this.setData({'user.avatarUrl':avatarUrl});api.upload(avatarUrl).then(file=>this.setData({'user.avatarUrl':file.url}))}})
+1
View File
@@ -0,0 +1 @@
{"navigationBarTitleText":"我的","backgroundColor":"#f5f1ea"}
+1
View File
@@ -0,0 +1 @@
<view class="page safe"><view class="profile card"><button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="chooseAvatar"><image src="{{user.avatarUrl || '/images/avatar.png'}}"></image></button><view wx:if="{{!editing}}"><view class="name">{{user.nickname || '微信用户'}}</view><text class="muted">CoffeeChat 参与者</text><button class="secondary small" bindtap="edit">编辑资料</button></view><view wx:else class="edit"><input value="{{nickname}}" bindinput="input" placeholder="你的昵称"><view><button class="primary small" bindtap="save">保存</button><button class="small" bindtap="cancel">取消</button></view></view></view><view class="about"><text class="eyebrow">ABOUT</text><view>在会展里找到真正想聊的人。</view><text class="muted">加入活动前写下你的目的,让现场的每次开场更自然。</text></view></view>
+1
View File
@@ -0,0 +1 @@
.profile{margin-top:30rpx;padding:36rpx;display:flex;gap:28rpx;align-items:center}.avatar-btn{padding:0;width:132rpx;height:132rpx;border-radius:50%;overflow:hidden;flex:none}.avatar-btn image{width:100%;height:100%}.name{font-size:40rpx;font-weight:700;margin-bottom:8rpx}.small{font-size:24rpx;padding:8rpx 22rpx;margin:18rpx 8rpx 0 0;display:inline-block;line-height:1.8}.edit input{background:#f2ece5;border-radius:12rpx;padding:16rpx}.about{margin:70rpx 10rpx}.about view{font:700 46rpx/1.3 serif;margin:16rpx 0}.about .muted{line-height:1.7}.eyebrow{font-size:20rpx;letter-spacing:4rpx;color:#9b7256}
+7
View File
@@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}
+4
View File
@@ -0,0 +1,4 @@
const { apiBaseUrl } = require('../config')
function request(path, options = {}) { return new Promise((resolve, reject) => wx.request({ url: apiBaseUrl + path, method: options.method || 'GET', data: options.data, header: { 'content-type': 'application/json', Authorization: `Bearer ${wx.getStorageSync('token') || ''}` }, success: (res) => res.statusCode >= 200 && res.statusCode < 300 ? resolve(res.data) : reject(new Error(res.data && res.data.error || '网络请求失败')), fail: reject })) }
function upload(filePath) { return new Promise((resolve, reject) => wx.uploadFile({ url: apiBaseUrl + '/api/uploads', filePath, name: 'files', header: { Authorization: `Bearer ${wx.getStorageSync('token') || ''}` }, success: (res) => { const data = JSON.parse(res.data || '{}'); res.statusCode < 300 ? resolve(data.files[0]) : reject(new Error(data.error || '上传失败')) }, fail: reject })) }
module.exports = { request, upload, apiBaseUrl }
+1318
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
{"name":"coffeechat","private":true,"workspaces":["server"],"scripts":{"dev":"npm --workspace server run dev","start":"npm --workspace server start","test":"npm --workspace server test","check":"npm --workspace server run check"}}
+85
View File
@@ -0,0 +1,85 @@
{
"miniprogramRoot": "miniprogram/",
"cloudfunctionRoot": "cloudfunctions/",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"enableEngineNative": false,
"useIsolateContext": true,
"useCompilerModule": true,
"userConfirmedUseCompilerModuleSwitch": false,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"compileWorklet": false,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"appid": "wx386640f47786131f",
"projectname": "coffeechat",
"libVersion": "2.20.1",
"cloudfunctionTemplateRoot": "cloudfunctionTemplate/",
"condition": {
"search": {
"list": []
},
"conversation": {
"list": []
},
"plugin": {
"list": []
},
"game": {
"list": []
},
"miniprogram": {
"list": [
{
"id": -1,
"name": "db guide",
"pathName": "pages/databaseGuide/databaseGuide"
}
]
}
},
"compileType": "miniprogram",
"srcMiniprogramRoot": "miniprogram/",
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"simulatorPluginLibVersion": {}
}
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY server/package.json server/package.json
RUN npm ci --omit=dev --workspace server
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
RUN apk add --no-cache curl
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY server/package.json server/package.json
COPY server/src server/src
COPY server/public server/public
RUN mkdir -p /app/uploads && chown -R node:node /app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -fsS http://localhost:3000/health || exit 1
CMD ["node", "server/src/index.js"]
+48
View File
@@ -0,0 +1,48 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), openid text UNIQUE NOT NULL, unionid text,
nickname text NOT NULL DEFAULT '微信用户', avatar_url text,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS exhibitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, venue text NOT NULL,
address text NOT NULL DEFAULT '', starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL,
description text NOT NULL DEFAULT '', cover_url text, created_at timestamptz NOT NULL DEFAULT now(),
CHECK (ends_at > starts_at)
);
CREATE TABLE IF NOT EXISTS tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text UNIQUE NOT NULL, color text NOT NULL DEFAULT '#7C5CFC'
);
CREATE TABLE IF NOT EXISTS activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), exhibition_id uuid NOT NULL REFERENCES exhibitions(id) ON DELETE CASCADE,
title text NOT NULL, description text NOT NULL DEFAULT '', location text NOT NULL DEFAULT '',
starts_at timestamptz NOT NULL, ends_at timestamptz, capacity integer CHECK (capacity IS NULL OR capacity > 0),
published boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (ends_at IS NULL OR ends_at > starts_at)
);
CREATE TABLE IF NOT EXISTS activity_images (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
url text NOT NULL, sort_order integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS activity_tags (
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (activity_id, tag_id)
);
CREATE TABLE IF NOT EXISTS participations (
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose text NOT NULL DEFAULT '',
joined_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (activity_id, user_id)
);
DO $$ BEGIN CREATE TYPE group_review_status AS ENUM ('pending', 'approved', 'rejected'); EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS activity_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
submitted_by uuid REFERENCES users(id) ON DELETE SET NULL, name text NOT NULL, qr_image_url text NOT NULL,
note text NOT NULL DEFAULT '', status group_review_status NOT NULL DEFAULT 'pending', review_note text NOT NULL DEFAULT '',
reviewed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_activities_exhibition_start ON activities(exhibition_id, starts_at);
CREATE INDEX IF NOT EXISTS idx_activities_published_start ON activities(published, starts_at);
CREATE INDEX IF NOT EXISTS idx_participations_activity ON participations(activity_id, joined_at);
CREATE INDEX IF NOT EXISTS idx_groups_review ON activity_groups(status, created_at);
+4
View File
@@ -0,0 +1,4 @@
INSERT INTO tags (name, color) VALUES
('创业', '#FF7A59'), ('AI', '#7C5CFC'), ('投资', '#2FA36B'), ('出海', '#1687A7'), ('设计', '#E65C9C')
ON CONFLICT (name) DO NOTHING;
+2
View File
@@ -0,0 +1,2 @@
{"name":"coffeechat-server","version":"1.0.0","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"dev":"node --watch src/index.js","start":"node src/index.js","test":"node --test","check":"node --check src/index.js && node --check src/app.js"},"dependencies":{"cors":"^2.8.5","express":"^5.1.0","helmet":"^8.1.0","jsonwebtoken":"^9.0.2","multer":"^2.0.2","pg":"^8.16.3","zod":"^4.1.5"}}
+18
View File
@@ -0,0 +1,18 @@
const state={token:localStorage.adminToken||'',data:null};
const $=(s)=>document.querySelector(s); const esc=(v='')=>String(v).replace(/[&<>"']/g,(c)=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(message){const el=$('#toast');el.textContent=message;el.style.display='block';setTimeout(()=>el.style.display='none',2200)}
async function api(path,options={}){const response=await fetch(`/api/admin${path}`,{...options,headers:{'Content-Type':'application/json','x-admin-token':state.token,...options.headers}});const data=response.status===204?null:await response.json();if(!response.ok)throw new Error(data.error||'请求失败');return data}
async function uploadImages(files){if(!files.length)return[];if(files.length>9)throw new Error('最多上传 9 张图片');const body=new FormData();Array.from(files).forEach(file=>body.append('files',file));const response=await fetch('/api/uploads/admin',{method:'POST',headers:{'x-admin-token':state.token},body});const data=await response.json();if(!response.ok)throw new Error(data.error||'图片上传失败');return data.files.map(x=>x.url)}
function date(v){return new Date(v).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
async function load(){try{state.data=await api('/dashboard');localStorage.adminToken=state.token;$('#login').hidden=true;$('#app').hidden=false;render()}catch(e){$('#login').hidden=false;$('#app').hidden=true;toast(e.message)}}
function render(){const d=state.data;$('#exhibitionCount').textContent=d.exhibitions.length;$('#activityCount').textContent=d.activities.length;$('#pendingCount').textContent=d.groups.filter(g=>g.status==='pending').length;
$('#exhibitionSelect').innerHTML=d.exhibitions.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('');$('#tagChecks').innerHTML=d.tags.map(t=>`<label><input type="checkbox" name="tagIds" value="${t.id}">${esc(t.name)}</label>`).join('');
$('#activityList').innerHTML=d.activities.map(a=>`<article class="row"><div><h3>${esc(a.title)}</h3><p>${esc(a.exhibitionName)} · ${date(a.startsAt)} · ${a.published?'已发布':'草稿'}</p></div><button data-publish="${a.id}" data-value="${!a.published}">${a.published?'下架':'发布'}</button></article>`).join('')||'<p>还没有活动</p>';
$('#exhibitionList').innerHTML=d.exhibitions.map(e=>`<article class="row"><div><h3>${esc(e.name)}</h3><p>${esc(e.venue)} · ${date(e.startsAt)} 至 ${date(e.endsAt)}</p></div></article>`).join('')||'<p>还没有会展</p>';
$('#groupList').innerHTML=d.groups.map(g=>`<article class="row"><div class="group-main"><img class="qr" src="${esc(g.qrImageUrl)}"><div><h3>${esc(g.name)} · ${esc(g.activityTitle)}</h3><p>${esc(g.submitterName||'未知用户')} · ${esc(g.note)} · ${g.status}</p></div></div>${g.status==='pending'?`<div class="row-actions"><button data-review="${g.id}" data-status="approved">通过</button><button class="reject" data-review="${g.id}" data-status="rejected">拒绝</button></div>`:''}</article>`).join('')||'<p>还没有群资料</p>';}
$('#loginForm').onsubmit=(e)=>{e.preventDefault();state.token=$('#token').value;load()};$('#logout').onclick=()=>{localStorage.removeItem('adminToken');location.reload()};
document.addEventListener('click',async(e)=>{const tab=e.target.dataset.tab;if(tab){document.querySelectorAll('nav button').forEach(x=>x.classList.toggle('active',x===e.target));document.querySelectorAll('.panel').forEach(x=>x.hidden=x.id!==tab)}if(e.target.dataset.open)document.getElementById(e.target.dataset.open).showModal();if(e.target.classList.contains('close'))e.target.closest('dialog').close();
try{if(e.target.dataset.publish){await api(`/activities/${e.target.dataset.publish}/publish`,{method:'PATCH',body:JSON.stringify({published:e.target.dataset.value==='true'})});await load()}if(e.target.dataset.review){await api(`/groups/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.dataset.status})});await load()}}catch(err){toast(err.message)}});
$('#exhibitionForm').onsubmit=async(e)=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{await api('/exhibitions',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('会展已创建')}catch(err){toast(err.message)}};
$('#activityForm').onsubmit=async(e)=>{e.preventDefault();const form=new FormData(e.target);const data=Object.fromEntries(form);data.published=form.has('published');data.tagIds=form.getAll('tagIds');const typed=(data.imageUrls||'').split('\n').map(x=>x.trim()).filter(Boolean);data.endsAt=data.endsAt||null;try{const uploaded=await uploadImages(e.target.elements.images.files);data.imageUrls=uploaded.concat(typed).slice(0,9);delete data.images;await api('/activities',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('活动已创建')}catch(err){toast(err.message)}};
if(state.token)load();
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CoffeeChat 管理后台</title><link rel="stylesheet" href="/admin/style.css"></head>
<body><main>
<header><div><span class="eyebrow">COFFEECHAT CONSOLE</span><h1>让每一次会面都有意义</h1></div><button id="logout" class="ghost">退出</button></header>
<section id="login" class="card login"><h2>管理员登录</h2><p>输入部署时配置的管理令牌。</p><form id="loginForm"><input id="token" type="password" placeholder="ADMIN_TOKEN" required><button>进入后台</button></form></section>
<div id="app" hidden>
<section class="stats"><article><b id="exhibitionCount">0</b><span>会展</span></article><article><b id="activityCount">0</b><span>活动</span></article><article><b id="pendingCount">0</b><span>待审核群</span></article></section>
<nav><button data-tab="activities" class="active">活动</button><button data-tab="exhibitions">会展</button><button data-tab="groups">群审核</button></nav>
<section id="activities" class="panel"><div class="section-title"><div><h2>活动管理</h2><p>创建活动并决定何时在小程序发布</p></div><button data-open="activityDialog">新建活动</button></div><div id="activityList" class="list"></div></section>
<section id="exhibitions" class="panel" hidden><div class="section-title"><div><h2>会展管理</h2><p>活动必须归属于一个会展</p></div><button data-open="exhibitionDialog">新建会展</button></div><div id="exhibitionList" class="list"></div></section>
<section id="groups" class="panel" hidden><div class="section-title"><div><h2>微信群审核</h2><p>确认群名称和二维码后再向参与者展示</p></div></div><div id="groupList" class="list"></div></section>
</div>
</main>
<dialog id="exhibitionDialog"><form id="exhibitionForm"><button type="button" class="close">×</button><h2>新建会展</h2><label>名称<input name="name" required></label><label>场馆<input name="venue" required></label><label>地址<input name="address"></label><div class="grid"><label>开始时间<input name="startsAt" type="datetime-local" required></label><label>结束时间<input name="endsAt" type="datetime-local" required></label></div><label>描述<textarea name="description" rows="5"></textarea></label><label>封面图 URL<input name="coverUrl" type="url"></label><button>保存会展</button></form></dialog>
<dialog id="activityDialog"><form id="activityForm"><button type="button" class="close">×</button><h2>新建活动</h2><label>所属会展<select name="exhibitionId" id="exhibitionSelect" required></select></label><label>标题<input name="title" required></label><label>地点<input name="location"></label><div class="grid"><label>开始时间<input name="startsAt" type="datetime-local" required></label><label>结束时间<input name="endsAt" type="datetime-local"></label></div><label>活动描述<textarea name="description" rows="6"></textarea></label><label>活动图片(最多 9 张)<input name="images" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple></label><label>也可填写图片 URL(每行一张)<textarea name="imageUrls" rows="3"></textarea></label><label>标签<div id="tagChecks" class="checks"></div></label><label class="inline"><input name="published" type="checkbox"> 立即发布</label><button>保存活动</button></form></dialog>
<div id="toast"></div><script src="/admin/app.js"></script></body></html>
+2
View File
@@ -0,0 +1,2 @@
:root{font-family:Inter,"PingFang SC",sans-serif;color:#241f1b;background:#f4efe8;font-size:16px}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#eadbca,transparent 38%),#f4efe8}main{max-width:1100px;margin:auto;padding:40px 24px 80px}header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:34px}h1{font-family:Georgia,"Songti SC",serif;font-size:42px;line-height:1.1;margin:8px 0}.eyebrow{font-size:12px;letter-spacing:3px;color:#8a6750}button{border:0;border-radius:999px;padding:12px 22px;background:#2e5b47;color:white;font-weight:600;cursor:pointer}.ghost{background:transparent;color:#684c3a;border:1px solid #bca895}.card,.panel{background:#fffdf9;border:1px solid #e3d8ca;border-radius:24px;box-shadow:0 16px 50px #604a3420}.login{max-width:480px;padding:34px}.login form{display:flex;gap:10px}.login input{flex:1}input,textarea,select{width:100%;padding:12px 14px;border:1px solid #d9ccbd;border-radius:12px;background:#fff;font:inherit}textarea{resize:vertical}label{display:block;margin:14px 0;color:#705c4c;font-size:14px}label input,label textarea,label select{margin-top:7px;color:#241f1b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:26px}.stats article{padding:24px;border-radius:20px;background:#2e5b47;color:#fff}.stats b{font:36px Georgia}.stats span{display:block;opacity:.75;margin-top:5px}nav{display:flex;gap:8px;margin-bottom:14px}nav button{background:transparent;color:#705c4c}nav button.active{background:#2e5b47;color:#fff}.panel{padding:28px}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.section-title h2{margin:0}.section-title p{margin:6px 0;color:#8f7d6e}.list{display:grid;gap:10px}.row{display:flex;align-items:center;justify-content:space-between;padding:16px;border-radius:14px;background:#f8f4ee}.row h3{font-size:16px;margin:0 0 5px}.row p{margin:0;color:#847365;font-size:13px}.row-actions{display:flex;gap:8px}.row-actions button{padding:8px 13px;font-size:12px}.reject{background:#a74b3f}.qr{width:64px;height:64px;object-fit:cover;border-radius:10px;margin-right:14px}.group-main{display:flex;align-items:center}dialog{border:0;border-radius:24px;padding:0;box-shadow:0 24px 90px #241f1b55;width:min(620px,calc(100% - 32px))}dialog::backdrop{background:#241f1b88}dialog form{padding:32px;position:relative}.close{position:absolute;right:20px;top:18px;background:transparent;color:#705c4c;font-size:24px;padding:5px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.checks{display:flex;flex-wrap:wrap;gap:8px}.checks label,.inline{display:flex;align-items:center;gap:6px}.checks input,.inline input{width:auto;margin:0}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#241f1b;color:#fff;padding:12px 20px;border-radius:99px;display:none}@media(max-width:620px){h1{font-size:32px}.stats{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.section-title,.row{align-items:flex-start;gap:12px}.row{flex-direction:column}.login form{flex-direction:column}}
+28
View File
@@ -0,0 +1,28 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import fs from 'node:fs';
import path from 'node:path';
import { createAuth } from './lib/auth.js';
import { authRoutes } from './routes/auth.js';
import { catalogRoutes } from './routes/catalog.js';
import { participationRoutes } from './routes/participations.js';
import { adminRoutes } from './routes/admin.js';
import { uploadRoutes } from './routes/uploads.js';
export function createApp({ db, config }) {
fs.mkdirSync(config.uploadDir, { recursive: true });
const app = express(); const auth = createAuth(config);
app.use(helmet({ contentSecurityPolicy: false })); app.use(cors()); app.use(express.json({ limit: '1mb' }));
app.get('/health', async (_req, res) => { await db.query('SELECT 1'); res.json({ ok: true }); });
app.use('/uploads', express.static(config.uploadDir, { maxAge: '7d' }));
app.use('/admin', express.static(new URL('../public/admin', import.meta.url).pathname));
app.use('/api/auth', authRoutes({ db, config, auth }));
app.use('/api/activities', catalogRoutes({ db }));
app.use('/api/activities/:activityId/participations', participationRoutes({ db, auth }));
app.use('/api/uploads', uploadRoutes({ config, auth }));
app.use('/api/admin', adminRoutes({ db, auth }));
app.use((error, _req, res, _next) => { console.error(error); res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 500).json({ error: '服务器暂时无法处理请求' }); });
return app;
}
+22
View File
@@ -0,0 +1,22 @@
const requiredInProduction = ['DATABASE_URL', 'JWT_SECRET', 'WECHAT_APP_ID', 'WECHAT_APP_SECRET', 'ADMIN_TOKEN'];
export function loadConfig(env = process.env) {
const production = env.NODE_ENV === 'production';
if (production) {
const missing = requiredInProduction.filter((key) => !env[key]);
if (missing.length) throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}
return {
production,
port: Number(env.PORT || 3000),
databaseUrl: env.DATABASE_URL || 'postgres://coffeechat:coffeechat@localhost:5432/coffeechat',
jwtSecret: env.JWT_SECRET || 'development-only-secret-change-me',
wechatAppId: env.WECHAT_APP_ID || '',
wechatAppSecret: env.WECHAT_APP_SECRET || '',
adminToken: env.ADMIN_TOKEN || 'development-admin-token',
publicBaseUrl: (env.PUBLIC_BASE_URL || `http://localhost:${env.PORT || 3000}`).replace(/\/$/, ''),
uploadDir: env.UPLOAD_DIR || new URL('../uploads', import.meta.url).pathname,
allowDevLogin: !production && env.ALLOW_DEV_LOGIN !== 'false'
};
}
+15
View File
@@ -0,0 +1,15 @@
import pg from 'pg';
export function createDb(connectionString) {
const pool = new pg.Pool({ connectionString });
return {
query: (query, params) => pool.query(query, params),
async transaction(work) {
const client = await pool.connect();
try { await client.query('BEGIN'); const result = await work(client); await client.query('COMMIT'); return result; }
catch (error) { await client.query('ROLLBACK'); throw error; }
finally { client.release(); }
},
close: () => pool.end()
};
}
+9
View File
@@ -0,0 +1,9 @@
import { loadConfig } from './config.js';
import { createDb } from './db.js';
import { createApp } from './app.js';
const config = loadConfig(); const db = createDb(config.databaseUrl); const app = createApp({ db, config });
const server = app.listen(config.port, '0.0.0.0', () => console.log(`CoffeeChat API listening on ${config.port}`));
async function shutdown() { server.close(); await db.close(); }
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
+16
View File
@@ -0,0 +1,16 @@
export function activityStatus(startsAt, endsAt, now = new Date()) {
const start = new Date(startsAt);
const end = endsAt ? new Date(endsAt) : start;
if (start > now) return 'upcoming';
if (end >= now) return 'active';
return 'ended';
}
export function normalizeActivity(row, now = new Date()) {
return {
...row,
status: activityStatus(row.startsAt, row.endsAt, now),
images: row.images || [], tags: row.tags || [], participantCount: Number(row.participantCount || 0)
};
}
+18
View File
@@ -0,0 +1,18 @@
import jwt from 'jsonwebtoken';
export function createAuth(config) {
const sign = (user) => jwt.sign({ sub: user.id, openid: user.openid }, config.jwtSecret, { expiresIn: '30d' });
function userRequired(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
if (!token) return res.status(401).json({ error: '请先登录' });
try { req.user = jwt.verify(token, config.jwtSecret); next(); }
catch { res.status(401).json({ error: '登录已过期,请重新登录' }); }
}
function adminRequired(req, res, next) {
const token = req.headers['x-admin-token'];
if (!token || token !== config.adminToken) return res.status(401).json({ error: '管理员凭据无效' });
next();
}
return { sign, userRequired, adminRequired };
}
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
export const loginSchema = z.object({ code: z.string().min(1), nickname: z.string().trim().max(40).optional(), avatarUrl: z.string().url().optional().or(z.literal('')) });
export const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), avatarUrl: z.string().url().optional().or(z.literal('')) });
export const purposeSchema = z.object({ purpose: z.string().trim().max(300).default('') });
export const groupSchema = z.object({ name: z.string().trim().min(1).max(80), qrImageUrl: z.string().url(), note: z.string().trim().max(300).default('') });
export const exhibitionSchema = z.object({
name: z.string().trim().min(1).max(120), venue: z.string().trim().min(1).max(120), address: z.string().trim().max(300).default(''),
startsAt: z.coerce.date(), endsAt: z.coerce.date(), description: z.string().trim().max(5000).default(''), coverUrl: z.string().url().optional().or(z.literal(''))
}).refine((v) => v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' });
export const activitySchema = z.object({
exhibitionId: z.string().uuid(), title: z.string().trim().min(1).max(120), description: z.string().trim().max(10000).default(''),
location: z.string().trim().max(200).default(''), startsAt: z.coerce.date(), endsAt: z.coerce.date().nullable().optional(),
capacity: z.coerce.number().int().positive().nullable().optional(), published: z.boolean().default(false),
imageUrls: z.array(z.string().url()).max(9).default([]), tagIds: z.array(z.string().uuid()).max(10).default([])
}).refine((v) => !v.endsAt || v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' });
export function parse(schema, req, res) {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.issues[0]?.message || '请求参数错误' });
return null;
}
return result.data;
}
+12
View File
@@ -0,0 +1,12 @@
export async function exchangeCode({ code, appId, appSecret, fetchImpl = fetch }) {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.searchParams.set('appid', appId);
url.searchParams.set('secret', appSecret);
url.searchParams.set('js_code', code);
url.searchParams.set('grant_type', 'authorization_code');
const response = await fetchImpl(url);
const data = await response.json();
if (!response.ok || data.errcode || !data.openid) throw new Error(data.errmsg || '微信登录失败');
return data;
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import { activitySchema, exhibitionSchema, parse } from '../lib/validation.js';
export function adminRoutes({ db, auth }) {
const router = Router();
router.use(auth.adminRequired);
router.get('/dashboard', async (_req, res) => {
const [exhibitions, activities, groups, tags] = await Promise.all([
db.query('SELECT id,name,venue,starts_at AS "startsAt",ends_at AS "endsAt" FROM exhibitions ORDER BY starts_at DESC'),
db.query(`SELECT a.id,a.title,a.starts_at AS "startsAt",a.published,e.name AS "exhibitionName" FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id ORDER BY a.starts_at DESC`),
db.query(`SELECT g.id,g.name,g.qr_image_url AS "qrImageUrl",g.note,g.status,g.created_at AS "createdAt",a.title AS "activityTitle",u.nickname AS "submitterName" FROM activity_groups g JOIN activities a ON a.id=g.activity_id LEFT JOIN users u ON u.id=g.submitted_by ORDER BY g.created_at DESC`),
db.query('SELECT id,name,color FROM tags ORDER BY name')
]);
res.json({ exhibitions: exhibitions.rows, activities: activities.rows, groups: groups.rows, tags: tags.rows });
});
router.post('/exhibitions', async (req, res) => {
const body = parse(exhibitionSchema, req, res); if (!body) return;
const { rows: [item] } = await db.query(
`INSERT INTO exhibitions(name,venue,address,starts_at,ends_at,description,cover_url) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
[body.name, body.venue, body.address, body.startsAt, body.endsAt, body.description, body.coverUrl || null]
);
res.status(201).json(item);
});
router.post('/activities', async (req, res) => {
const body = parse(activitySchema, req, res); if (!body) return;
const item = await db.transaction(async (client) => {
const { rows: [created] } = await client.query(
`INSERT INTO activities(exhibition_id,title,description,location,starts_at,ends_at,capacity,published) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[body.exhibitionId, body.title, body.description, body.location, body.startsAt, body.endsAt || null, body.capacity || null, body.published]
);
for (const [index, url] of body.imageUrls.entries()) await client.query('INSERT INTO activity_images(activity_id,url,sort_order) VALUES($1,$2,$3)', [created.id, url, index]);
for (const tagId of body.tagIds) await client.query('INSERT INTO activity_tags(activity_id,tag_id) VALUES($1,$2)', [created.id, tagId]);
return created;
});
res.status(201).json(item);
});
router.patch('/activities/:id/publish', async (req, res) => {
const published = req.body.published === true;
const { rows: [item] } = await db.query('UPDATE activities SET published=$2,updated_at=now() WHERE id=$1 RETURNING id,published', [req.params.id, published]);
if (!item) return res.status(404).json({ error: '活动不存在' });
res.json(item);
});
router.patch('/groups/:id/review', async (req, res) => {
if (!['approved', 'rejected'].includes(req.body.status)) return res.status(400).json({ error: '审核状态无效' });
const { rows: [item] } = await db.query(
`UPDATE activity_groups SET status=$2,review_note=$3,reviewed_at=now() WHERE id=$1 RETURNING id,status`,
[req.params.id, req.body.status, String(req.body.reviewNote || '').slice(0, 300)]
);
if (!item) return res.status(404).json({ error: '群资料不存在' });
res.json(item);
});
return router;
}
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import { exchangeCode } from '../lib/wechat.js';
import { loginSchema, parse, profileSchema } from '../lib/validation.js';
export function authRoutes({ db, config, auth }) {
const router = Router();
router.post('/wechat', async (req, res) => {
const body = parse(loginSchema, req, res); if (!body) return;
try {
let session;
if (config.allowDevLogin && body.code.startsWith('dev:')) session = { openid: body.code, unionid: null };
else session = await exchangeCode({ code: body.code, appId: config.wechatAppId, appSecret: config.wechatAppSecret });
const { rows: [user] } = await db.query(
`INSERT INTO users (openid, unionid, nickname, avatar_url) VALUES ($1,$2,$3,$4)
ON CONFLICT (openid) DO UPDATE SET unionid=COALESCE(EXCLUDED.unionid,users.unionid),
nickname=CASE WHEN EXCLUDED.nickname <> '微信用户' THEN EXCLUDED.nickname ELSE users.nickname END,
avatar_url=COALESCE(NULLIF(EXCLUDED.avatar_url,''),users.avatar_url), updated_at=now()
RETURNING id, nickname, avatar_url AS "avatarUrl", created_at AS "createdAt"`,
[session.openid, session.unionid || null, body.nickname || '微信用户', body.avatarUrl || null]
);
res.json({ token: auth.sign({ id: user.id, openid: session.openid }), user });
} catch (error) { res.status(401).json({ error: error.message }); }
});
router.get('/me', auth.userRequired, async (req, res) => {
const { rows: [user] } = await db.query('SELECT id,nickname,avatar_url AS "avatarUrl",created_at AS "createdAt" FROM users WHERE id=$1', [req.user.sub]);
if (!user) return res.status(404).json({ error: '用户不存在' });
res.json(user);
});
router.patch('/me', auth.userRequired, async (req, res) => {
const body = parse(profileSchema, req, res); if (!body) return;
const { rows: [user] } = await db.query(
'UPDATE users SET nickname=$2,avatar_url=$3,updated_at=now() WHERE id=$1 RETURNING id,nickname,avatar_url AS "avatarUrl"',
[req.user.sub, body.nickname, body.avatarUrl || null]
);
res.json(user);
});
return router;
}
+44
View File
@@ -0,0 +1,44 @@
import { Router } from 'express';
import { normalizeActivity } from '../lib/activities.js';
const activitySelect = `
SELECT a.id,a.title,a.description,a.location,a.starts_at AS "startsAt",a.ends_at AS "endsAt",a.capacity,
json_build_object('id',e.id,'name',e.name,'venue',e.venue,'address',e.address,'startsAt',e.starts_at,'endsAt',e.ends_at,'description',e.description,'coverUrl',e.cover_url) exhibition,
COALESCE((SELECT json_agg(json_build_object('id',i.id,'url',i.url) ORDER BY i.sort_order) FROM activity_images i WHERE i.activity_id=a.id),'[]') images,
COALESCE((SELECT json_agg(json_build_object('id',t.id,'name',t.name,'color',t.color)) FROM activity_tags at JOIN tags t ON t.id=at.tag_id WHERE at.activity_id=a.id),'[]') tags,
(SELECT count(*) FROM participations p WHERE p.activity_id=a.id) AS "participantCount"
FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id`;
export function catalogRoutes({ db }) {
const router = Router();
router.get('/meta', async (_req, res) => {
const [exhibitions, tags] = await Promise.all([
db.query('SELECT id,name,venue,address,starts_at AS "startsAt",ends_at AS "endsAt",description,cover_url AS "coverUrl" FROM exhibitions ORDER BY starts_at DESC'),
db.query('SELECT id,name,color FROM tags ORDER BY name')
]);
res.json({ exhibitions: exhibitions.rows, tags: tags.rows });
});
router.get('/', async (req, res) => {
const values = []; const where = ['a.published=true'];
if (req.query.exhibitionId) { values.push(req.query.exhibitionId); where.push(`a.exhibition_id=$${values.length}`); }
if (req.query.tagId) { values.push(req.query.tagId); where.push(`EXISTS (SELECT 1 FROM activity_tags x WHERE x.activity_id=a.id AND x.tag_id=$${values.length})`); }
if (req.query.time === 'upcoming') where.push('a.starts_at > now()');
if (req.query.time === 'available') where.push('COALESCE(a.ends_at,a.starts_at) >= now()');
const { rows } = await db.query(`${activitySelect} WHERE ${where.join(' AND ')} ORDER BY a.starts_at ASC`, values);
res.json(rows.map((row) => normalizeActivity(row)));
});
router.get('/:id', async (req, res) => {
const { rows: [row] } = await db.query(`${activitySelect} WHERE a.id=$1 AND a.published=true`, [req.params.id]);
if (!row) return res.status(404).json({ error: '活动不存在' });
const participants = await db.query(
`SELECT u.id,u.nickname,u.avatar_url AS "avatarUrl",p.purpose,p.joined_at AS "joinedAt"
FROM participations p JOIN users u ON u.id=p.user_id WHERE p.activity_id=$1 ORDER BY p.joined_at`, [req.params.id]
);
const groups = await db.query(
`SELECT id,name,qr_image_url AS "qrImageUrl",note FROM activity_groups WHERE activity_id=$1 AND status='approved' ORDER BY created_at`, [req.params.id]
);
res.json({ ...normalizeActivity(row), participants: participants.rows, groups: groups.rows });
});
return router;
}
+33
View File
@@ -0,0 +1,33 @@
import { Router } from 'express';
import { groupSchema, parse, purposeSchema } from '../lib/validation.js';
export function participationRoutes({ db, auth }) {
const router = Router({ mergeParams: true });
router.put('/me', auth.userRequired, async (req, res) => {
const body = parse(purposeSchema, req, res); if (!body) return;
const capacity = await db.query(`SELECT capacity,(SELECT count(*) FROM participations WHERE activity_id=$1) count FROM activities WHERE id=$1 AND published=true`, [req.params.activityId]);
if (!capacity.rows[0]) return res.status(404).json({ error: '活动不存在' });
const existing = await db.query('SELECT 1 FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]);
if (!existing.rowCount && capacity.rows[0].capacity && Number(capacity.rows[0].count) >= capacity.rows[0].capacity) return res.status(409).json({ error: '活动人数已满' });
const { rows: [item] } = await db.query(
`INSERT INTO participations(activity_id,user_id,purpose) VALUES($1,$2,$3)
ON CONFLICT(activity_id,user_id) DO UPDATE SET purpose=EXCLUDED.purpose,updated_at=now()
RETURNING activity_id AS "activityId",purpose,joined_at AS "joinedAt"`, [req.params.activityId, req.user.sub, body.purpose]
);
res.json(item);
});
router.delete('/me', auth.userRequired, async (req, res) => {
await db.query('DELETE FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]);
res.status(204).end();
});
router.post('/groups', auth.userRequired, async (req, res) => {
const body = parse(groupSchema, req, res); if (!body) return;
const { rows: [group] } = await db.query(
`INSERT INTO activity_groups(activity_id,submitted_by,name,qr_image_url,note) VALUES($1,$2,$3,$4,$5)
RETURNING id,status,created_at AS "createdAt"`, [req.params.activityId, req.user.sub, body.name, body.qrImageUrl, body.note]
);
res.status(201).json(group);
});
return router;
}
+21
View File
@@ -0,0 +1,21 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
function uploader(uploadDir) {
const storage = multer.diskStorage({
destination: uploadDir,
filename: (_req, file, done) => done(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase()}`)
});
return multer({ storage, limits: { fileSize: 8 * 1024 * 1024, files: 9 }, fileFilter: (_req, file, done) => done(null, /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype)) });
}
export function uploadRoutes({ config, auth }) {
const router = Router(); const upload = uploader(config.uploadDir);
const response = (req, res) => res.status(201).json({ files: req.files.map((file) => ({ url: `${config.publicBaseUrl}/uploads/${file.filename}` })) });
router.post('/', auth.userRequired, upload.array('files', 9), response);
router.post('/admin', auth.adminRequired, upload.array('files', 9), response);
return router;
}
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { activityStatus, normalizeActivity } from '../src/lib/activities.js';
test('activityStatus distinguishes upcoming, active and ended', () => {
const now = new Date('2026-09-21T12:00:00Z');
assert.equal(activityStatus('2026-09-22T12:00:00Z', null, now), 'upcoming');
assert.equal(activityStatus('2026-09-21T11:00:00Z', '2026-09-21T13:00:00Z', now), 'active');
assert.equal(activityStatus('2026-09-20T11:00:00Z', '2026-09-20T13:00:00Z', now), 'ended');
});
test('normalizeActivity supplies stable arrays and numeric count', () => {
const value = normalizeActivity({ startsAt: '2026-09-22T12:00:00Z', participantCount: '3' }, new Date('2026-09-21T12:00:00Z'));
assert.deepEqual(value.images, []); assert.deepEqual(value.tags, []); assert.equal(value.participantCount, 3);
});
+7
View File
@@ -0,0 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
test('production rejects missing secrets', () => assert.throws(() => loadConfig({ NODE_ENV: 'production' }), /Missing environment variables/));
test('development permits local defaults', () => assert.equal(loadConfig({ NODE_ENV: 'development' }).allowDevLogin, true));
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { exchangeCode } from '../src/lib/wechat.js';
test('exchangeCode returns WeChat session', async () => {
const session = await exchangeCode({ code: 'abc', appId: 'id', appSecret: 'secret', fetchImpl: async (url) => {
assert.equal(url.searchParams.get('js_code'), 'abc'); return { ok: true, json: async () => ({ openid: 'openid-1' }) };
}});
assert.equal(session.openid, 'openid-1');
});
test('exchangeCode rejects WeChat API errors', async () => {
await assert.rejects(exchangeCode({ code: 'bad', appId: 'id', appSecret: 'secret', fetchImpl: async () => ({ ok: true, json: async () => ({ errcode: 40029, errmsg: 'invalid code' }) }) }), /invalid code/);
});

Some files were not shown because too many files have changed in this diff Show More