Update dependencies, enhance match view with QR code functionality, and improve user login process

This commit is contained in:
ethanfly 2026-01-20 09:23:30 +08:00
parent 68eb2b6d93
commit 824334907d
46 changed files with 3393 additions and 247 deletions

View File

@ -2,301 +2,307 @@
"hash": "72015d08",
"configHash": "0bd4dba1",
"lockfileHash": "39601b45",
"browserHash": "a4d88a8a",
"browserHash": "b4faa0d4",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "7f008ba6",
"fileHash": "a0465eac",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "3f18dca8",
"fileHash": "5dd68ace",
"needsInterop": false
},
"dayjs": {
"src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "58ba4ccf",
"fileHash": "43487a3d",
"needsInterop": true
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "3f46101b",
"fileHash": "641ecf31",
"needsInterop": false
},
"element-plus/dist/locale/zh-cn.mjs": {
"src": "../../element-plus/dist/locale/zh-cn.mjs",
"file": "element-plus_dist_locale_zh-cn__mjs.js",
"fileHash": "7c78b45c",
"fileHash": "c69676ce",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "af5f9503",
"fileHash": "bfa2bd84",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "8671a44a",
"fileHash": "95941b62",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "e5335c2b",
"fileHash": "799d6557",
"needsInterop": false
},
"element-plus/es": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus_es.js",
"fileHash": "294409a4",
"fileHash": "bf7435a6",
"needsInterop": false
},
"element-plus/es/components/base/style/css": {
"src": "../../element-plus/es/components/base/style/css.mjs",
"file": "element-plus_es_components_base_style_css.js",
"fileHash": "6f0ffffd",
"fileHash": "69992cb3",
"needsInterop": false
},
"element-plus/es/components/form/style/css": {
"src": "../../element-plus/es/components/form/style/css.mjs",
"file": "element-plus_es_components_form_style_css.js",
"fileHash": "db280542",
"fileHash": "ebec77cf",
"needsInterop": false
},
"element-plus/es/components/button/style/css": {
"src": "../../element-plus/es/components/button/style/css.mjs",
"file": "element-plus_es_components_button_style_css.js",
"fileHash": "647ba0c4",
"fileHash": "911a2184",
"needsInterop": false
},
"element-plus/es/components/form-item/style/css": {
"src": "../../element-plus/es/components/form-item/style/css.mjs",
"file": "element-plus_es_components_form-item_style_css.js",
"fileHash": "df94624f",
"fileHash": "36203619",
"needsInterop": false
},
"element-plus/es/components/input/style/css": {
"src": "../../element-plus/es/components/input/style/css.mjs",
"file": "element-plus_es_components_input_style_css.js",
"fileHash": "6ce4b79b",
"fileHash": "3cb1f96d",
"needsInterop": false
},
"element-plus/es/components/dialog/style/css": {
"src": "../../element-plus/es/components/dialog/style/css.mjs",
"file": "element-plus_es_components_dialog_style_css.js",
"fileHash": "09e38fa4",
"fileHash": "76e79a0a",
"needsInterop": false
},
"element-plus/es/components/container/style/css": {
"src": "../../element-plus/es/components/container/style/css.mjs",
"file": "element-plus_es_components_container_style_css.js",
"fileHash": "9f75a15b",
"fileHash": "3871ef81",
"needsInterop": false
},
"element-plus/es/components/main/style/css": {
"src": "../../element-plus/es/components/main/style/css.mjs",
"file": "element-plus_es_components_main_style_css.js",
"fileHash": "b7d52e31",
"fileHash": "3d6a71ca",
"needsInterop": false
},
"element-plus/es/components/header/style/css": {
"src": "../../element-plus/es/components/header/style/css.mjs",
"file": "element-plus_es_components_header_style_css.js",
"fileHash": "dc7cbd51",
"fileHash": "88603798",
"needsInterop": false
},
"element-plus/es/components/dropdown/style/css": {
"src": "../../element-plus/es/components/dropdown/style/css.mjs",
"file": "element-plus_es_components_dropdown_style_css.js",
"fileHash": "c25ab9b5",
"fileHash": "9e766544",
"needsInterop": false
},
"element-plus/es/components/dropdown-menu/style/css": {
"src": "../../element-plus/es/components/dropdown-menu/style/css.mjs",
"file": "element-plus_es_components_dropdown-menu_style_css.js",
"fileHash": "36878229",
"fileHash": "b8031298",
"needsInterop": false
},
"element-plus/es/components/dropdown-item/style/css": {
"src": "../../element-plus/es/components/dropdown-item/style/css.mjs",
"file": "element-plus_es_components_dropdown-item_style_css.js",
"fileHash": "3f098ecd",
"fileHash": "a1a95087",
"needsInterop": false
},
"element-plus/es/components/avatar/style/css": {
"src": "../../element-plus/es/components/avatar/style/css.mjs",
"file": "element-plus_es_components_avatar_style_css.js",
"fileHash": "a302ad09",
"fileHash": "9aca49c4",
"needsInterop": false
},
"element-plus/es/components/breadcrumb/style/css": {
"src": "../../element-plus/es/components/breadcrumb/style/css.mjs",
"file": "element-plus_es_components_breadcrumb_style_css.js",
"fileHash": "630e60e3",
"fileHash": "3bc001aa",
"needsInterop": false
},
"element-plus/es/components/breadcrumb-item/style/css": {
"src": "../../element-plus/es/components/breadcrumb-item/style/css.mjs",
"file": "element-plus_es_components_breadcrumb-item_style_css.js",
"fileHash": "3d33f916",
"fileHash": "be124fe0",
"needsInterop": false
},
"element-plus/es/components/aside/style/css": {
"src": "../../element-plus/es/components/aside/style/css.mjs",
"file": "element-plus_es_components_aside_style_css.js",
"fileHash": "52b6f7bc",
"fileHash": "c068ded5",
"needsInterop": false
},
"element-plus/es/components/menu/style/css": {
"src": "../../element-plus/es/components/menu/style/css.mjs",
"file": "element-plus_es_components_menu_style_css.js",
"fileHash": "77e7329d",
"fileHash": "403f5873",
"needsInterop": false
},
"element-plus/es/components/menu-item/style/css": {
"src": "../../element-plus/es/components/menu-item/style/css.mjs",
"file": "element-plus_es_components_menu-item_style_css.js",
"fileHash": "ae2a2097",
"fileHash": "27234326",
"needsInterop": false
},
"element-plus/es/components/icon/style/css": {
"src": "../../element-plus/es/components/icon/style/css.mjs",
"file": "element-plus_es_components_icon_style_css.js",
"fileHash": "c16e8e96",
"fileHash": "235345a9",
"needsInterop": false
},
"element-plus/es/components/input-number/style/css": {
"src": "../../element-plus/es/components/input-number/style/css.mjs",
"file": "element-plus_es_components_input-number_style_css.js",
"fileHash": "0ef981fb",
"fileHash": "9b08cd36",
"needsInterop": false
},
"element-plus/es/components/tag/style/css": {
"src": "../../element-plus/es/components/tag/style/css.mjs",
"file": "element-plus_es_components_tag_style_css.js",
"fileHash": "7268bcfa",
"fileHash": "9f589faf",
"needsInterop": false
},
"element-plus/es/components/row/style/css": {
"src": "../../element-plus/es/components/row/style/css.mjs",
"file": "element-plus_es_components_row_style_css.js",
"fileHash": "3ebd4979",
"fileHash": "638d7e8c",
"needsInterop": false
},
"element-plus/es/components/col/style/css": {
"src": "../../element-plus/es/components/col/style/css.mjs",
"file": "element-plus_es_components_col_style_css.js",
"fileHash": "2d165654",
"fileHash": "bb1da678",
"needsInterop": false
},
"element-plus/es/components/loading/style/css": {
"src": "../../element-plus/es/components/loading/style/css.mjs",
"file": "element-plus_es_components_loading_style_css.js",
"fileHash": "88f7c147",
"fileHash": "eef180be",
"needsInterop": false
},
"element-plus/es/components/descriptions/style/css": {
"src": "../../element-plus/es/components/descriptions/style/css.mjs",
"file": "element-plus_es_components_descriptions_style_css.js",
"fileHash": "ce70f737",
"fileHash": "4cb57571",
"needsInterop": false
},
"element-plus/es/components/descriptions-item/style/css": {
"src": "../../element-plus/es/components/descriptions-item/style/css.mjs",
"file": "element-plus_es_components_descriptions-item_style_css.js",
"fileHash": "8dd7f2f1",
"fileHash": "0f719728",
"needsInterop": false
},
"element-plus/es/components/pagination/style/css": {
"src": "../../element-plus/es/components/pagination/style/css.mjs",
"file": "element-plus_es_components_pagination_style_css.js",
"fileHash": "62918e26",
"fileHash": "42e6236a",
"needsInterop": false
},
"element-plus/es/components/table/style/css": {
"src": "../../element-plus/es/components/table/style/css.mjs",
"file": "element-plus_es_components_table_style_css.js",
"fileHash": "d66cccc2",
"fileHash": "20763031",
"needsInterop": false
},
"element-plus/es/components/table-column/style/css": {
"src": "../../element-plus/es/components/table-column/style/css.mjs",
"file": "element-plus_es_components_table-column_style_css.js",
"fileHash": "4b9e1b7d",
"fileHash": "2b02ba24",
"needsInterop": false
},
"element-plus/es/components/select/style/css": {
"src": "../../element-plus/es/components/select/style/css.mjs",
"file": "element-plus_es_components_select_style_css.js",
"fileHash": "2b082cb1",
"fileHash": "06a565f3",
"needsInterop": false
},
"element-plus/es/components/option/style/css": {
"src": "../../element-plus/es/components/option/style/css.mjs",
"file": "element-plus_es_components_option_style_css.js",
"fileHash": "dcd074a6",
"fileHash": "0c0e3f42",
"needsInterop": false
},
"element-plus/es/components/switch/style/css": {
"src": "../../element-plus/es/components/switch/style/css.mjs",
"file": "element-plus_es_components_switch_style_css.js",
"fileHash": "eb467983",
"fileHash": "b8c8ea9a",
"needsInterop": false
},
"element-plus/es/components/radio-group/style/css": {
"src": "../../element-plus/es/components/radio-group/style/css.mjs",
"file": "element-plus_es_components_radio-group_style_css.js",
"fileHash": "5557d38c",
"fileHash": "4174369f",
"needsInterop": false
},
"element-plus/es/components/radio/style/css": {
"src": "../../element-plus/es/components/radio/style/css.mjs",
"file": "element-plus_es_components_radio_style_css.js",
"fileHash": "98bc52ba",
"fileHash": "3b71271e",
"needsInterop": false
},
"element-plus/es/components/upload/style/css": {
"src": "../../element-plus/es/components/upload/style/css.mjs",
"file": "element-plus_es_components_upload_style_css.js",
"fileHash": "6b2b7aff",
"fileHash": "e7cad837",
"needsInterop": false
},
"element-plus/es/components/image/style/css": {
"src": "../../element-plus/es/components/image/style/css.mjs",
"file": "element-plus_es_components_image_style_css.js",
"fileHash": "7e74c215",
"fileHash": "4e0a87a4",
"needsInterop": false
},
"element-plus/es/components/divider/style/css": {
"src": "../../element-plus/es/components/divider/style/css.mjs",
"file": "element-plus_es_components_divider_style_css.js",
"fileHash": "6356af7f",
"fileHash": "c846d7f7",
"needsInterop": false
},
"element-plus/es/components/text/style/css": {
"src": "../../element-plus/es/components/text/style/css.mjs",
"file": "element-plus_es_components_text_style_css.js",
"fileHash": "591aafc3",
"fileHash": "3e8a90d0",
"needsInterop": false
},
"element-plus/es/components/collapse/style/css": {
"src": "../../element-plus/es/components/collapse/style/css.mjs",
"file": "element-plus_es_components_collapse_style_css.js",
"fileHash": "7310bb21",
"fileHash": "51560080",
"needsInterop": false
},
"element-plus/es/components/collapse-item/style/css": {
"src": "../../element-plus/es/components/collapse-item/style/css.mjs",
"file": "element-plus_es_components_collapse-item_style_css.js",
"fileHash": "27e07e55",
"fileHash": "94bca0a2",
"needsInterop": false
},
"qrcode": {
"src": "../../qrcode/lib/browser.js",
"file": "qrcode.js",
"fileHash": "ad0f2956",
"needsInterop": true
}
},
"chunks": {
@ -321,6 +327,9 @@
"chunk-5KK3TTMN": {
"file": "chunk-5KK3TTMN.js"
},
"chunk-NKQWFVTF": {
"file": "chunk-NKQWFVTF.js"
},
"chunk-REWOA3VH": {
"file": "chunk-REWOA3VH.js"
},
@ -330,9 +339,6 @@
"chunk-SMFPDFTD": {
"file": "chunk-SMFPDFTD.js"
},
"chunk-NKQWFVTF": {
"file": "chunk-NKQWFVTF.js"
},
"chunk-IV6PSERC": {
"file": "chunk-IV6PSERC.js"
},

View File

@ -1,9 +1,9 @@
import "./chunk-75C4BP7B.js";
import "./chunk-UBLR4G7Q.js";
import "./chunk-5KK3TTMN.js";
import "./chunk-NKQWFVTF.js";
import "./chunk-REWOA3VH.js";
import "./chunk-TX5YLZ4O.js";
import "./chunk-NKQWFVTF.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/pagination/style/css.mjs

2083
admin/node_modules/.vite/deps/qrcode.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

7
admin/node_modules/.vite/deps/qrcode.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,7 @@
"echarts": "^5.4.3",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"qrcode": "^1.5.3",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},

View File

@ -9,21 +9,21 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"qrcode": "^1.5.3",
"dayjs": "^1.11.10"
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.10",
"sass": "^1.69.5",
"unplugin-auto-import": "^0.17.2",
"unplugin-vue-components": "^0.26.0"
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.10"
}
}

View File

@ -38,7 +38,14 @@
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="matchCode" label="比赛码" width="140" />
<el-table-column prop="matchCode" label="比赛码" width="160">
<template #default="{ row }">
<div class="match-code-cell" @click="showQrcode(row)">
<span class="code">{{ row.matchCode }}</span>
<el-icon class="qr-icon"><View /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="比赛名称" min-width="180" />
<el-table-column v-if="userStore.isSuperAdmin" prop="storeName" label="门店" width="140" />
<el-table-column prop="type" label="类型" width="100">
@ -110,14 +117,38 @@
<el-button type="primary" @click="handleCreate">创建</el-button>
</template>
</el-dialog>
<!-- 比赛二维码弹窗 -->
<el-dialog v-model="showQrcodeDialog" title="比赛二维码" width="400px" class="qrcode-dialog">
<div class="qrcode-content">
<div class="qrcode-box" ref="qrcodeRef"></div>
<div class="match-info">
<div class="match-name">{{ currentMatch?.name }}</div>
<div class="match-code">{{ currentMatch?.matchCode }}</div>
</div>
<div class="qrcode-tip">用户扫描二维码即可加入比赛</div>
</div>
<template #footer>
<el-button @click="downloadQrcode">
<el-icon><Download /></el-icon>
下载二维码
</el-button>
<el-button type="primary" @click="printQrcode">
<el-icon><Printer /></el-icon>
打印
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Download, Printer } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import QRCode from 'qrcode'
import { useUserStore } from '@/stores/user'
import { getMatches, createMatch, startMatch, endMatch, getStores } from '@/api/admin'
@ -130,6 +161,12 @@ const stores = ref([])
const searchForm = ref({ store_id: '', type: '', status: '' })
const pagination = ref({ page: 1, pageSize: 20, total: 0 })
//
const showQrcodeDialog = ref(false)
const currentMatch = ref(null)
const qrcodeRef = ref()
const qrcodeDataUrl = ref('')
const showCreateDialog = ref(false)
const createFormRef = ref()
const createForm = ref({
@ -224,8 +261,159 @@ const handleEnd = (row) => {
})
}
//
const showQrcode = async (row) => {
currentMatch.value = row
showQrcodeDialog.value = true
await nextTick()
//
try {
const canvas = document.createElement('canvas')
await QRCode.toCanvas(canvas, row.matchCode, {
width: 200,
margin: 2,
color: {
dark: '#1A1A2E',
light: '#FFFFFF'
}
})
// canvas
if (qrcodeRef.value) {
qrcodeRef.value.innerHTML = ''
qrcodeRef.value.appendChild(canvas)
}
// dataUrl
qrcodeDataUrl.value = canvas.toDataURL('image/png')
} catch (err) {
console.error('生成二维码失败:', err)
ElMessage.error('生成二维码失败')
}
}
//
const downloadQrcode = () => {
if (!qrcodeDataUrl.value) return
const link = document.createElement('a')
link.download = `比赛二维码_${currentMatch.value?.matchCode}.png`
link.href = qrcodeDataUrl.value
link.click()
}
//
const printQrcode = () => {
const printWindow = window.open('', '_blank')
if (!printWindow) {
ElMessage.warning('请允许弹出窗口以打印')
return
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>比赛二维码</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: sans-serif;
}
.qrcode { margin-bottom: 20px; }
.match-name { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.match-code { font-size: 20px; color: #FF6B35; margin-bottom: 10px; }
.tip { font-size: 14px; color: #666; }
</style>
</head>
<body>
<img class="qrcode" src="${qrcodeDataUrl.value}" width="200" height="200" />
<div class="match-name">${currentMatch.value?.name}</div>
<div class="match-code">${currentMatch.value?.matchCode}</div>
<div class="tip">扫描二维码加入比赛</div>
</body>
</html>
`)
printWindow.document.close()
printWindow.focus()
printWindow.print()
}
onMounted(() => {
fetchStores()
fetchData()
})
</script>
<style lang="scss" scoped>
.match-code-cell {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
color: var(--primary-color);
.qr-icon {
opacity: 1;
}
}
.code {
font-family: monospace;
font-weight: 500;
}
.qr-icon {
margin-left: 6px;
opacity: 0.5;
transition: opacity 0.2s;
}
}
.qrcode-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
.qrcode-box {
margin-bottom: 20px;
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.match-info {
text-align: center;
margin-bottom: 16px;
.match-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.match-code {
font-size: 20px;
font-weight: 700;
color: var(--primary-color);
font-family: monospace;
}
}
.qrcode-tip {
font-size: 14px;
color: var(--text-secondary);
}
}
</style>

View File

@ -1,212 +1,290 @@
const config = require("./config");
App({
globalData: {
userInfo: null,
token: null,
currentStore: null,
ladderUser: null,
wsConnected: false
wsConnected: false,
// 微信登录临时信息
wxLoginInfo: null,
// 从配置文件读取
baseUrl: config.baseUrl,
wsUrl: config.wsUrl,
},
onLaunch() {
// 从本地存储读取token
const token = wx.getStorageSync('token')
const token = wx.getStorageSync("token");
if (token) {
this.globalData.token = token
this.getUserInfo()
this.globalData.token = token;
this.getUserInfo();
}
},
// 登录
login() {
// 微信登录第一步获取openid和session_key
wxLogin() {
return new Promise((resolve, reject) => {
wx.login({
success: res => {
success: (res) => {
wx.request({
url: `${this.globalData.baseUrl}/api/user/login`,
method: 'POST',
method: "POST",
data: { code: res.code },
success: loginRes => {
success: (loginRes) => {
if (loginRes.data.code === 0) {
this.globalData.token = loginRes.data.data.token
this.globalData.userInfo = loginRes.data.data.userInfo
wx.setStorageSync('token', loginRes.data.data.token)
this.connectWebSocket()
resolve(loginRes.data.data)
const data = loginRes.data.data;
// 保存微信登录信息(用于后续手机号授权)
this.globalData.wxLoginInfo = {
openid: data.openid,
unionid: data.unionid,
sessionKey: data.sessionKey,
isNewUser: data.isNewUser,
hasPhone: data.hasPhone,
};
// 如果已有token老用户直接使用
if (data.userInfo && data.hasPhone) {
// 老用户已绑定手机号生成token并登录
this.globalData.userInfo = data.userInfo;
}
resolve(data);
} else {
reject(loginRes.data)
reject(loginRes.data);
}
},
fail: reject
})
fail: reject,
});
},
fail: reject
})
})
fail: reject,
});
});
},
// 手机号授权登录(第二步:解密手机号完成注册/登录)
phoneLogin(encryptedData, iv, userProfile) {
return new Promise((resolve, reject) => {
const wxInfo = this.globalData.wxLoginInfo;
if (!wxInfo) {
reject({ message: "请先进行微信登录" });
return;
}
wx.request({
url: `${this.globalData.baseUrl}/api/user/phone-login`,
method: "POST",
data: {
openid: wxInfo.openid,
unionid: wxInfo.unionid,
sessionKey: wxInfo.sessionKey,
encryptedData,
iv,
nickname: userProfile?.nickName || "",
avatar: userProfile?.avatarUrl || "",
gender: userProfile?.gender || 0,
},
success: (loginRes) => {
if (loginRes.data.code === 0) {
this.globalData.token = loginRes.data.data.token;
this.globalData.userInfo = loginRes.data.data.userInfo;
wx.setStorageSync("token", loginRes.data.data.token);
this.connectWebSocket();
resolve(loginRes.data.data);
} else {
reject(loginRes.data);
}
},
fail: reject,
});
});
},
// 旧的登录方法(兼容)
login() {
return this.wxLogin();
},
// 获取用户信息
getUserInfo() {
return new Promise((resolve, reject) => {
this.request('/api/user/info').then(res => {
this.globalData.userInfo = res.data
this.connectWebSocket()
resolve(res.data)
}).catch(reject)
})
this.request("/api/user/info")
.then((res) => {
this.globalData.userInfo = res.data;
this.connectWebSocket();
resolve(res.data);
})
.catch(reject);
});
},
// 获取当前门店
getCurrentStore() {
return new Promise((resolve, reject) => {
wx.getLocation({
type: 'gcj02',
success: loc => {
this.request('/api/user/current-store', {
type: "gcj02",
success: (loc) => {
this.request("/api/user/current-store", {
latitude: loc.latitude,
longitude: loc.longitude
}).then(res => {
this.globalData.currentStore = res.data
if (res.data?.ladderUserId) {
this.getLadderUser(res.data.storeId)
}
resolve(res.data)
}).catch(reject)
longitude: loc.longitude,
})
.then((res) => {
this.globalData.currentStore = res.data;
if (res.data?.ladderUserId) {
this.getLadderUser(res.data.storeId);
}
resolve(res.data);
})
.catch(reject);
},
fail: () => {
// 无法获取位置,使用默认门店
this.request('/api/user/current-store').then(res => {
this.globalData.currentStore = res.data
resolve(res.data)
}).catch(reject)
}
})
})
this.request("/api/user/current-store")
.then((res) => {
this.globalData.currentStore = res.data;
resolve(res.data);
})
.catch(reject);
},
});
});
},
// 获取天梯用户信息
getLadderUser(storeId) {
return this.request('/api/user/ladder-info', { store_id: storeId }).then(res => {
if (res.data && res.data.length > 0) {
this.globalData.ladderUser = res.data[0]
return this.request("/api/user/ladder-info", { store_id: storeId }).then(
(res) => {
if (res.data && res.data.length > 0) {
this.globalData.ladderUser = res.data[0];
}
return res.data;
}
return res.data
})
);
},
// WebSocket连接
connectWebSocket() {
if (this.globalData.wsConnected || !this.globalData.token) return
if (this.globalData.wsConnected || !this.globalData.token) return;
const wsUrl = this.globalData.wsUrl || "ws://localhost:3000/ws";
const wsUrl = this.globalData.wsUrl || 'ws://localhost:3000/ws'
this.ws = wx.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket连接中...')
}
})
console.log("WebSocket连接中...");
},
});
wx.onSocketOpen(() => {
console.log('WebSocket已连接')
this.globalData.wsConnected = true
console.log("WebSocket已连接");
this.globalData.wsConnected = true;
// 发送认证
wx.sendSocketMessage({
data: JSON.stringify({
type: 'auth',
token: this.globalData.token
})
})
})
type: "auth",
token: this.globalData.token,
}),
});
});
wx.onSocketMessage(res => {
const data = JSON.parse(res.data)
this.handleWsMessage(data)
})
wx.onSocketMessage((res) => {
const data = JSON.parse(res.data);
this.handleWsMessage(data);
});
wx.onSocketClose(() => {
console.log('WebSocket已断开')
this.globalData.wsConnected = false
console.log("WebSocket已断开");
this.globalData.wsConnected = false;
// 尝试重连
setTimeout(() => {
this.connectWebSocket()
}, 5000)
})
this.connectWebSocket();
}, 5000);
});
wx.onSocketError(err => {
console.error('WebSocket错误:', err)
})
wx.onSocketError((err) => {
console.error("WebSocket错误:", err);
});
},
// 处理WebSocket消息
handleWsMessage(data) {
switch (data.type) {
case 'challenge_request':
case "challenge_request":
// 收到挑战请求
wx.showModal({
title: '收到挑战',
title: "收到挑战",
content: `${data.data.challenger.realName} 向你发起挑战`,
confirmText: '接受',
cancelText: '拒绝',
success: res => {
this.request('/api/match/challenge/respond', {
match_id: data.data.matchId,
accept: res.confirm
}, 'POST')
}
})
break
case 'score_confirm_request':
confirmText: "接受",
cancelText: "拒绝",
success: (res) => {
this.request(
"/api/match/challenge/respond",
{
match_id: data.data.matchId,
accept: res.confirm,
},
"POST"
);
},
});
break;
case "score_confirm_request":
// 收到比分确认请求
wx.showModal({
title: '确认比分',
title: "确认比分",
content: `比分: ${data.data.player1Score} : ${data.data.player2Score}`,
confirmText: '确认',
cancelText: '有争议',
success: res => {
this.request('/api/match/challenge/confirm-score', {
game_id: data.data.gameId,
confirm: res.confirm
}, 'POST')
}
})
break
case 'match_paired':
confirmText: "确认",
cancelText: "有争议",
success: (res) => {
this.request(
"/api/match/challenge/confirm-score",
{
game_id: data.data.gameId,
confirm: res.confirm,
},
"POST"
);
},
});
break;
case "match_paired":
// 排位赛匹配通知
wx.showModal({
title: '匹配成功',
title: "匹配成功",
content: `你的对手是: ${data.data.opponent.realName}`,
showCancel: false
})
break
showCancel: false,
});
break;
}
},
// 封装请求
request(url, data = {}, method = 'GET') {
request(url, data = {}, method = "GET") {
return new Promise((resolve, reject) => {
wx.request({
url: `${this.globalData.baseUrl}${url}`,
method,
data,
header: {
'Authorization': `Bearer ${this.globalData.token}`
Authorization: `Bearer ${this.globalData.token}`,
},
success: res => {
success: (res) => {
if (res.data.code === 0) {
resolve(res.data)
resolve(res.data);
} else if (res.data.code === 401) {
// 登录过期
this.globalData.token = null
wx.removeStorageSync('token')
wx.reLaunch({ url: '/pages/user/index' })
reject(res.data)
this.globalData.token = null;
wx.removeStorageSync("token");
wx.reLaunch({ url: "/pages/user/index" });
reject(res.data);
} else {
wx.showToast({ title: res.data.message, icon: 'none' })
reject(res.data)
wx.showToast({ title: res.data.message, icon: "none" });
reject(res.data);
}
},
fail: reject
})
})
}
})
fail: reject,
});
});
},
});

48
miniprogram/config.js Normal file
View File

@ -0,0 +1,48 @@
/**
* 小程序配置文件
* 请根据实际环境修改以下配置
*/
// 开发环境配置
const devConfig = {
// API 基础地址(本地开发)
baseUrl: "http://localhost:3000",
// WebSocket 地址(本地开发)
wsUrl: "ws://localhost:3000/ws",
};
// 生产环境配置
const prodConfig = {
// API 基础地址(生产环境,请替换为实际域名)
baseUrl: "https://your-domain.com",
// WebSocket 地址(生产环境,请替换为实际域名)
wsUrl: "wss://your-domain.com/ws",
};
// 根据环境变量选择配置
// 小程序可以通过 __wxConfig.envVersion 获取当前环境
// develop: 开发版, trial: 体验版, release: 正式版
const getEnv = () => {
try {
// 尝试获取微信环境
const envVersion = __wxConfig?.envVersion || "develop";
return envVersion === "release" ? "production" : "development";
} catch (e) {
return "development";
}
};
const env = getEnv();
const config = env === "production" ? prodConfig : devConfig;
module.exports = {
...config,
env,
// 其他配置项
// 上传文件大小限制 (MB)
uploadMaxSize: 5,
// 请求超时时间 (ms)
requestTimeout: 30000,
// 版本号
version: "1.0.0",
};

View File

@ -0,0 +1,40 @@
================================================================================
影沙俱乐部小程序配置说明
================================================================================
【配置文件位置】
config.js
【开发环境配置】
在 config.js 中修改 devConfig:
- baseUrl: API 服务器地址,本地开发默认 http://localhost:3000
- wsUrl: WebSocket 地址,本地开发默认 ws://localhost:3000/ws
【生产环境配置】
在 config.js 中修改 prodConfig:
- baseUrl: 正式环境 API 地址,如 https://api.yingsha.com
- wsUrl: 正式环境 WebSocket 地址,如 wss://api.yingsha.com/ws
【微信小程序后台配置】
1. 登录微信公众平台 -> 开发管理 -> 开发设置
2. 服务器域名配置:
- request 合法域名: 添加你的 API 域名https://
- socket 合法域名: 添加你的 WebSocket 域名wss://
- uploadFile 合法域名: 添加你的上传文件域名
- downloadFile 合法域名: 添加你的下载文件域名
3. 业务域名配置(如需要 webview:
- 添加需要在 webview 中打开的域名
【本地开发调试】
1. 微信开发者工具中勾选"不校验合法域名"
2. 确保本地服务器已启动:
cd server
npm run dev
【注意事项】
- 正式环境必须使用 HTTPS 和 WSS
- 配置更改后需要重新编译小程序
- 首次发布需要在微信后台配置服务器域名
================================================================================

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<rect width="120" height="120" fill="#e8e8e8"/>
<circle cx="60" cy="45" r="25" fill="#bfbfbf"/>
<ellipse cx="60" cy="100" rx="40" ry="30" fill="#bfbfbf"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<circle cx="70" cy="60" r="25" fill="#e8e8e8"/>
<circle cx="130" cy="60" r="25" fill="#e8e8e8"/>
<text x="100" y="68" text-anchor="middle" fill="#d9d9d9" font-size="24">VS</text>
<rect x="50" y="100" width="100" height="20" fill="#e8e8e8" rx="4"/>
<text x="100" y="150" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无比赛记录</text>
</svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="25" width="100" height="100" fill="#e8e8e8" rx="8"/>
<rect x="65" y="40" width="70" height="35" fill="#d9d9d9" rx="4"/>
<rect x="65" y="85" width="45" height="10" fill="#f5f5f5" rx="2"/>
<rect x="65" y="100" width="30" height="15" fill="#faad14" rx="3"/>
<circle cx="130" cy="107" r="8" fill="#d9d9d9"/>
<text x="100" y="145" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无订单</text>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="40" y="30" width="50" height="70" fill="#e8e8e8" rx="6"/>
<rect x="110" y="30" width="50" height="70" fill="#e8e8e8" rx="6"/>
<rect x="50" y="40" width="30" height="30" fill="#d9d9d9" rx="4"/>
<rect x="120" y="40" width="30" height="30" fill="#d9d9d9" rx="4"/>
<rect x="50" y="75" width="30" height="6" fill="#f5f5f5" rx="2"/>
<rect x="120" y="75" width="30" height="6" fill="#f5f5f5" rx="2"/>
<rect x="50" y="85" width="20" height="8" fill="#faad14" rx="2"/>
<rect x="120" y="85" width="20" height="8" fill="#faad14" rx="2"/>
<text x="100" y="130" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无商品</text>
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="40" y="80" width="30" height="50" fill="#e8e8e8" rx="4"/>
<rect x="85" y="50" width="30" height="80" fill="#e8e8e8" rx="4"/>
<rect x="130" y="95" width="30" height="35" fill="#e8e8e8" rx="4"/>
<circle cx="55" cy="65" r="12" fill="#d9d9d9"/>
<circle cx="100" cy="35" r="12" fill="#d9d9d9"/>
<circle cx="145" cy="80" r="12" fill="#d9d9d9"/>
<text x="100" y="150" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无排名数据</text>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="30" width="100" height="90" fill="#e8e8e8" rx="8"/>
<rect x="65" y="45" width="70" height="8" fill="#d9d9d9" rx="2"/>
<rect x="65" y="60" width="50" height="8" fill="#f5f5f5" rx="2"/>
<rect x="65" y="75" width="60" height="8" fill="#d9d9d9" rx="2"/>
<rect x="65" y="90" width="40" height="8" fill="#f5f5f5" rx="2"/>
<rect x="65" y="105" width="55" height="8" fill="#d9d9d9" rx="2"/>
<text x="100" y="145" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无记录</text>
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="40" width="100" height="70" fill="#e8e8e8" rx="8"/>
<rect x="60" y="50" width="25" height="20" fill="#d9d9d9" rx="2"/>
<rect x="90" y="50" width="50" height="10" fill="#d9d9d9" rx="2"/>
<rect x="90" y="65" width="40" height="8" fill="#f5f5f5" rx="2"/>
<path d="M70 85 L80 95 L130 95 L130 110 L80 110 Z" fill="#d9d9d9"/>
<text x="100" y="140" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无门店</text>
</svg>

After

Width:  |  Height:  |  Size: 549 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#999999" d="M18 12l2.83-2.83L32.66 21 20.83 32.83 18 30l9-9z"/>
</svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#ff6b35" d="M24 4l5.5 11.5L42 17l-9 9 2 12.5L24 33l-11 5.5 2-12.5-9-9 12.5-1.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="20" fill="#52c41a"/>
<path fill="#ffffff" d="M20 30.59l-6.29-6.3 2.12-2.12L20 26.34l12.17-12.17 2.12 2.12z"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M25.99 6C16.04 6 8 14.06 8 24H2l7.79 7.79.14.29L18 24h-6c0-7.73 6.27-14 14-14s14 6.27 14 14-6.27 14-14 14c-3.87 0-7.36-1.58-9.89-4.11l-2.83 2.83C16.53 39.98 21.02 42 26 42c9.94 0 18-8.06 18-18S35.94 6 25.99 6zM24 16v10l8.56 5.08 1.44-2.43-7-4.15V16h-3z"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="20" fill="#1890ff"/>
<text x="24" y="32" text-anchor="middle" fill="#ffffff" font-size="24" font-weight="bold">i</text>
</svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M38 6H10c-2.21 0-4 1.79-4 4v28c0 2.21 1.79 4 4 4h28c2.21 0 4-1.79 4-4V10c0-2.21-1.79-4-4-4zm-22 6h16v4H16v-4zm16 20H16v-4h16v4zm0-8H16v-4h16v4z"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="18" fill="#faad14"/>
<text x="24" y="30" text-anchor="middle" fill="#ffffff" font-size="18" font-weight="bold">P</text>
</svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M8 8h14v14H8V8zm4 4v6h6v-6h-6zm12-4h14v14H24V8zm4 4v6h6v-6h-6zM8 26h14v14H8V26zm4 4v6h6v-6h-6zm16-4h4v4h-4zm4 4h4v4h-4zm-4 4h4v4h-4zm4 4h4v4h-4zm4-8h4v4h-4zm0 8h4v4h-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect x="6" y="26" width="10" height="16" fill="#52c41a"/>
<rect x="19" y="14" width="10" height="28" fill="#faad14"/>
<rect x="32" y="20" width="10" height="22" fill="#1890ff"/>
<text x="11" y="24" text-anchor="middle" fill="#666" font-size="10">2</text>
<text x="24" y="12" text-anchor="middle" fill="#666" font-size="10">1</text>
<text x="37" y="18" text-anchor="middle" fill="#666" font-size="10">3</text>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M14 6c-2.21 0-4 1.79-4 4v28c0 2.21 1.79 4 4 4h20c2.21 0 4-1.79 4-4V10c0-2.21-1.79-4-4-4H14zm2 8h16v4H16v-4zm0 8h12v4H16v-4zm0 8h16v4H16v-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M6 14V8c0-1.1.9-2 2-2h6v4H10v4H6zm32-8h6c1.1 0 2 .9 2 2v6h-4v-4h-4V6zM6 34v6c0 1.1.9 2 2 2h6v-4H10v-4H6zm32 8h6c1.1 0 2-.9 2-2v-6h-4v4h-4v4zM6 22h36v4H6z"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M24 4L6 14v4h4v22h28V18h4v-4L24 4zm8 30H16V22h16v12z"/>
<rect x="20" y="26" width="8" height="8" fill="#999"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<rect width="200" height="200" fill="#f5f5f5"/>
<rect x="50" y="50" width="100" height="100" fill="#e8e8e8" rx="8"/>
<rect x="70" y="70" width="60" height="40" fill="#d9d9d9" rx="4"/>
<rect x="70" y="120" width="40" height="10" fill="#bfbfbf" rx="2"/>
<rect x="70" y="135" width="25" height="8" fill="#faad14" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@ -3,10 +3,10 @@
<!-- 门店选择 -->
<view class="store-selector" bindtap="selectStore">
<view class="store-info">
<image class="store-icon" src="/images/icon-store.png" mode="aspectFit"></image>
<image class="store-icon" src="/images/icon-store.svg" mode="aspectFit"></image>
<text class="store-name">{{currentStore.storeName || '选择门店'}}</text>
</view>
<image class="arrow-icon" src="/images/icon-arrow.png" mode="aspectFit"></image>
<image class="arrow-icon" src="/images/icon-arrow.svg" mode="aspectFit"></image>
</view>
<!-- 性别筛选 -->
@ -51,7 +51,7 @@
</view>
</view>
<view class="col-user">
<image class="avatar" src="{{item.avatar || '/images/avatar-default.png'}}" mode="aspectFill"></image>
<image class="avatar" src="{{item.avatar || '/images/avatar-default.svg'}}" mode="aspectFill"></image>
<view class="user-info">
<text class="name">{{item.realName}}</text>
<text class="rate">胜率 {{item.winRate}}%</text>
@ -67,7 +67,7 @@
</block>
<view wx:else class="empty-state">
<image src="/images/empty-ranking.png" mode="aspectFit"></image>
<image src="/images/empty-ranking.svg" mode="aspectFit"></image>
<text>暂无排名数据</text>
<text class="sub-text">每月完成3场比赛即可上榜</text>
</view>

View File

@ -2,7 +2,7 @@
<view class="container">
<!-- 未登录或非天梯用户提示 -->
<view class="notice-card" wx:if="{{!ladderUser}}">
<image src="/images/icon-info.png" mode="aspectFit"></image>
<image src="/images/icon-info.svg" mode="aspectFit"></image>
<text>仅天梯用户可使用比赛功能,请联系门店工作人员加入天梯系统</text>
</view>
@ -11,7 +11,7 @@
<!-- 我的信息 -->
<view class="my-info-card">
<view class="info-header">
<image class="avatar" src="{{userInfo.avatar || '/images/avatar-default.png'}}" mode="aspectFill"></image>
<image class="avatar" src="{{userInfo.avatar || '/images/avatar-default.svg'}}" mode="aspectFill"></image>
<view class="info-meta">
<text class="name">{{ladderUser.realName}}</text>
<view class="level-power">
@ -25,12 +25,12 @@
<!-- 挑战赛入口 -->
<view class="action-card">
<view class="card-title">
<image src="/images/icon-challenge.png" mode="aspectFit"></image>
<image src="/images/icon-challenge.svg" mode="aspectFit"></image>
<text>挑战赛</text>
</view>
<view class="card-desc">扫描对手会员码发起挑战,挑战赛权重 x1.5</view>
<button class="btn-primary" bindtap="startChallenge">
<image src="/images/icon-scan.png" mode="aspectFit"></image>
<image src="/images/icon-scan.svg" mode="aspectFit"></image>
扫码挑战
</button>
</view>
@ -38,12 +38,12 @@
<!-- 排位赛入口 -->
<view class="action-card">
<view class="card-title">
<image src="/images/icon-ranking.png" mode="aspectFit"></image>
<image src="/images/icon-ranking.svg" mode="aspectFit"></image>
<text>排位赛</text>
</view>
<view class="card-desc">扫描比赛二维码加入排位赛</view>
<button class="btn-secondary" bindtap="joinRankingMatch">
<image src="/images/icon-scan.png" mode="aspectFit"></image>
<image src="/images/icon-scan.svg" mode="aspectFit"></image>
扫码加入
</button>
</view>

View File

@ -25,7 +25,7 @@
</view>
<view class="empty-state" wx:else>
<image src="/images/empty-match.png" mode="aspectFit"></image>
<image src="/images/empty-match.svg" mode="aspectFit"></image>
<text>暂无比赛记录</text>
</view>

View File

@ -8,11 +8,11 @@
</view>
<view class="points-actions">
<view class="action-btn" bindtap="goToRecords">
<image src="/images/icon-records.png" mode="aspectFit"></image>
<image src="/images/icon-records.svg" mode="aspectFit"></image>
<text>积分记录</text>
</view>
<view class="action-btn" bindtap="goToOrders">
<image src="/images/icon-order.png" mode="aspectFit"></image>
<image src="/images/icon-order.svg" mode="aspectFit"></image>
<text>我的订单</text>
</view>
</view>
@ -30,7 +30,7 @@
bindtap="viewProduct"
data-product="{{item}}"
>
<image class="product-image" src="{{item.image || '/images/product-default.png'}}" mode="aspectFill"></image>
<image class="product-image" src="{{item.image || '/images/product-default.svg'}}" mode="aspectFill"></image>
<view class="product-info">
<text class="product-name">{{item.name}}</text>
<view class="product-meta">
@ -46,7 +46,7 @@
</view>
<view class="empty-state" wx:else>
<image src="/images/empty-products.png" mode="aspectFit"></image>
<image src="/images/empty-products.svg" mode="aspectFit"></image>
<text>暂无可兑换商品</text>
</view>
</view>
@ -60,7 +60,7 @@
<!-- 商品详情弹窗 -->
<view class="product-modal" wx:if="{{showProductModal}}" bindtap="closeProductModal">
<view class="modal-content" catchtap="">
<image class="modal-image" src="{{currentProduct.image || '/images/product-default.png'}}" mode="aspectFill"></image>
<image class="modal-image" src="{{currentProduct.image || '/images/product-default.svg'}}" mode="aspectFill"></image>
<view class="modal-info">
<text class="modal-name">{{currentProduct.name}}</text>
<text class="modal-desc">{{currentProduct.description || '暂无描述'}}</text>

View File

@ -33,7 +33,7 @@
<text class="order-status status-{{item.status}}">{{getStatusText(item.status)}}</text>
</view>
<view class="order-content">
<image class="product-image" src="{{item.productImage || '/images/product-default.png'}}" mode="aspectFill"></image>
<image class="product-image" src="{{item.productImage || '/images/product-default.svg'}}" mode="aspectFill"></image>
<view class="product-info">
<text class="product-name">{{item.productName}}</text>
<text class="store-name">{{item.storeName}}</text>
@ -48,7 +48,7 @@
</view>
<view class="empty-state" wx:else>
<image src="/images/empty-order.png" mode="aspectFit"></image>
<image src="/images/empty-order.svg" mode="aspectFit"></image>
<text>暂无订单</text>
</view>
@ -67,7 +67,7 @@
</view>
<view class="modal-body">
<image class="product-image" src="{{currentOrder.productImage || '/images/product-default.png'}}" mode="aspectFill"></image>
<image class="product-image" src="{{currentOrder.productImage || '/images/product-default.svg'}}" mode="aspectFill"></image>
<text class="product-name">{{currentOrder.productName}}</text>
<text class="points-used">使用积分: {{currentOrder.pointsUsed}}</text>

View File

@ -13,7 +13,7 @@
</view>
<view class="empty-state" wx:else>
<image src="/images/empty-records.png" mode="aspectFit"></image>
<image src="/images/empty-records.svg" mode="aspectFit"></image>
<text>暂无积分记录</text>
</view>

View File

@ -17,13 +17,13 @@
</view>
</view>
<view class="store-check" wx:if="{{currentStoreId === item.id}}">
<image src="/images/icon-check.png" mode="aspectFit"></image>
<image src="/images/icon-check.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<view class="empty-state" wx:if="{{stores.length === 0}}">
<image src="/images/empty-store.png" mode="aspectFit"></image>
<image src="/images/empty-store.svg" mode="aspectFit"></image>
<text>暂无门店</text>
</view>
</view>

View File

@ -6,7 +6,9 @@ Page({
userInfo: null,
ladderUser: null,
currentStore: null,
showQrcode: false
showQrcode: false,
needProfile: false,
tempUserProfile: null
},
onLoad() {
@ -18,11 +20,18 @@ Page({
},
async initData() {
if (!app.globalData.token) {
return
// 先进行微信登录获取openid
if (!app.globalData.wxLoginInfo) {
try {
await app.wxLogin()
} catch (e) {
console.error('微信登录失败:', e)
}
}
await this.refreshData()
if (app.globalData.token) {
await this.refreshData()
}
},
async refreshData() {
@ -40,20 +49,80 @@ Page({
}
},
async handleLogin() {
// 获取手机号授权
async onGetPhoneNumber(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
wx.showToast({ title: '需要授权手机号才能登录', icon: 'none' })
return
}
wx.showLoading({ title: '登录中...' })
try {
await app.login()
// 如果没有微信登录信息,先登录
if (!app.globalData.wxLoginInfo) {
await app.wxLogin()
}
// 获取用户头像昵称
let userProfile = this.data.tempUserProfile
if (!userProfile) {
try {
const profileRes = await wx.getUserProfile({
desc: '用于完善会员资料'
})
userProfile = profileRes.userInfo
} catch (err) {
// 用户拒绝授权头像昵称,使用默认值
userProfile = { nickName: '新用户', avatarUrl: '' }
}
}
// 手机号登录
await app.phoneLogin(e.detail.encryptedData, e.detail.iv, userProfile)
// 获取门店信息
await app.getCurrentStore()
this.setData({
userInfo: app.globalData.userInfo,
currentStore: app.globalData.currentStore
ladderUser: app.globalData.ladderUser,
currentStore: app.globalData.currentStore,
needProfile: false,
tempUserProfile: null
})
wx.hideLoading()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
wx.showToast({ title: '登录失败', icon: 'none' })
wx.hideLoading()
console.error('登录失败:', e)
wx.showToast({ title: e.message || '登录失败', icon: 'none' })
}
},
// 选择头像
async onChooseAvatar() {
try {
const res = await wx.getUserProfile({
desc: '用于完善会员资料'
})
this.setData({
tempUserProfile: res.userInfo,
needProfile: false
})
wx.showToast({ title: '已获取头像昵称', icon: 'success' })
} catch (e) {
wx.showToast({ title: '获取头像昵称失败', icon: 'none' })
}
},
// 旧的登录方法(兼容)
async handleLogin() {
// 触发手机号授权按钮
wx.showToast({ title: '请点击手机号登录按钮', icon: 'none' })
},
showMemberCode() {
if (!this.data.userInfo?.memberCode) return

View File

@ -2,21 +2,39 @@
<view class="container">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-header" wx:if="{{userInfo}}">
<image class="avatar-large" src="{{userInfo.avatar || '/images/avatar-default.png'}}" mode="aspectFill"></image>
<!-- 已登录状态 -->
<view class="user-header" wx:if="{{userInfo && userInfo.phone}}">
<image class="avatar-large" src="{{userInfo.avatar || '/images/avatar-default.svg'}}" mode="aspectFill"></image>
<view class="user-meta">
<text class="nickname">{{userInfo.nickname || '新用户'}}</text>
<view class="member-code" bindtap="showMemberCode">
<text>会员码: {{userInfo.memberCode}}</text>
<image src="/images/icon-qrcode.png" mode="aspectFit"></image>
<image src="/images/icon-qrcode.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<view class="user-header" wx:else bindtap="handleLogin">
<image class="avatar-large" src="/images/avatar-default.png" mode="aspectFill"></image>
<view class="user-meta">
<text class="nickname">点击登录</text>
<!-- 未登录状态 -->
<view class="login-panel" wx:else>
<image class="avatar-large" src="/images/avatar-default.svg" mode="aspectFill"></image>
<view class="login-tips">
<text class="title">欢迎来到羽动俱乐部</text>
<text class="desc">授权手机号,开启您的运动之旅</text>
</view>
<button
class="login-btn phone-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
手机号快捷登录
</button>
<button
class="login-btn profile-btn"
bindtap="onChooseAvatar"
wx:if="{{needProfile}}"
>
完善头像昵称
</button>
</view>
<!-- 积分展示 -->
@ -50,31 +68,31 @@
</view>
<view class="notice-card" wx:else>
<image src="/images/icon-info.png" mode="aspectFit"></image>
<image src="/images/icon-info.svg" mode="aspectFit"></image>
<text>您还不是天梯用户,请联系门店工作人员加入天梯系统</text>
</view>
<!-- 功能菜单 -->
<view class="menu-list">
<view class="menu-item" bindtap="goTo" data-url="/pages/match/history/index">
<image src="/images/icon-history.png" mode="aspectFit"></image>
<image src="/images/icon-history.svg" mode="aspectFit"></image>
<text>比赛记录</text>
<image class="arrow" src="/images/icon-arrow.png" mode="aspectFit"></image>
<image class="arrow" src="/images/icon-arrow.svg" mode="aspectFit"></image>
</view>
<view class="menu-item" bindtap="goTo" data-url="/pages/points/records/index">
<image src="/images/icon-points.png" mode="aspectFit"></image>
<image src="/images/icon-points.svg" mode="aspectFit"></image>
<text>积分记录</text>
<image class="arrow" src="/images/icon-arrow.png" mode="aspectFit"></image>
<image class="arrow" src="/images/icon-arrow.svg" mode="aspectFit"></image>
</view>
<view class="menu-item" bindtap="goTo" data-url="/pages/points/order/index">
<image src="/images/icon-order.png" mode="aspectFit"></image>
<image src="/images/icon-order.svg" mode="aspectFit"></image>
<text>兑换订单</text>
<image class="arrow" src="/images/icon-arrow.png" mode="aspectFit"></image>
<image class="arrow" src="/images/icon-arrow.svg" mode="aspectFit"></image>
</view>
<view class="menu-item" bindtap="goTo" data-url="/pages/store/index">
<image src="/images/icon-store.png" mode="aspectFit"></image>
<image src="/images/icon-store.svg" mode="aspectFit"></image>
<text>切换门店</text>
<image class="arrow" src="/images/icon-arrow.png" mode="aspectFit"></image>
<image class="arrow" src="/images/icon-arrow.svg" mode="aspectFit"></image>
</view>
</view>
</view>

View File

@ -21,6 +21,61 @@
margin-right: 24rpx;
}
/* 登录面板 */
.login-panel {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.login-panel .avatar-large {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.3);
margin-bottom: 30rpx;
}
.login-tips {
text-align: center;
margin-bottom: 40rpx;
}
.login-tips .title {
display: block;
font-size: 36rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.login-tips .desc {
display: block;
font-size: 26rpx;
opacity: 0.8;
}
.login-btn {
width: 80%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 500;
margin-bottom: 20rpx;
border: none;
}
.login-btn.phone-btn {
background: #fff;
color: var(--primary-color);
}
.login-btn.profile-btn {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.user-meta {
flex: 1;
}

View File

@ -0,0 +1,201 @@
/**
* 生成小程序所需的图标和图片
* 运行: node scripts/generateImages.js
*/
const fs = require('fs')
const path = require('path')
const imagesDir = path.join(__dirname, '..', 'images')
// 确保 images 目录存在
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true })
}
// SVG 图标定义 (48x48)
const icons = {
// 箭头图标
'icon-arrow': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#999999" d="M18 12l2.83-2.83L32.66 21 20.83 32.83 18 30l9-9z"/>
</svg>`,
// 勾选图标
'icon-check': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="20" fill="#52c41a"/>
<path fill="#ffffff" d="M20 30.59l-6.29-6.3 2.12-2.12L20 26.34l12.17-12.17 2.12 2.12z"/>
</svg>`,
// 挑战图标
'icon-challenge': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#ff6b35" d="M24 4l5.5 11.5L42 17l-9 9 2 12.5L24 33l-11 5.5 2-12.5-9-9 12.5-1.5z"/>
</svg>`,
// 历史图标
'icon-history': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M25.99 6C16.04 6 8 14.06 8 24H2l7.79 7.79.14.29L18 24h-6c0-7.73 6.27-14 14-14s14 6.27 14 14-6.27 14-14 14c-3.87 0-7.36-1.58-9.89-4.11l-2.83 2.83C16.53 39.98 21.02 42 26 42c9.94 0 18-8.06 18-18S35.94 6 25.99 6zM24 16v10l8.56 5.08 1.44-2.43-7-4.15V16h-3z"/>
</svg>`,
// 信息图标
'icon-info': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="20" fill="#1890ff"/>
<text x="24" y="32" text-anchor="middle" fill="#ffffff" font-size="24" font-weight="bold">i</text>
</svg>`,
// 订单图标
'icon-order': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M38 6H10c-2.21 0-4 1.79-4 4v28c0 2.21 1.79 4 4 4h28c2.21 0 4-1.79 4-4V10c0-2.21-1.79-4-4-4zm-22 6h16v4H16v-4zm16 20H16v-4h16v4zm0-8H16v-4h16v4z"/>
</svg>`,
// 积分图标
'icon-points': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="18" fill="#faad14"/>
<text x="24" y="30" text-anchor="middle" fill="#ffffff" font-size="18" font-weight="bold">P</text>
</svg>`,
// 二维码图标
'icon-qrcode': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M8 8h14v14H8V8zm4 4v6h6v-6h-6zm12-4h14v14H24V8zm4 4v6h6v-6h-6zM8 26h14v14H8V26zm4 4v6h6v-6h-6zm16-4h4v4h-4zm4 4h4v4h-4zm-4 4h4v4h-4zm4 4h4v4h-4zm4-8h4v4h-4zm0 8h4v4h-4z"/>
</svg>`,
// 排名图标
'icon-ranking': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect x="6" y="26" width="10" height="16" fill="#52c41a"/>
<rect x="19" y="14" width="10" height="28" fill="#faad14"/>
<rect x="32" y="20" width="10" height="22" fill="#1890ff"/>
<text x="11" y="24" text-anchor="middle" fill="#666" font-size="10">2</text>
<text x="24" y="12" text-anchor="middle" fill="#666" font-size="10">1</text>
<text x="37" y="18" text-anchor="middle" fill="#666" font-size="10">3</text>
</svg>`,
// 记录图标
'icon-records': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M14 6c-2.21 0-4 1.79-4 4v28c0 2.21 1.79 4 4 4h20c2.21 0 4-1.79 4-4V10c0-2.21-1.79-4-4-4H14zm2 8h16v4H16v-4zm0 8h12v4H16v-4zm0 8h16v4H16v-4z"/>
</svg>`,
// 扫码图标
'icon-scan': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M6 14V8c0-1.1.9-2 2-2h6v4H10v4H6zm32-8h6c1.1 0 2 .9 2 2v6h-4v-4h-4V6zM6 34v6c0 1.1.9 2 2 2h6v-4H10v-4H6zm32 8h6c1.1 0 2-.9 2-2v-6h-4v4h-4v4zM6 22h36v4H6z"/>
</svg>`,
// 门店图标
'icon-store': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#666666" d="M24 4L6 14v4h4v22h28V18h4v-4L24 4zm8 30H16V22h16v12z"/>
<rect x="20" y="26" width="8" height="8" fill="#999"/>
</svg>`
}
// 空状态图片 (200x160)
const emptyImages = {
// 空排名
'empty-ranking': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="40" y="80" width="30" height="50" fill="#e8e8e8" rx="4"/>
<rect x="85" y="50" width="30" height="80" fill="#e8e8e8" rx="4"/>
<rect x="130" y="95" width="30" height="35" fill="#e8e8e8" rx="4"/>
<circle cx="55" cy="65" r="12" fill="#d9d9d9"/>
<circle cx="100" cy="35" r="12" fill="#d9d9d9"/>
<circle cx="145" cy="80" r="12" fill="#d9d9d9"/>
<text x="100" y="150" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无排名数据</text>
</svg>`,
// 空门店
'empty-store': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="40" width="100" height="70" fill="#e8e8e8" rx="8"/>
<rect x="60" y="50" width="25" height="20" fill="#d9d9d9" rx="2"/>
<rect x="90" y="50" width="50" height="10" fill="#d9d9d9" rx="2"/>
<rect x="90" y="65" width="40" height="8" fill="#f5f5f5" rx="2"/>
<path d="M70 85 L80 95 L130 95 L130 110 L80 110 Z" fill="#d9d9d9"/>
<text x="100" y="140" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无门店</text>
</svg>`,
// 空比赛
'empty-match': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<circle cx="70" cy="60" r="25" fill="#e8e8e8"/>
<circle cx="130" cy="60" r="25" fill="#e8e8e8"/>
<text x="100" y="68" text-anchor="middle" fill="#d9d9d9" font-size="24">VS</text>
<rect x="50" y="100" width="100" height="20" fill="#e8e8e8" rx="4"/>
<text x="100" y="150" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无比赛记录</text>
</svg>`,
// 空记录
'empty-records': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="30" width="100" height="90" fill="#e8e8e8" rx="8"/>
<rect x="65" y="45" width="70" height="8" fill="#d9d9d9" rx="2"/>
<rect x="65" y="60" width="50" height="8" fill="#f5f5f5" rx="2"/>
<rect x="65" y="75" width="60" height="8" fill="#d9d9d9" rx="2"/>
<rect x="65" y="90" width="40" height="8" fill="#f5f5f5" rx="2"/>
<rect x="65" y="105" width="55" height="8" fill="#d9d9d9" rx="2"/>
<text x="100" y="145" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无记录</text>
</svg>`,
// 空商品
'empty-products': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="40" y="30" width="50" height="70" fill="#e8e8e8" rx="6"/>
<rect x="110" y="30" width="50" height="70" fill="#e8e8e8" rx="6"/>
<rect x="50" y="40" width="30" height="30" fill="#d9d9d9" rx="4"/>
<rect x="120" y="40" width="30" height="30" fill="#d9d9d9" rx="4"/>
<rect x="50" y="75" width="30" height="6" fill="#f5f5f5" rx="2"/>
<rect x="120" y="75" width="30" height="6" fill="#f5f5f5" rx="2"/>
<rect x="50" y="85" width="20" height="8" fill="#faad14" rx="2"/>
<rect x="120" y="85" width="20" height="8" fill="#faad14" rx="2"/>
<text x="100" y="130" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无商品</text>
</svg>`,
// 空订单
'empty-order': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
<rect x="50" y="25" width="100" height="100" fill="#e8e8e8" rx="8"/>
<rect x="65" y="40" width="70" height="35" fill="#d9d9d9" rx="4"/>
<rect x="65" y="85" width="45" height="10" fill="#f5f5f5" rx="2"/>
<rect x="65" y="100" width="30" height="15" fill="#faad14" rx="3"/>
<circle cx="130" cy="107" r="8" fill="#d9d9d9"/>
<text x="100" y="145" text-anchor="middle" fill="#bfbfbf" font-size="14">暂无订单</text>
</svg>`
}
// 默认图片
const defaultImages = {
// 默认头像 (120x120)
'avatar-default': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<rect width="120" height="120" fill="#e8e8e8"/>
<circle cx="60" cy="45" r="25" fill="#bfbfbf"/>
<ellipse cx="60" cy="100" rx="40" ry="30" fill="#bfbfbf"/>
</svg>`,
// 默认商品图 (200x200)
'product-default': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<rect width="200" height="200" fill="#f5f5f5"/>
<rect x="50" y="50" width="100" height="100" fill="#e8e8e8" rx="8"/>
<rect x="70" y="70" width="60" height="40" fill="#d9d9d9" rx="4"/>
<rect x="70" y="120" width="40" height="10" fill="#bfbfbf" rx="2"/>
<rect x="70" y="135" width="25" height="8" fill="#faad14" rx="2"/>
</svg>`
}
// 写入 SVG 文件
function writeSvgFile(name, content) {
const filePath = path.join(imagesDir, `${name}.svg`)
fs.writeFileSync(filePath, content.trim())
console.log(`✓ 已生成: ${name}.svg`)
}
// 生成所有图标
console.log('\n=== 生成图标 ===')
Object.entries(icons).forEach(([name, svg]) => {
writeSvgFile(name, svg)
})
// 生成所有空状态图片
console.log('\n=== 生成空状态图片 ===')
Object.entries(emptyImages).forEach(([name, svg]) => {
writeSvgFile(name, svg)
})
// 生成默认图片
console.log('\n=== 生成默认图片 ===')
Object.entries(defaultImages).forEach(([name, svg]) => {
writeSvgFile(name, svg)
})
console.log('\n所有图片生成完成!')
console.log('提示: 微信小程序支持 SVG 格式图片,可直接使用')
console.log('如需 PNG 格式,可使用在线工具转换或安装 sharp 库')

View File

@ -97,16 +97,12 @@ class LadderAdminController {
return res.status(400).json(error('该手机号在此门店已存在天梯用户', 400));
}
// 查找或创建基础用户
let user = await User.findOne({ where: { phone } });
if (!user) {
// 手机号对应的用户不存在,无法创建天梯用户
return res.status(400).json(error('该手机号用户未注册小程序,请先引导用户注册', 400));
}
// 查找已注册的微信用户(可能不存在)
const user = await User.findOne({ where: { phone } });
// 创建天梯用户
// 创建天梯用户允许user_id为空待微信用户登录后自动关联
const ladderUser = await LadderUser.create({
user_id: user.id,
user_id: user ? user.id : null, // 如果微信用户存在则关联,否则为空
store_id: targetStoreId,
real_name,
phone,
@ -116,7 +112,11 @@ class LadderAdminController {
status: 1
});
res.json(success({ id: ladderUser.id }, '创建成功'));
const message = user
? '创建成功,已关联微信用户'
: '创建成功,待用户注册小程序后自动关联';
res.json(success({ id: ladderUser.id, linked: !!user }, message));
} catch (err) {
console.error('创建天梯用户失败:', err);
res.status(500).json(error('创建失败'));

View File

@ -1,11 +1,12 @@
const jwt = require('jsonwebtoken');
const axios = require('axios');
const crypto = require('crypto');
const { User, LadderUser, Store, Match, MatchGame } = require('../models');
const { generateMemberCode, success, error, calculateDistance } = require('../utils/helper');
const { Op } = require('sequelize');
class UserController {
// 微信登录
// 微信登录(获取 session_key用于后续手机号解密
async login(req, res) {
try {
const { code } = req.body;
@ -14,7 +15,7 @@ class UserController {
return res.status(400).json(error('缺少登录code', 400));
}
// 获取微信openid
// 获取微信openid和session_key
const wxRes = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: process.env.WX_APPID,
@ -28,7 +29,92 @@ class UserController {
return res.status(400).json(error('微信登录失败: ' + wxRes.data.errmsg, 400));
}
const { openid, unionid } = wxRes.data;
const { openid, unionid, session_key } = wxRes.data;
// 查找用户
let user = await User.findOne({ where: { openid } });
let isNewUser = false;
if (!user) {
isNewUser = true;
}
// 返回登录信息包含session_key用于后续手机号解密
// 注意实际生产环境中session_key不应该直接返回给前端
// 这里为了简化流程使用加密后的session_key
const encryptedSessionKey = this.encryptSessionKey(session_key, openid);
res.json(success({
openid,
unionid,
sessionKey: encryptedSessionKey,
isNewUser,
hasPhone: user?.phone ? true : false,
userInfo: user ? {
id: user.id,
nickname: user.nickname,
avatar: user.avatar,
phone: user.phone,
gender: user.gender,
memberCode: user.member_code,
totalPoints: user.total_points
} : null
}, isNewUser ? '请授权手机号完成注册' : '登录成功'));
} catch (err) {
console.error('登录失败:', err);
res.status(500).json(error('登录失败'));
}
}
// 加密session_key
encryptSessionKey(sessionKey, openid) {
const key = crypto.createHash('md5').update(process.env.JWT_SECRET + openid).digest();
const iv = Buffer.alloc(16, 0);
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
let encrypted = cipher.update(sessionKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
}
// 解密session_key
decryptSessionKey(encryptedSessionKey, openid) {
const key = crypto.createHash('md5').update(process.env.JWT_SECRET + openid).digest();
const iv = Buffer.alloc(16, 0);
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
let decrypted = decipher.update(encryptedSessionKey, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 手机号授权登录(解密手机号并完成注册/登录)
async phoneLogin(req, res) {
try {
const { openid, unionid, sessionKey, encryptedData, iv, nickname, avatar, gender } = req.body;
if (!openid || !sessionKey || !encryptedData || !iv) {
return res.status(400).json(error('参数不完整', 400));
}
// 解密session_key
let realSessionKey;
try {
realSessionKey = this.decryptSessionKey(sessionKey, openid);
} catch (e) {
return res.status(400).json(error('会话已过期,请重新登录', 400));
}
// 解密手机号
let phone;
try {
phone = this.decryptPhoneNumber(realSessionKey, encryptedData, iv);
} catch (e) {
console.error('手机号解密失败:', e);
return res.status(400).json(error('手机号解密失败,请重新授权', 400));
}
if (!phone) {
return res.status(400).json(error('获取手机号失败', 400));
}
// 查找或创建用户
let user = await User.findOne({ where: { openid } });
@ -38,10 +124,27 @@ class UserController {
user = await User.create({
openid,
unionid,
phone,
member_code: generateMemberCode(),
nickname: '新用户',
nickname: nickname || '新用户',
avatar: avatar || '',
gender: gender || 0,
status: 1
});
// 关联已存在的天梯用户(通过手机号)
await this.linkLadderUsers(user.id, phone);
} else {
// 更新用户信息
const updateData = { phone };
if (nickname) updateData.nickname = nickname;
if (avatar) updateData.avatar = avatar;
if (gender !== undefined) updateData.gender = gender;
await user.update(updateData);
// 如果手机号变化,重新关联天梯用户
await this.linkLadderUsers(user.id, phone);
}
// 生成token
@ -51,6 +154,12 @@ class UserController {
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
// 获取关联的天梯用户信息
const ladderUsers = await LadderUser.findAll({
where: { user_id: user.id, status: 1 },
include: [{ model: Store, as: 'store', attributes: ['id', 'name'] }]
});
res.json(success({
token,
userInfo: {
@ -60,15 +169,84 @@ class UserController {
phone: user.phone,
gender: user.gender,
memberCode: user.member_code,
totalPoints: user.total_points
totalPoints: user.total_points,
ladderUsers: ladderUsers.map(lu => ({
id: lu.id,
storeId: lu.store_id,
storeName: lu.store?.name,
realName: lu.real_name,
level: lu.level,
powerScore: lu.power_score
}))
}
}, '登录成功'));
} catch (err) {
console.error('登录失败:', err);
console.error('手机号登录失败:', err);
res.status(500).json(error('登录失败'));
}
}
// 解密微信手机号
decryptPhoneNumber(sessionKey, encryptedData, iv) {
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64');
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const decipher = crypto.createDecipheriv('aes-128-cbc', sessionKeyBuffer, ivBuffer);
decipher.setAutoPadding(true);
let decoded = decipher.update(encryptedDataBuffer, 'binary', 'utf8');
decoded += decipher.final('utf8');
const result = JSON.parse(decoded);
return result.phoneNumber || result.purePhoneNumber;
}
// 关联天梯用户(通过手机号)
async linkLadderUsers(userId, phone) {
// 查找所有未关联的天梯用户(相同手机号)
const unlinkedLadderUsers = await LadderUser.findAll({
where: {
phone,
user_id: null,
status: 1
}
});
// 批量更新关联
if (unlinkedLadderUsers.length > 0) {
await LadderUser.update(
{ user_id: userId },
{ where: { phone, user_id: null } }
);
console.log(`已关联 ${unlinkedLadderUsers.length} 个天梯用户到用户 ${userId}`);
}
}
// 更新用户资料(头像、昵称)
async updateProfile(req, res) {
try {
const { nickname, avatar, gender } = req.body;
const user = req.user;
const updateData = {};
if (nickname) updateData.nickname = nickname;
if (avatar) updateData.avatar = avatar;
if (gender !== undefined) updateData.gender = gender;
await user.update(updateData);
res.json(success({
nickname: user.nickname,
avatar: user.avatar,
gender: user.gender
}, '更新成功'));
} catch (err) {
console.error('更新资料失败:', err);
res.status(500).json(error('更新失败'));
}
}
// 获取用户信息
async getInfo(req, res) {
try {

View File

@ -9,8 +9,8 @@ const LadderUser = sequelize.define('LadderUser', {
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
comment: '关联用户ID'
allowNull: true,
comment: '关联用户ID(可为空,待微信用户登录后通过手机号关联)'
},
store_id: {
type: DataTypes.BIGINT,

View File

@ -3,9 +3,15 @@ const router = express.Router();
const userController = require('../controllers/userController');
const { authUser } = require('../middlewares/auth');
// 微信登录
// 微信登录获取openid和session_key
router.post('/login', userController.login);
// 手机号授权登录(完成注册/登录)
router.post('/phone-login', userController.phoneLogin);
// 更新用户资料(头像、昵称)
router.put('/profile', authUser, userController.updateProfile);
// 获取用户信息
router.get('/info', authUser, userController.getInfo);

View File

@ -0,0 +1,60 @@
/**
* 修复 ladder_users 表的 user_id 外键约束
* 运行: node src/scripts/fixLadderUserForeignKey.js
*/
require('dotenv').config();
const sequelize = require('../config/database');
async function fixForeignKey() {
try {
console.log('开始修复 ladder_users 表的 user_id 外键...\n');
// 1. 删除已存在的外键约束
console.log('1. 删除已存在的外键约束...');
const [constraints] = await sequelize.query(`
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_NAME = 'ladder_users'
AND COLUMN_NAME = 'user_id'
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_SCHEMA = '${process.env.DB_NAME || 'yingsha'}'
`);
for (const constraint of constraints) {
console.log(` 删除外键: ${constraint.CONSTRAINT_NAME}`);
try {
await sequelize.query(`ALTER TABLE ladder_users DROP FOREIGN KEY ${constraint.CONSTRAINT_NAME}`);
} catch (e) {
console.log(` 跳过: ${e.message}`);
}
}
// 2. 修改 user_id 列为可空
console.log('\n2. 修改 user_id 列为可空...');
await sequelize.query(`
ALTER TABLE ladder_users
MODIFY COLUMN user_id BIGINT NULL
COMMENT '关联用户ID可为空待微信用户登录后通过手机号关联'
`);
console.log(' 完成');
// 3. 重新添加外键约束(允许 SET NULL
console.log('\n3. 重新添加外键约束ON DELETE SET NULL...');
await sequelize.query(`
ALTER TABLE ladder_users
ADD CONSTRAINT fk_ladder_users_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL ON UPDATE CASCADE
`);
console.log(' 完成');
console.log('\n✅ 修复完成!现在可以重新启动服务器了。');
process.exit(0);
} catch (err) {
console.error('\n❌ 修复失败:', err.message);
process.exit(1);
}
}
fixForeignKey();