feat(天梯): 新增选手定位功能并调整挑战赛权重

- 在小程序天梯排名页添加“定位我”按钮,点击可滚动到当前用户所在位置
- 新增获取用户排名接口 `/ladder/my-rank` 用于定位计算
- 调整挑战赛权重从 1.5 降至 1.0,与日常畅打保持一致
- 新增数据库脚本 `setChallengeMatchWeightTo1.js` 用于更新历史数据
- 在管理员界面创建天梯用户时,根据所选等级自动填充默认战力值
- 修复管理员更新比赛时挑战赛权重强制设置为 1.0 的问题
- 新增天梯汇总大屏页面及相关路由
- 添加大屏比赛列表接口 `/match/display-list` 用于展示进行中和近期比赛
- 优化用户详情页的胜负场和胜率显示逻辑
- 修复小程序用户注册时的性别选择逻辑
This commit is contained in:
ethanfly 2026-02-02 03:22:36 +08:00
parent d3f0515155
commit 02937ca33c
92 changed files with 5199 additions and 1219 deletions

View File

@ -292,8 +292,8 @@ import {
wind_power_default,
zoom_in_default,
zoom_out_default
} from "./chunk-L7WLSQ4R.js";
import "./chunk-ELEEJBJQ.js";
} from "./chunk-OP4ZUAFM.js";
import "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
export {
add_location_default as AddLocation,

View File

@ -1,358 +1,346 @@
{
"hash": "02b9d14d",
"configHash": "502f42c3",
"hash": "0b0fcdca",
"configHash": "0bd4dba1",
"lockfileHash": "45a4e0fd",
"browserHash": "e5c88585",
"browserHash": "2a9cc92b",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "4df6a1b3",
"fileHash": "0ce2075a",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "43b194e5",
"fileHash": "e0339058",
"needsInterop": false
},
"dayjs": {
"src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "ee47ac98",
"fileHash": "d6bd156a",
"needsInterop": true
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "a2307ed3",
"fileHash": "eb6d080f",
"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": "ab38f2b9",
"fileHash": "23bb7a06",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "3fe9af82",
"fileHash": "3e06a4e4",
"needsInterop": false
},
"qrcode": {
"src": "../../qrcode/lib/browser.js",
"file": "qrcode.js",
"fileHash": "c89f7311",
"fileHash": "b1dc1626",
"needsInterop": true
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "0d9f3b0d",
"fileHash": "cf2fc702",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "a57ec867",
"fileHash": "731df609",
"needsInterop": false
},
"element-plus/es": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus_es.js",
"fileHash": "ffb2b783",
"fileHash": "a8df203a",
"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": "3a6cacdf",
"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": "da37a236",
"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": "eb41d819",
"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": "b7497adf",
"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": "5da3bed7",
"fileHash": "a217b400",
"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": "06dd3372",
"fileHash": "75a45c45",
"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": "d80bcc5a",
"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": "1c8a603a",
"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": "0acbe0b6",
"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": "7206bf43",
"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": "6d1afd0d",
"fileHash": "98a7fc8d",
"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": "240bd7ee",
"fileHash": "06ad125e",
"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": "41bb0592",
"fileHash": "0d145a75",
"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": "6969c6d1",
"fileHash": "59eef490",
"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": "825caf98",
"fileHash": "14e34d1b",
"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": "ff1984bb",
"fileHash": "b27e43bb",
"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": "4b4a4a1d",
"fileHash": "2a5e4249",
"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": "3c495fad",
"fileHash": "429623db",
"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": "3d5b413c",
"fileHash": "41266817",
"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": "7b9f21f2",
"fileHash": "7d5a15fd",
"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": "bf30c0ea",
"fileHash": "16c1dd4d",
"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": "9d2c02ba",
"fileHash": "61715a00",
"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": "3758817f",
"fileHash": "dadfdc54",
"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": "c0aa2e6f",
"fileHash": "fe1a5065",
"needsInterop": false
},
"element-plus/es/components/autocomplete/style/css": {
"src": "../../element-plus/es/components/autocomplete/style/css.mjs",
"file": "element-plus_es_components_autocomplete_style_css.js",
"fileHash": "e2cec4e0",
"fileHash": "18633b2b",
"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": "df646624",
"fileHash": "9560cfd1",
"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": "ee0b3ee3",
"fileHash": "28bb8b75",
"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": "3912ea08",
"fileHash": "e29b5cd8",
"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": "c7565ad8",
"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": "0fdf620f",
"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": "957ebe8c",
"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": "78632d60",
"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": "fbf0885c",
"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": "a3645e7c",
"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": "1643aa99",
"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": "8d67fb6e",
"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": "a3326872",
"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": "348ec1fe",
"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": "f4e21a7c",
"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": "e8cb2b84",
"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": "86b9c070",
"fileHash": "c5141ba1",
"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": "f73dd83d",
"fileHash": "37c51f12",
"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": "23ae7ef7",
"fileHash": "e5b97e77",
"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": "2b98b2df",
"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": "a90022be",
"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": "42b918e0",
"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": "29d647a3",
"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": "1f1a4f40",
"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": "f35fe942",
"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": "7ee42f75",
"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": "78e3b880",
"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": "585f05ee",
"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": "0dc4fea5",
"needsInterop": false
}
},
"chunks": {
"chunk-LZQ3XESL": {
"file": "chunk-LZQ3XESL.js"
"chunk-4PW274X2": {
"file": "chunk-4PW274X2.js"
},
"chunk-SB5RV2IU": {
"file": "chunk-SB5RV2IU.js"
"chunk-B2YDYSZR": {
"file": "chunk-B2YDYSZR.js"
},
"chunk-JJYIKYUB": {
"file": "chunk-JJYIKYUB.js"
"chunk-75C4BP7B": {
"file": "chunk-75C4BP7B.js"
},
"chunk-P5E57UKJ": {
"file": "chunk-P5E57UKJ.js"
"chunk-UBLR4G7Q": {
"file": "chunk-UBLR4G7Q.js"
},
"chunk-62JQAJRX": {
"file": "chunk-62JQAJRX.js"
"chunk-5KK3TTMN": {
"file": "chunk-5KK3TTMN.js"
},
"chunk-3Y6T2BWZ": {
"file": "chunk-3Y6T2BWZ.js"
"chunk-R5DNQ3QC": {
"file": "chunk-R5DNQ3QC.js"
},
"chunk-ZCVI6XFC": {
"file": "chunk-ZCVI6XFC.js"
"chunk-NKQWFVTF": {
"file": "chunk-NKQWFVTF.js"
},
"chunk-MGVC5NZO": {
"file": "chunk-MGVC5NZO.js"
"chunk-REWOA3VH": {
"file": "chunk-REWOA3VH.js"
},
"chunk-2XFUNMCG": {
"file": "chunk-2XFUNMCG.js"
"chunk-TX5YLZ4O": {
"file": "chunk-TX5YLZ4O.js"
},
"chunk-VID4RN2V": {
"file": "chunk-VID4RN2V.js"
"chunk-SMFPDFTD": {
"file": "chunk-SMFPDFTD.js"
},
"chunk-BXHXCFF5": {
"file": "chunk-BXHXCFF5.js"
"chunk-JUCAMQ7P": {
"file": "chunk-JUCAMQ7P.js"
},
"chunk-TBYZ47XG": {
"file": "chunk-TBYZ47XG.js"
"chunk-IV6PSERC": {
"file": "chunk-IV6PSERC.js"
},
"chunk-WNNLDN6V": {
"file": "chunk-WNNLDN6V.js"
"chunk-6CKQ2YFZ": {
"file": "chunk-6CKQ2YFZ.js"
},
"chunk-WDY4WTIC": {
"file": "chunk-WDY4WTIC.js"
},
"chunk-L7WLSQ4R": {
"file": "chunk-L7WLSQ4R.js"
},
"chunk-YAGW2SQC": {
"file": "chunk-YAGW2SQC.js"
"chunk-OP4ZUAFM": {
"file": "chunk-OP4ZUAFM.js"
},
"chunk-QZC7O2C6": {
"file": "chunk-QZC7O2C6.js"
},
"chunk-ELEEJBJQ": {
"file": "chunk-ELEEJBJQ.js"
"chunk-YFT6OQ5R": {
"file": "chunk-YFT6OQ5R.js"
},
"chunk-HYZ2CRGS": {
"file": "chunk-HYZ2CRGS.js"
},
"chunk-H2732BJL": {
"file": "chunk-H2732BJL.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"

File diff suppressed because one or more lines are too long

View File

@ -514,11 +514,11 @@ import {
virtualizedScrollbarProps,
watermarkProps,
zIndexContextKey
} from "./chunk-WDY4WTIC.js";
import "./chunk-L7WLSQ4R.js";
import "./chunk-YAGW2SQC.js";
} from "./chunk-6CKQ2YFZ.js";
import "./chunk-OP4ZUAFM.js";
import "./chunk-QZC7O2C6.js";
import "./chunk-ELEEJBJQ.js";
import "./chunk-HYZ2CRGS.js";
import "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
var export_dayjs = import_dayjs.default;
export {

File diff suppressed because one or more lines are too long

View File

@ -514,11 +514,11 @@ import {
virtualizedScrollbarProps,
watermarkProps,
zIndexContextKey
} from "./chunk-WDY4WTIC.js";
import "./chunk-L7WLSQ4R.js";
import "./chunk-YAGW2SQC.js";
} from "./chunk-6CKQ2YFZ.js";
import "./chunk-OP4ZUAFM.js";
import "./chunk-QZC7O2C6.js";
import "./chunk-ELEEJBJQ.js";
import "./chunk-HYZ2CRGS.js";
import "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
var export_dayjs = import_dayjs.default;
export {

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/aside/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-aside.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-aside.css";
//# sourceMappingURL=element-plus_es_components_aside_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/aside/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-aside.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-aside.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/avatar/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-avatar.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-avatar.css";
//# sourceMappingURL=element-plus_es_components_avatar_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/avatar/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-avatar.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-avatar.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,2 +1,2 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_base_style_css.js.map

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/breadcrumb-item/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-breadcrumb-item.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-breadcrumb-item.css";
//# sourceMappingURL=element-plus_es_components_breadcrumb-item_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/breadcrumb-item/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-breadcrumb-item.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-breadcrumb-item.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/breadcrumb/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-breadcrumb.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-breadcrumb.css";
//# sourceMappingURL=element-plus_es_components_breadcrumb_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/breadcrumb/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-breadcrumb.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-breadcrumb.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,3 +1,3 @@
import "./chunk-BXHXCFF5.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-SMFPDFTD.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_button_style_css.js.map

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/col/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-col.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-col.css";
//# sourceMappingURL=element-plus_es_components_col_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/col/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-col.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-col.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,9 +1,9 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/container/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-container.css";
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-aside.css";
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-footer.css";
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-header.css";
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-main.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-container.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-aside.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-footer.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-header.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-main.css";
//# sourceMappingURL=element-plus_es_components_container_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/container/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-container.css';\r\nimport 'element-plus/theme-chalk/el-aside.css';\r\nimport 'element-plus/theme-chalk/el-footer.css';\r\nimport 'element-plus/theme-chalk/el-header.css';\r\nimport 'element-plus/theme-chalk/el-main.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-container.css';\nimport 'element-plus/theme-chalk/el-aside.css';\nimport 'element-plus/theme-chalk/el-footer.css';\nimport 'element-plus/theme-chalk/el-header.css';\nimport 'element-plus/theme-chalk/el-main.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;",
"names": []
}

View File

@ -1,8 +1,8 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/dialog/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-dialog.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-dialog.css";
// node_modules/element-plus/es/components/overlay/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-overlay.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-overlay.css";
//# sourceMappingURL=element-plus_es_components_dialog_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/dialog/style/css.mjs", "../../element-plus/es/components/overlay/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-dialog.css';\r\nimport '../../overlay/style/css.mjs';\r\n//# sourceMappingURL=css.mjs.map\r\n", "import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-overlay.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-dialog.css';\nimport '../../overlay/style/css.mjs';\n//# sourceMappingURL=css.mjs.map\n", "import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-overlay.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;;;ACAP,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/dropdown-item/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-dropdown-item.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-dropdown-item.css";
//# sourceMappingURL=element-plus_es_components_dropdown-item_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/dropdown-item/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-dropdown-item.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-dropdown-item.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/dropdown-menu/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-dropdown-menu.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-dropdown-menu.css";
//# sourceMappingURL=element-plus_es_components_dropdown-menu_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/dropdown-menu/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-dropdown-menu.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-dropdown-menu.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,11 +1,11 @@
import "./chunk-MGVC5NZO.js";
import "./chunk-2XFUNMCG.js";
import "./chunk-BXHXCFF5.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-REWOA3VH.js";
import "./chunk-TX5YLZ4O.js";
import "./chunk-SMFPDFTD.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/button-group/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-button-group.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-button-group.css";
// node_modules/element-plus/es/components/dropdown/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-dropdown.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-dropdown.css";
//# sourceMappingURL=element-plus_es_components_dropdown_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/button-group/style/css.mjs", "../../element-plus/es/components/dropdown/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-button-group.css';\r\n//# sourceMappingURL=css.mjs.map\r\n", "import '../../base/style/css.mjs';\r\nimport '../../button/style/css.mjs';\r\nimport '../../button-group/style/css.mjs';\r\nimport '../../popper/style/css.mjs';\r\nimport '../../scrollbar/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-dropdown.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-button-group.css';\n//# sourceMappingURL=css.mjs.map\n", "import '../../base/style/css.mjs';\nimport '../../button/style/css.mjs';\nimport '../../button-group/style/css.mjs';\nimport '../../popper/style/css.mjs';\nimport '../../scrollbar/style/css.mjs';\nimport 'element-plus/theme-chalk/el-dropdown.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;;;AACA,OAAO;;;ACIP,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/form-item/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-form-item.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-form-item.css";
//# sourceMappingURL=element-plus_es_components_form-item_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/form-item/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-form-item.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-form-item.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/form/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-form.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-form.css";
//# sourceMappingURL=element-plus_es_components_form_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/form/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-form.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-form.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/header/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-header.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-header.css";
//# sourceMappingURL=element-plus_es_components_header_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/header/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-header.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-header.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,2 +1,2 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_icon_style_css.js.map

View File

@ -1,6 +1,6 @@
import "./chunk-TBYZ47XG.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-NKQWFVTF.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/input-number/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-input-number.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-input-number.css";
//# sourceMappingURL=element-plus_es_components_input-number_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/input-number/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport '../../input/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-input-number.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport '../../input/style/css.mjs';\nimport 'element-plus/theme-chalk/el-input-number.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;AAEA,OAAO;",
"names": []
}

View File

@ -1,3 +1,3 @@
import "./chunk-TBYZ47XG.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-NKQWFVTF.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_input_style_css.js.map

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/loading/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-loading.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-loading.css";
//# sourceMappingURL=element-plus_es_components_loading_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/loading/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-loading.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-loading.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/main/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-main.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-main.css";
//# sourceMappingURL=element-plus_es_components_main_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/main/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-main.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-main.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/menu-item/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-menu-item.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-menu-item.css";
//# sourceMappingURL=element-plus_es_components_menu-item_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/menu-item/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-menu-item.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-menu-item.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,7 +1,7 @@
import "./chunk-ZCVI6XFC.js";
import "./chunk-2XFUNMCG.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-R5DNQ3QC.js";
import "./chunk-TX5YLZ4O.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/menu/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-menu.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-menu.css";
//# sourceMappingURL=element-plus_es_components_menu_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/menu/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-menu.css';\r\nimport '../../tooltip/style/css.mjs';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-menu.css';\nimport '../../tooltip/style/css.mjs';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;;AACA,OAAO;",
"names": []
}

View File

@ -1,3 +1,3 @@
import "./chunk-SB5RV2IU.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-UBLR4G7Q.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_option_style_css.js.map

View File

@ -1,11 +1,11 @@
import "./chunk-LZQ3XESL.js";
import "./chunk-SB5RV2IU.js";
import "./chunk-P5E57UKJ.js";
import "./chunk-MGVC5NZO.js";
import "./chunk-2XFUNMCG.js";
import "./chunk-TBYZ47XG.js";
import "./chunk-WNNLDN6V.js";
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-IV6PSERC.js";
// node_modules/element-plus/es/components/pagination/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-pagination.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-pagination.css";
//# sourceMappingURL=element-plus_es_components_pagination_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/pagination/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-pagination.css';\r\nimport '../../select/style/css.mjs';\r\nimport '../../input/style/css.mjs';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-pagination.css';\nimport '../../select/style/css.mjs';\nimport '../../input/style/css.mjs';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;;;;;;AACA,OAAO;",
"names": []
}

View File

@ -1,6 +1,6 @@
import "./chunk-JJYIKYUB.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-4PW274X2.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/radio-group/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-radio-group.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-radio-group.css";
//# sourceMappingURL=element-plus_es_components_radio-group_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/radio-group/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport '../../radio/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-radio-group.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport '../../radio/style/css.mjs';\nimport 'element-plus/theme-chalk/el-radio-group.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;AAEA,OAAO;",
"names": []
}

View File

@ -1,3 +1,3 @@
import "./chunk-JJYIKYUB.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-4PW274X2.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_radio_style_css.js.map

View File

@ -1,5 +1,5 @@
import "./chunk-WNNLDN6V.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/row/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-row.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-row.css";
//# sourceMappingURL=element-plus_es_components_row_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/row/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-row.css';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-row.css';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;AACA,OAAO;",
"names": []
}

View File

@ -1,7 +1,7 @@
import "./chunk-LZQ3XESL.js";
import "./chunk-SB5RV2IU.js";
import "./chunk-P5E57UKJ.js";
import "./chunk-MGVC5NZO.js";
import "./chunk-2XFUNMCG.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-75C4BP7B.js";
import "./chunk-UBLR4G7Q.js";
import "./chunk-5KK3TTMN.js";
import "./chunk-REWOA3VH.js";
import "./chunk-TX5YLZ4O.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_select_style_css.js.map

View File

@ -1,7 +1,7 @@
import "./chunk-P5E57UKJ.js";
import "./chunk-3Y6T2BWZ.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-B2YDYSZR.js";
import "./chunk-5KK3TTMN.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/table-column/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-table-column.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-table-column.css";
//# sourceMappingURL=element-plus_es_components_table-column_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/table-column/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-table-column.css';\r\nimport '../../checkbox/style/css.mjs';\r\nimport '../../tag/style/css.mjs';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-table-column.css';\nimport '../../checkbox/style/css.mjs';\nimport '../../tag/style/css.mjs';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;;AACA,OAAO;",
"names": []
}

View File

@ -1,9 +1,9 @@
import "./chunk-3Y6T2BWZ.js";
import "./chunk-ZCVI6XFC.js";
import "./chunk-MGVC5NZO.js";
import "./chunk-2XFUNMCG.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-B2YDYSZR.js";
import "./chunk-R5DNQ3QC.js";
import "./chunk-REWOA3VH.js";
import "./chunk-TX5YLZ4O.js";
import "./chunk-IV6PSERC.js";
// node_modules/element-plus/es/components/table/style/css.mjs
import "E:/workspace/yingsa/admin/node_modules/element-plus/theme-chalk/el-table.css";
import "E:/workspace/yingsha/admin/node_modules/element-plus/theme-chalk/el-table.css";
//# sourceMappingURL=element-plus_es_components_table_style_css.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../element-plus/es/components/table/style/css.mjs"],
"sourcesContent": ["import '../../base/style/css.mjs';\r\nimport 'element-plus/theme-chalk/el-table.css';\r\nimport '../../checkbox/style/css.mjs';\r\nimport '../../tooltip/style/css.mjs';\r\nimport '../../scrollbar/style/css.mjs';\r\n//# sourceMappingURL=css.mjs.map\r\n"],
"sourcesContent": ["import '../../base/style/css.mjs';\nimport 'element-plus/theme-chalk/el-table.css';\nimport '../../checkbox/style/css.mjs';\nimport '../../tooltip/style/css.mjs';\nimport '../../scrollbar/style/css.mjs';\n//# sourceMappingURL=css.mjs.map\n"],
"mappings": ";;;;;;;AACA,OAAO;",
"names": []
}

View File

@ -1,3 +1,3 @@
import "./chunk-P5E57UKJ.js";
import "./chunk-WNNLDN6V.js";
import "./chunk-5KK3TTMN.js";
import "./chunk-IV6PSERC.js";
//# sourceMappingURL=element-plus_es_components_tag_style_css.js.map

View File

@ -1,11 +1,11 @@
import {
setupDevtoolsPlugin
} from "./chunk-VID4RN2V.js";
} from "./chunk-YFT6OQ5R.js";
import {
del,
isVue2,
set
} from "./chunk-YAGW2SQC.js";
} from "./chunk-HYZ2CRGS.js";
import {
computed,
effectScope,
@ -25,7 +25,7 @@ import {
toRefs,
unref,
watch
} from "./chunk-ELEEJBJQ.js";
} from "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
// node_modules/pinia/dist/pinia.mjs

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
import {
setupDevtoolsPlugin
} from "./chunk-VID4RN2V.js";
} from "./chunk-YFT6OQ5R.js";
import {
computed,
defineComponent,
@ -19,7 +19,7 @@ import {
unref,
watch,
watchEffect
} from "./chunk-ELEEJBJQ.js";
} from "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs

File diff suppressed because one or more lines are too long

View File

@ -170,7 +170,7 @@ import {
withMemo,
withModifiers,
withScopeId
} from "./chunk-ELEEJBJQ.js";
} from "./chunk-H2732BJL.js";
import "./chunk-G3PMV62Z.js";
export {
BaseTransition,

View File

@ -5,3 +5,6 @@ export const getPublicStores = () => request.get('/store/list')
// 获取公开天梯排名
export const getPublicRanking = (params) => request.get('/ladder/ranking', { params })
// 大屏:进行中/近7天比赛列表
export const getDisplayMatches = (params) => request.get('/match/display-list', { params })

View File

@ -87,6 +87,18 @@ const routes = [
component: () => import("@/views/display/RankingBoardOrange.vue"),
meta: { title: "天梯排行大屏(橙色)", public: true },
},
{
path: "/display/ladder-summary",
name: "LadderSummary",
component: () => import("@/views/display/LadderSummary.vue"),
meta: { title: "天梯汇总大屏", public: true },
},
{
path: "/display/ladder-summary-orange",
name: "LadderSummaryOrange",
component: () => import("@/views/display/LadderSummaryOrange.vue"),
meta: { title: "天梯汇总大屏(橙色)", public: true },
},
];
const router = createRouter({

View File

@ -59,6 +59,10 @@
<el-icon class="icon"><Monitor /></el-icon>
<span class="label">天梯大屏()</span>
</div>
<div class="quick-action-btn" @click="$router.push('/display/ladder-summary')">
<el-icon class="icon"><Monitor /></el-icon>
<span class="label">天梯汇总大屏</span>
</div>
</div>
</div>

View File

@ -0,0 +1,885 @@
<template>
<div class="ladder-summary-container">
<div class="top-bar">
<div class="time-box">{{ currentTime }}</div>
<div class="title-box">
<h1 class="main-title">英飒俱乐部 · 天梯汇总榜</h1>
<div class="sub-title">男女 · 段位 · 前15名</div>
</div>
<div class="right-controls">
<div
class="style-switcher"
@click="router.push({ path: '/display/ladder-summary-orange', query: route.query })">
<span>切换 Premium 风格</span>
</div>
<div class="store-box" @click.stop="showStoreMenu = !showStoreMenu">
<span class="label">场馆</span>
<span class="value">{{ currentStore?.name || "未选择" }}</span>
<el-icon :class="{ rotate: showStoreMenu }"><ArrowDown /></el-icon>
<transition name="fade">
<div v-if="showStoreMenu" class="store-dropdown" @click.stop>
<div
v-for="store in stores"
:key="store.id"
class="dropdown-item"
:class="{ active: currentStore?.id === store.id }"
@click="selectStore(store)">
{{ store.name }}
</div>
</div>
</transition>
</div>
</div>
</div>
<div class="content">
<div class="boards">
<div class="boards-header">
<div class="boards-title">排行榜前15名</div>
<div v-if="loadingBoards" class="boards-loading">加载中...</div>
</div>
<div class="boards-grid" ref="boardsScrollEl">
<div class="scroll-wrapper" :style="{ transform: `translateY(${boardsScrollY}px)` }">
<div class="scroll-group" ref="boardsGroup1">
<div v-for="level in levels" :key="`b1-${level.value}`" class="level-row">
<div class="level-label">{{ level.label }}</div>
<div class="gender-columns">
<div class="board-card">
<div class="board-header">
<div class="board-name">男子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(1, level.value)"
:key="`m-1-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(1, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
<div class="board-card">
<div class="board-header">
<div class="board-name">女子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(2, level.value)"
:key="`f-1-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(2, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
</div>
</div>
</div>
<div class="scroll-group">
<div v-for="level in levels" :key="`b2-${level.value}`" class="level-row">
<div class="level-label">{{ level.label }}</div>
<div class="gender-columns">
<div class="board-card">
<div class="board-header">
<div class="board-name">男子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(1, level.value)"
:key="`m-2-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(1, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
<div class="board-card">
<div class="board-header">
<div class="board-name">女子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(2, level.value)"
:key="`f-2-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(2, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="matches">
<div class="matches-header">
<div class="matches-title">进行中 / 近7天比赛</div>
<div v-if="loadingMatches" class="matches-loading">加载中...</div>
</div>
<div class="matches-list" ref="matchesScrollEl">
<div class="scroll-wrapper" :style="{ transform: `translateY(${matchesScrollY}px)` }">
<div class="scroll-group" ref="matchesGroup1">
<div v-for="m in matches" :key="`m1-${m.id}`" class="match-item">
<div class="match-top">
<div class="match-name">{{ m.name || m.matchCode }}</div>
<div class="match-badges">
<span class="badge type">{{ m.typeName }}</span>
<span class="badge status" :class="statusClass(m.status)">{{
m.statusName
}}</span>
</div>
</div>
<div class="match-meta">
<span class="store">{{ m.storeName || "-" }}</span>
<span class="time">{{ formatTime(m.startTime || m.createdAt) }}</span>
</div>
</div>
<div v-if="!loadingMatches && matches.length === 0" class="empty">
暂无比赛
</div>
</div>
<div class="scroll-group">
<div v-for="m in matches" :key="`m2-${m.id}`" class="match-item">
<div class="match-top">
<div class="match-name">{{ m.name || m.matchCode }}</div>
<div class="match-badges">
<span class="badge type">{{ m.typeName }}</span>
<span class="badge status" :class="statusClass(m.status)">{{
m.statusName
}}</span>
</div>
</div>
<div class="match-meta">
<span class="store">{{ m.storeName || "-" }}</span>
<span class="time">{{ formatTime(m.startTime || m.createdAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<span>数据实时同步 · 每30秒刷新</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import dayjs from "dayjs";
import { ArrowDown } from "@element-plus/icons-vue";
import { getPublicStores, getPublicRanking, getDisplayMatches } from "@/api/display";
const route = useRoute();
const router = useRouter();
const currentTime = ref(dayjs().format("YYYY-MM-DD HH:mm:ss"));
const stores = ref([]);
const currentStore = ref(null);
const showStoreMenu = ref(false);
const levels = ref([
{ value: 1, label: "Lv1 新锐" },
{ value: 2, label: "Lv2 精锐" },
{ value: 3, label: "Lv3 高手" },
{ value: 4, label: "Lv4 大师" },
{ value: 5, label: "Lv5 宗师" },
]);
const boards = ref({});
const matches = ref([]);
const loadingBoards = ref(false);
const loadingMatches = ref(false);
let timer = null;
let refreshTimer = null;
let handleWindowClick = null;
let boardsRaf = null;
let matchesRaf = null;
const boardsScrollEl = ref(null);
const boardsGroup1 = ref(null);
const matchesScrollEl = ref(null);
const matchesGroup1 = ref(null);
const boardsScrollY = ref(0);
const matchesScrollY = ref(0);
const updateTime = () => {
currentTime.value = dayjs().format("YYYY-MM-DD HH:mm:ss");
};
const formatTime = (t) => {
if (!t) return "-";
return dayjs(t).format("MM-DD HH:mm");
};
const statusClass = (s) => {
if (s === 1) return "ongoing";
if (s === 0) return "pending";
if (s === 2) return "finished";
return "cancelled";
};
const getBoardKey = (gender, level) => `${gender}_${level}`;
const getBoardList = (gender, level) => {
const key = getBoardKey(gender, level);
return boards.value[key] || [];
};
const fetchStores = async () => {
const res = await getPublicStores();
stores.value = res.data?.list || [];
const urlStoreId = route.query.store_id;
if (urlStoreId) {
const found = stores.value.find((s) => s.id === parseInt(urlStoreId));
if (found) currentStore.value = found;
}
if (!currentStore.value && stores.value.length > 0) {
currentStore.value = stores.value[0];
}
};
const fetchBoards = async () => {
if (!currentStore.value) return;
loadingBoards.value = true;
try {
const tasks = [];
for (const level of levels.value) {
tasks.push(
getPublicRanking({
store_id: currentStore.value.id,
gender: 1,
level: level.value,
page: 1,
pageSize: 15,
is_display: 1,
no_count: 1,
}).then((res) => ({ gender: 1, level: level.value, list: res.data?.list || [] })),
);
tasks.push(
getPublicRanking({
store_id: currentStore.value.id,
gender: 2,
level: level.value,
page: 1,
pageSize: 15,
is_display: 1,
no_count: 1,
}).then((res) => ({ gender: 2, level: level.value, list: res.data?.list || [] })),
);
}
const result = await Promise.all(tasks);
const nextBoards = {};
for (const r of result) {
nextBoards[getBoardKey(r.gender, r.level)] = r.list;
}
boards.value = nextBoards;
} finally {
loadingBoards.value = false;
}
};
const fetchMatches = async () => {
if (!currentStore.value) return;
loadingMatches.value = true;
try {
const res = await getDisplayMatches({
store_id: currentStore.value.id,
days: 7,
limit: 30,
});
matches.value = res.data || [];
} finally {
loadingMatches.value = false;
}
};
const refreshAll = async () => {
await Promise.all([fetchBoards(), fetchMatches()]);
};
const stopAutoScroll = () => {
if (boardsRaf) cancelAnimationFrame(boardsRaf);
if (matchesRaf) cancelAnimationFrame(matchesRaf);
boardsRaf = null;
matchesRaf = null;
};
const startBoardsScroll = () => {
if (!boardsScrollEl.value || !boardsGroup1.value) return;
if (loadingBoards.value) return;
const tick = () => {
if (!boardsScrollEl.value || !boardsGroup1.value) {
boardsRaf = requestAnimationFrame(tick);
return;
}
if (loadingBoards.value) {
boardsRaf = requestAnimationFrame(tick);
return;
}
const h = boardsGroup1.value.offsetHeight;
const containerH = boardsScrollEl.value.offsetHeight;
if (!h || h <= containerH) {
boardsScrollY.value = 0;
boardsRaf = requestAnimationFrame(tick);
return;
}
boardsScrollY.value -= 0.35;
if (Math.abs(boardsScrollY.value) >= h) {
boardsScrollY.value = 0;
}
boardsRaf = requestAnimationFrame(tick);
};
if (boardsRaf) cancelAnimationFrame(boardsRaf);
boardsRaf = requestAnimationFrame(tick);
};
const startMatchesScroll = () => {
if (!matchesScrollEl.value || !matchesGroup1.value) return;
if (loadingMatches.value) return;
const tick = () => {
if (!matchesScrollEl.value || !matchesGroup1.value) {
matchesRaf = requestAnimationFrame(tick);
return;
}
if (loadingMatches.value) {
matchesRaf = requestAnimationFrame(tick);
return;
}
const h = matchesGroup1.value.offsetHeight;
const containerH = matchesScrollEl.value.offsetHeight;
if (!h || h <= containerH) {
matchesScrollY.value = 0;
matchesRaf = requestAnimationFrame(tick);
return;
}
matchesScrollY.value -= 0.4;
if (Math.abs(matchesScrollY.value) >= h) {
matchesScrollY.value = 0;
}
matchesRaf = requestAnimationFrame(tick);
};
if (matchesRaf) cancelAnimationFrame(matchesRaf);
matchesRaf = requestAnimationFrame(tick);
};
const selectStore = async (store) => {
currentStore.value = store;
showStoreMenu.value = false;
router.replace({ query: { ...route.query, store_id: store.id } });
await nextTick();
stopAutoScroll();
boardsScrollY.value = 0;
matchesScrollY.value = 0;
refreshAll();
};
onMounted(async () => {
await fetchStores();
await refreshAll();
timer = setInterval(updateTime, 1000);
refreshTimer = setInterval(refreshAll, 30 * 1000);
handleWindowClick = () => {
showStoreMenu.value = false;
};
window.addEventListener("click", handleWindowClick);
nextTick(() => {
startBoardsScroll();
startMatchesScroll();
});
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
stopAutoScroll();
if (handleWindowClick) window.removeEventListener("click", handleWindowClick);
});
watch(
() => loadingBoards.value,
(v) => {
if (!v) {
nextTick(() => startBoardsScroll());
}
},
);
watch(
() => loadingMatches.value,
(v) => {
if (!v) {
nextTick(() => startMatchesScroll());
}
},
);
</script>
<style scoped lang="scss">
.ladder-summary-container {
width: 100vw;
height: 100vh;
background: #050a18;
color: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
}
.top-bar {
height: 84px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
background: linear-gradient(to bottom, rgba(10, 25, 51, 0.9), transparent);
position: relative;
z-index: 50;
border-bottom: 1px solid rgba(0, 242, 255, 0.15);
}
.time-box {
min-width: 260px;
color: #00f2ff;
font-size: 20px;
letter-spacing: 2px;
}
.title-box {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 62%;
text-align: center;
pointer-events: none;
}
.main-title {
margin: 0;
font-size: 40px;
letter-spacing: 6px;
font-weight: 900;
background: linear-gradient(to bottom, #fff, #a5d8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sub-title {
margin-top: 6px;
color: rgba(165, 216, 255, 0.8);
font-size: 14px;
letter-spacing: 2px;
}
.right-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.style-switcher {
height: 38px;
padding: 0 14px;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.2);
border-radius: 4px;
cursor: pointer;
color: rgba(165, 216, 255, 0.95);
font-size: 14px;
white-space: nowrap;
user-select: none;
transition: all 0.2s;
}
.style-switcher:hover {
background: rgba(0, 242, 255, 0.1);
border-color: #00f2ff;
color: #00f2ff;
}
.store-box {
background: rgba(0, 242, 255, 0.1);
border: 1px solid rgba(0, 242, 255, 0.3);
height: 38px;
padding: 0 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
position: relative;
user-select: none;
white-space: nowrap;
}
.store-box .label {
color: rgba(165, 216, 255, 0.85);
font-size: 14px;
line-height: 1;
}
.store-box .value {
color: #00f2ff;
font-size: 16px;
font-weight: 700;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
}
.store-box :deep(.el-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.store-box .rotate {
transform: rotate(180deg);
}
.store-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: #0a1933;
border: 1px solid #00f2ff;
border-radius: 4px;
padding: 10px 0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
z-index: 200;
}
.dropdown-item {
padding: 10px 16px;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
.dropdown-item:hover {
background: rgba(0, 242, 255, 0.1);
color: #00f2ff;
}
.dropdown-item.active {
color: #00f2ff;
background: rgba(0, 242, 255, 0.12);
font-weight: 700;
}
.content {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 360px;
gap: 18px;
padding: 18px 22px 14px;
}
.boards,
.matches {
min-height: 0;
border: 1px solid rgba(0, 242, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
}
.boards-header,
.matches-header {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: rgba(0, 242, 255, 0.08);
border-bottom: 1px solid rgba(0, 242, 255, 0.12);
}
.boards-title,
.matches-title {
font-weight: 900;
letter-spacing: 2px;
color: #00f2ff;
}
.boards-loading,
.matches-loading {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.boards-grid {
height: calc(100% - 48px);
overflow: hidden;
padding: 12px 12px 18px;
position: relative;
}
.level-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin-bottom: 12px;
}
.level-label {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 242, 255, 0.12);
border-radius: 10px;
background: rgba(0, 242, 255, 0.04);
color: rgba(165, 216, 255, 0.95);
font-weight: 800;
letter-spacing: 1px;
}
.gender-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.board-card {
border: 1px solid rgba(0, 242, 255, 0.1);
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.board-header {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: rgba(0, 242, 255, 0.07);
border-bottom: 1px solid rgba(0, 242, 255, 0.1);
}
.board-name {
font-weight: 900;
color: #00f2ff;
letter-spacing: 1px;
}
.board-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.board-list {
padding: 6px 0;
}
.board-item {
display: grid;
grid-template-columns: 46px 1fr 72px;
gap: 10px;
padding: 6px 12px;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.board-item:last-child {
border-bottom: none;
}
.rank {
color: rgba(255, 255, 255, 0.75);
font-weight: 800;
text-align: center;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.score {
text-align: right;
color: #00f2ff;
font-weight: 900;
font-family: "Arial Black";
}
.matches-list {
height: calc(100% - 48px);
overflow: hidden;
padding: 10px 12px 14px;
position: relative;
}
.scroll-wrapper {
will-change: transform;
}
.match-item {
border: 1px solid rgba(255, 152, 0, 0.12);
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
padding: 10px 10px;
margin-bottom: 10px;
}
.match-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.match-name {
font-weight: 900;
color: rgba(255, 255, 255, 0.92);
line-height: 1.2;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
white-space: nowrap;
}
.badge.type {
color: rgba(255, 204, 128, 0.95);
border-color: rgba(255, 152, 0, 0.18);
background: rgba(255, 152, 0, 0.06);
}
.badge.status.ongoing {
color: #00f2ff;
border-color: rgba(0, 242, 255, 0.25);
background: rgba(0, 242, 255, 0.08);
}
.badge.status.pending {
color: rgba(165, 216, 255, 0.95);
border-color: rgba(165, 216, 255, 0.18);
background: rgba(165, 216, 255, 0.06);
}
.badge.status.finished {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.badge.status.cancelled {
color: rgba(255, 90, 90, 0.9);
border-color: rgba(255, 90, 90, 0.18);
background: rgba(255, 90, 90, 0.08);
}
.match-meta {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.empty {
padding: 12px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
}
.footer {
height: 38px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.65);
border-top: 1px solid rgba(0, 242, 255, 0.1);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,887 @@
<template>
<div class="ladder-summary-orange">
<div class="top-bar">
<div class="time-box">{{ currentTime }}</div>
<div class="title-box">
<h1 class="main-title">英飒俱乐部 · 天梯汇总榜</h1>
<div class="sub-title">男女 · 段位 · 前15名</div>
</div>
<div class="right-controls">
<div
class="style-switcher"
@click="router.push({ path: '/display/ladder-summary', query: route.query })">
<span>切换经典风格</span>
</div>
<div class="store-box" @click.stop="showStoreMenu = !showStoreMenu">
<span class="label">场馆</span>
<span class="value">{{ currentStore?.name || "未选择" }}</span>
<el-icon :class="{ rotate: showStoreMenu }"><ArrowDown /></el-icon>
<transition name="fade">
<div v-if="showStoreMenu" class="store-dropdown" @click.stop>
<div
v-for="store in stores"
:key="store.id"
class="dropdown-item"
:class="{ active: currentStore?.id === store.id }"
@click="selectStore(store)">
{{ store.name }}
</div>
</div>
</transition>
</div>
</div>
</div>
<div class="content">
<div class="boards">
<div class="boards-header">
<div class="boards-title">排行榜前15名</div>
<div v-if="loadingBoards" class="boards-loading">加载中...</div>
</div>
<div class="boards-grid" ref="boardsScrollEl">
<div class="scroll-wrapper" :style="{ transform: `translateY(${boardsScrollY}px)` }">
<div class="scroll-group" ref="boardsGroup1">
<div v-for="level in levels" :key="`b1-${level.value}`" class="level-row">
<div class="level-label">{{ level.label }}</div>
<div class="gender-columns">
<div class="board-card">
<div class="board-header">
<div class="board-name">男子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(1, level.value)"
:key="`m-1-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(1, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
<div class="board-card">
<div class="board-header">
<div class="board-name">女子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(2, level.value)"
:key="`f-1-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(2, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
</div>
</div>
</div>
<div class="scroll-group">
<div v-for="level in levels" :key="`b2-${level.value}`" class="level-row">
<div class="level-label">{{ level.label }}</div>
<div class="gender-columns">
<div class="board-card">
<div class="board-header">
<div class="board-name">男子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(1, level.value)"
:key="`m-2-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(1, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
<div class="board-card">
<div class="board-header">
<div class="board-name">女子榜</div>
<div class="board-meta">Top 15</div>
</div>
<div class="board-list">
<div
v-for="item in getBoardList(2, level.value)"
:key="`f-2-${level.value}-${item.id}`"
class="board-item">
<div class="rank">{{ item.rank }}</div>
<div class="name">{{ item.realName || item.nickname || "-" }}</div>
<div class="score">{{ item.powerScore }}</div>
</div>
<div
v-if="!loadingBoards && getBoardList(2, level.value).length === 0"
class="empty">
暂无数据
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="matches">
<div class="matches-header">
<div class="matches-title">进行中 / 近7天比赛</div>
<div v-if="loadingMatches" class="matches-loading">加载中...</div>
</div>
<div class="matches-list" ref="matchesScrollEl">
<div class="scroll-wrapper" :style="{ transform: `translateY(${matchesScrollY}px)` }">
<div class="scroll-group" ref="matchesGroup1">
<div v-for="m in matches" :key="`m1-${m.id}`" class="match-item">
<div class="match-top">
<div class="match-name">{{ m.name || m.matchCode }}</div>
<div class="match-badges">
<span class="badge type">{{ m.typeName }}</span>
<span class="badge status" :class="statusClass(m.status)">{{
m.statusName
}}</span>
</div>
</div>
<div class="match-meta">
<span class="store">{{ m.storeName || "-" }}</span>
<span class="time">{{ formatTime(m.startTime || m.createdAt) }}</span>
</div>
</div>
<div v-if="!loadingMatches && matches.length === 0" class="empty">
暂无比赛
</div>
</div>
<div class="scroll-group">
<div v-for="m in matches" :key="`m2-${m.id}`" class="match-item">
<div class="match-top">
<div class="match-name">{{ m.name || m.matchCode }}</div>
<div class="match-badges">
<span class="badge type">{{ m.typeName }}</span>
<span class="badge status" :class="statusClass(m.status)">{{
m.statusName
}}</span>
</div>
</div>
<div class="match-meta">
<span class="store">{{ m.storeName || "-" }}</span>
<span class="time">{{ formatTime(m.startTime || m.createdAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<span>数据实时同步 · 每30秒刷新</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import dayjs from "dayjs";
import { ArrowDown } from "@element-plus/icons-vue";
import { getPublicStores, getPublicRanking, getDisplayMatches } from "@/api/display";
const route = useRoute();
const router = useRouter();
const currentTime = ref(dayjs().format("YYYY-MM-DD HH:mm:ss"));
const stores = ref([]);
const currentStore = ref(null);
const showStoreMenu = ref(false);
const levels = ref([
{ value: 1, label: "Lv1 新锐" },
{ value: 2, label: "Lv2 精锐" },
{ value: 3, label: "Lv3 高手" },
{ value: 4, label: "Lv4 大师" },
{ value: 5, label: "Lv5 宗师" },
]);
const boards = ref({});
const matches = ref([]);
const loadingBoards = ref(false);
const loadingMatches = ref(false);
let timer = null;
let refreshTimer = null;
let handleWindowClick = null;
let boardsRaf = null;
let matchesRaf = null;
const boardsScrollEl = ref(null);
const boardsGroup1 = ref(null);
const matchesScrollEl = ref(null);
const matchesGroup1 = ref(null);
const boardsScrollY = ref(0);
const matchesScrollY = ref(0);
const updateTime = () => {
currentTime.value = dayjs().format("YYYY-MM-DD HH:mm:ss");
};
const formatTime = (t) => {
if (!t) return "-";
return dayjs(t).format("MM-DD HH:mm");
};
const statusClass = (s) => {
if (s === 1) return "ongoing";
if (s === 0) return "pending";
if (s === 2) return "finished";
return "cancelled";
};
const getBoardKey = (gender, level) => `${gender}_${level}`;
const getBoardList = (gender, level) => {
const key = getBoardKey(gender, level);
return boards.value[key] || [];
};
const fetchStores = async () => {
const res = await getPublicStores();
stores.value = res.data?.list || [];
const urlStoreId = route.query.store_id;
if (urlStoreId) {
const found = stores.value.find((s) => s.id === parseInt(urlStoreId));
if (found) currentStore.value = found;
}
if (!currentStore.value && stores.value.length > 0) {
currentStore.value = stores.value[0];
}
};
const fetchBoards = async () => {
if (!currentStore.value) return;
loadingBoards.value = true;
try {
const tasks = [];
for (const level of levels.value) {
tasks.push(
getPublicRanking({
store_id: currentStore.value.id,
gender: 1,
level: level.value,
page: 1,
pageSize: 15,
is_display: 1,
no_count: 1,
}).then((res) => ({ gender: 1, level: level.value, list: res.data?.list || [] })),
);
tasks.push(
getPublicRanking({
store_id: currentStore.value.id,
gender: 2,
level: level.value,
page: 1,
pageSize: 15,
is_display: 1,
no_count: 1,
}).then((res) => ({ gender: 2, level: level.value, list: res.data?.list || [] })),
);
}
const result = await Promise.all(tasks);
const nextBoards = {};
for (const r of result) {
nextBoards[getBoardKey(r.gender, r.level)] = r.list;
}
boards.value = nextBoards;
} finally {
loadingBoards.value = false;
}
};
const fetchMatches = async () => {
if (!currentStore.value) return;
loadingMatches.value = true;
try {
const res = await getDisplayMatches({
store_id: currentStore.value.id,
days: 7,
limit: 30,
});
matches.value = res.data || [];
} finally {
loadingMatches.value = false;
}
};
const refreshAll = async () => {
await Promise.all([fetchBoards(), fetchMatches()]);
};
const stopAutoScroll = () => {
if (boardsRaf) cancelAnimationFrame(boardsRaf);
if (matchesRaf) cancelAnimationFrame(matchesRaf);
boardsRaf = null;
matchesRaf = null;
};
const startBoardsScroll = () => {
if (!boardsScrollEl.value || !boardsGroup1.value) return;
if (loadingBoards.value) return;
const tick = () => {
if (!boardsScrollEl.value || !boardsGroup1.value) {
boardsRaf = requestAnimationFrame(tick);
return;
}
if (loadingBoards.value) {
boardsRaf = requestAnimationFrame(tick);
return;
}
const h = boardsGroup1.value.offsetHeight;
const containerH = boardsScrollEl.value.offsetHeight;
if (!h || h <= containerH) {
boardsScrollY.value = 0;
boardsRaf = requestAnimationFrame(tick);
return;
}
boardsScrollY.value -= 0.35;
if (Math.abs(boardsScrollY.value) >= h) {
boardsScrollY.value = 0;
}
boardsRaf = requestAnimationFrame(tick);
};
if (boardsRaf) cancelAnimationFrame(boardsRaf);
boardsRaf = requestAnimationFrame(tick);
};
const startMatchesScroll = () => {
if (!matchesScrollEl.value || !matchesGroup1.value) return;
if (loadingMatches.value) return;
const tick = () => {
if (!matchesScrollEl.value || !matchesGroup1.value) {
matchesRaf = requestAnimationFrame(tick);
return;
}
if (loadingMatches.value) {
matchesRaf = requestAnimationFrame(tick);
return;
}
const h = matchesGroup1.value.offsetHeight;
const containerH = matchesScrollEl.value.offsetHeight;
if (!h || h <= containerH) {
matchesScrollY.value = 0;
matchesRaf = requestAnimationFrame(tick);
return;
}
matchesScrollY.value -= 0.4;
if (Math.abs(matchesScrollY.value) >= h) {
matchesScrollY.value = 0;
}
matchesRaf = requestAnimationFrame(tick);
};
if (matchesRaf) cancelAnimationFrame(matchesRaf);
matchesRaf = requestAnimationFrame(tick);
};
const selectStore = async (store) => {
currentStore.value = store;
showStoreMenu.value = false;
router.replace({ query: { ...route.query, store_id: store.id } });
await nextTick();
stopAutoScroll();
boardsScrollY.value = 0;
matchesScrollY.value = 0;
refreshAll();
};
onMounted(async () => {
await fetchStores();
await refreshAll();
timer = setInterval(updateTime, 1000);
refreshTimer = setInterval(refreshAll, 30 * 1000);
handleWindowClick = () => {
showStoreMenu.value = false;
};
window.addEventListener("click", handleWindowClick);
nextTick(() => {
startBoardsScroll();
startMatchesScroll();
});
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
stopAutoScroll();
if (handleWindowClick) window.removeEventListener("click", handleWindowClick);
});
watch(
() => loadingBoards.value,
(v) => {
if (!v) {
nextTick(() => startBoardsScroll());
}
},
);
watch(
() => loadingMatches.value,
(v) => {
if (!v) {
nextTick(() => startMatchesScroll());
}
},
);
</script>
<style scoped lang="scss">
.ladder-summary-orange {
width: 100vw;
height: 100vh;
background: #0a0806;
color: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
}
.top-bar {
height: 84px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
background: linear-gradient(to bottom, rgba(18, 12, 8, 0.92), transparent);
position: relative;
z-index: 50;
border-bottom: 1px solid rgba(255, 152, 0, 0.18);
}
.time-box {
min-width: 260px;
color: #ff9800;
font-size: 20px;
letter-spacing: 2px;
font-family: monospace;
}
.title-box {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 62%;
text-align: center;
pointer-events: none;
}
.main-title {
margin: 0;
font-size: 40px;
letter-spacing: 6px;
font-weight: 900;
background: linear-gradient(to bottom, #fff, #ffcc80);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sub-title {
margin-top: 6px;
color: rgba(255, 204, 128, 0.75);
font-size: 14px;
letter-spacing: 2px;
}
.right-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.style-switcher {
height: 38px;
padding: 0 14px;
display: flex;
align-items: center;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.28);
border-radius: 4px;
cursor: pointer;
color: rgba(255, 204, 128, 0.95);
font-size: 14px;
white-space: nowrap;
user-select: none;
transition: all 0.2s;
}
.style-switcher:hover {
background: rgba(255, 152, 0, 0.18);
border-color: #ff9800;
color: #ff9800;
}
.store-box {
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.28);
height: 38px;
padding: 0 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
position: relative;
user-select: none;
white-space: nowrap;
}
.store-box .label {
color: rgba(255, 255, 255, 0.65);
font-size: 14px;
line-height: 1;
}
.store-box .value {
color: #ff9800;
font-size: 16px;
font-weight: 800;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
}
.store-box :deep(.el-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.store-box .rotate {
transform: rotate(180deg);
}
.store-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: #1a140f;
border: 1px solid #ff9800;
border-radius: 4px;
padding: 10px 0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
z-index: 200;
}
.dropdown-item {
padding: 10px 16px;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
.dropdown-item:hover {
background: rgba(255, 152, 0, 0.12);
color: #ff9800;
}
.dropdown-item.active {
color: #ff9800;
background: rgba(255, 152, 0, 0.15);
font-weight: 800;
}
.content {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 360px;
gap: 18px;
padding: 18px 22px 14px;
}
.boards,
.matches {
min-height: 0;
border: 1px solid rgba(255, 152, 0, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.55);
}
.boards-header,
.matches-header {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: rgba(255, 152, 0, 0.08);
border-bottom: 1px solid rgba(255, 152, 0, 0.12);
}
.boards-title,
.matches-title {
font-weight: 900;
letter-spacing: 2px;
color: #ff9800;
}
.boards-loading,
.matches-loading {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.boards-grid {
height: calc(100% - 48px);
overflow: hidden;
padding: 12px 12px 18px;
position: relative;
}
.level-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin-bottom: 12px;
}
.level-label {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 152, 0, 0.14);
border-radius: 10px;
background: rgba(255, 152, 0, 0.04);
color: rgba(255, 204, 128, 0.95);
font-weight: 800;
letter-spacing: 1px;
}
.gender-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.board-card {
border: 1px solid rgba(255, 152, 0, 0.12);
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.board-header {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: rgba(255, 152, 0, 0.08);
border-bottom: 1px solid rgba(255, 152, 0, 0.12);
}
.board-name {
font-weight: 900;
color: #ff9800;
letter-spacing: 1px;
}
.board-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.board-list {
padding: 6px 0;
}
.board-item {
display: grid;
grid-template-columns: 46px 1fr 72px;
gap: 10px;
padding: 6px 12px;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.board-item:last-child {
border-bottom: none;
}
.rank {
color: rgba(255, 255, 255, 0.7);
font-weight: 800;
text-align: center;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, 0.92);
font-weight: 600;
}
.score {
text-align: right;
color: #ff9800;
font-weight: 900;
font-family: "Arial Black";
}
.matches-list {
height: calc(100% - 48px);
overflow: hidden;
padding: 10px 12px 14px;
position: relative;
}
.match-item {
border: 1px solid rgba(255, 152, 0, 0.16);
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
padding: 10px 10px;
margin-bottom: 10px;
}
.match-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.match-name {
font-weight: 900;
color: rgba(255, 255, 255, 0.92);
line-height: 1.2;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
white-space: nowrap;
}
.badge.type {
color: rgba(255, 204, 128, 0.95);
border-color: rgba(255, 152, 0, 0.18);
background: rgba(255, 152, 0, 0.08);
}
.badge.status.ongoing {
color: #ff9800;
border-color: rgba(255, 152, 0, 0.35);
background: rgba(255, 152, 0, 0.14);
}
.badge.status.pending {
color: rgba(255, 204, 128, 0.95);
border-color: rgba(255, 204, 128, 0.2);
background: rgba(255, 204, 128, 0.08);
}
.badge.status.finished {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.badge.status.cancelled {
color: rgba(255, 90, 90, 0.9);
border-color: rgba(255, 90, 90, 0.18);
background: rgba(255, 90, 90, 0.08);
}
.match-meta {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.empty {
padding: 12px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
}
.footer {
height: 38px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.65);
border-top: 1px solid rgba(255, 152, 0, 0.12);
}
.scroll-wrapper {
will-change: transform;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -19,7 +19,23 @@
<el-icon><Monitor /></el-icon>
<span>切换 Premium 风格</span>
</div>
<div class="store-selector" @click="showStoreMenu = !showStoreMenu">
<div class="gender-toggle" @click.stop>
<div
class="toggle-item"
:class="{ active: genderMode === 'split' }"
@click.stop="setGenderMode('split')">
男女分榜
</div>
<div
class="toggle-item"
:class="{ active: genderMode === 'all' }"
@click.stop="setGenderMode('all')">
全部
</div>
</div>
<div
class="store-selector"
@click.stop="showStoreMenu = !showStoreMenu">
<span class="store-label">当前场馆:</span>
<span class="store-name">{{ currentStore?.name || "请选择" }}</span>
<el-icon class="arrow-icon" :class="{ rotate: showStoreMenu }"
@ -44,7 +60,7 @@
</div>
<!-- 主体内容区 -->
<div class="board-body">
<div class="board-body" :class="{ split: genderMode === 'split' }">
<!-- 左右装饰线条 -->
<div class="side-decoration left"></div>
<div class="side-decoration right"></div>
@ -58,7 +74,257 @@
</transition>
<!-- 排行榜主体 -->
<div class="ranking-list-wrapper">
<div
v-if="genderMode === 'split'"
class="ranking-list-wrapper split-wrapper">
<div class="split-column">
<div class="gender-title">男子榜</div>
<div class="list-header">
<div class="col-rank">排名</div>
<div class="col-player">选手</div>
<div class="col-level">等级</div>
<div class="col-matches">场次/胜率</div>
<div class="col-score">战力值</div>
</div>
<div class="list-body">
<div
class="scroll-content"
:style="{ transform: `translateY(${scrollYMale}px)` }">
<div
v-for="player in rankingListMale"
:key="'m1-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="col-rank">
<div v-if="player.rank <= 3" class="rank-badge">
<span class="rank-num">{{ player.rank }}</span>
</div>
<span v-else class="rank-num">{{ player.rank }}</span>
</div>
<div class="col-player">
<div class="player-info">
<el-avatar
:size="50"
:src="player.avatar"
class="player-avatar">
{{ player.realName?.[0] || player.nickname?.[0] || "?" }}
</el-avatar>
<div class="player-names">
<span class="real-name">{{ player.realName }}</span>
<span class="nickname">{{ player.nickname }}</span>
</div>
</div>
</div>
<div class="col-level">
<span :class="['level-tag', 'lv' + player.level]">
{{ player.levelName }}
</span>
</div>
<div class="col-matches">
<div class="match-stats">
<span class="count">{{ player.matchCount }}</span>
<span class="rate">{{ player.winRate }}% 胜率</span>
</div>
</div>
<div class="col-score">
<div class="score-wrapper">
<span class="score-value">{{ player.powerScore }}</span>
<div class="score-bar">
<div
class="bar-fill"
:style="{
width: (player.powerScore / 3000) * 100 + '%',
}"></div>
</div>
</div>
</div>
</div>
<div
v-for="player in rankingListMale"
:key="'m2-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="col-rank">
<div v-if="player.rank <= 3" class="rank-badge">
<span class="rank-num">{{ player.rank }}</span>
</div>
<span v-else class="rank-num">{{ player.rank }}</span>
</div>
<div class="col-player">
<div class="player-info">
<el-avatar
:size="50"
:src="player.avatar"
class="player-avatar">
{{ player.realName?.[0] || player.nickname?.[0] || "?" }}
</el-avatar>
<div class="player-names">
<span class="real-name">{{ player.realName }}</span>
<span class="nickname">{{ player.nickname }}</span>
</div>
</div>
</div>
<div class="col-level">
<span :class="['level-tag', 'lv' + player.level]">
{{ player.levelName }}
</span>
</div>
<div class="col-matches">
<div class="match-stats">
<span class="count">{{ player.matchCount }}</span>
<span class="rate">{{ player.winRate }}% 胜率</span>
</div>
</div>
<div class="col-score">
<div class="score-wrapper">
<span class="score-value">{{ player.powerScore }}</span>
<div class="score-bar">
<div
class="bar-fill"
:style="{
width: (player.powerScore / 3000) * 100 + '%',
}"></div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!loading && rankingListMale.length === 0"
class="empty-state">
暂无排行数据
</div>
</div>
</div>
<div class="split-column">
<div class="gender-title">女子榜</div>
<div class="list-header">
<div class="col-rank">排名</div>
<div class="col-player">选手</div>
<div class="col-level">等级</div>
<div class="col-matches">场次/胜率</div>
<div class="col-score">战力值</div>
</div>
<div class="list-body">
<div
class="scroll-content"
:style="{ transform: `translateY(${scrollYFemale}px)` }">
<div
v-for="player in rankingListFemale"
:key="'f1-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="col-rank">
<div v-if="player.rank <= 3" class="rank-badge">
<span class="rank-num">{{ player.rank }}</span>
</div>
<span v-else class="rank-num">{{ player.rank }}</span>
</div>
<div class="col-player">
<div class="player-info">
<el-avatar
:size="50"
:src="player.avatar"
class="player-avatar">
{{ player.realName?.[0] || player.nickname?.[0] || "?" }}
</el-avatar>
<div class="player-names">
<span class="real-name">{{ player.realName }}</span>
<span class="nickname">{{ player.nickname }}</span>
</div>
</div>
</div>
<div class="col-level">
<span :class="['level-tag', 'lv' + player.level]">
{{ player.levelName }}
</span>
</div>
<div class="col-matches">
<div class="match-stats">
<span class="count">{{ player.matchCount }}</span>
<span class="rate">{{ player.winRate }}% 胜率</span>
</div>
</div>
<div class="col-score">
<div class="score-wrapper">
<span class="score-value">{{ player.powerScore }}</span>
<div class="score-bar">
<div
class="bar-fill"
:style="{
width: (player.powerScore / 3000) * 100 + '%',
}"></div>
</div>
</div>
</div>
</div>
<div
v-for="player in rankingListFemale"
:key="'f2-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="col-rank">
<div v-if="player.rank <= 3" class="rank-badge">
<span class="rank-num">{{ player.rank }}</span>
</div>
<span v-else class="rank-num">{{ player.rank }}</span>
</div>
<div class="col-player">
<div class="player-info">
<el-avatar
:size="50"
:src="player.avatar"
class="player-avatar">
{{ player.realName?.[0] || player.nickname?.[0] || "?" }}
</el-avatar>
<div class="player-names">
<span class="real-name">{{ player.realName }}</span>
<span class="nickname">{{ player.nickname }}</span>
</div>
</div>
</div>
<div class="col-level">
<span :class="['level-tag', 'lv' + player.level]">
{{ player.levelName }}
</span>
</div>
<div class="col-matches">
<div class="match-stats">
<span class="count">{{ player.matchCount }}</span>
<span class="rate">{{ player.winRate }}% 胜率</span>
</div>
</div>
<div class="col-score">
<div class="score-wrapper">
<span class="score-value">{{ player.powerScore }}</span>
<div class="score-bar">
<div
class="bar-fill"
:style="{
width: (player.powerScore / 3000) * 100 + '%',
}"></div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!loading && rankingListFemale.length === 0"
class="empty-state">
暂无排行数据
</div>
</div>
</div>
</div>
<div v-else class="ranking-list-wrapper">
<div class="list-header">
<div class="col-rank">排名</div>
<div class="col-player">选手</div>
@ -70,7 +336,7 @@
<div class="list-body" ref="scrollContainer">
<div
class="scroll-content"
:style="{ transform: `translateY(${scrollY}px)` }">
:style="{ transform: `translateY(${scrollYAll}px)` }">
<div
v-for="(player, index) in rankingList"
:key="'p1-' + player.id"
@ -217,15 +483,23 @@
const currentTime = ref(dayjs().format("YYYY-MM-DD HH:mm:ss"));
const stores = ref([]);
const currentStore = ref(null);
const genderMode = ref(route.query.gender_mode === "all" ? "all" : "split");
const rankingList = ref([]);
const rankingListMale = ref([]);
const rankingListFemale = ref([]);
const loading = ref(false);
const showStoreMenu = ref(false);
const scrollContainer = ref(null);
const isRefreshing = ref(false);
let timer = null;
let refreshTimer = null;
const scrollReqId = ref(null);
const scrollY = ref(0); // 使 transform
let handleWindowClick = null;
const scrollReqIdAll = ref(null);
const scrollReqIdMale = ref(null);
const scrollReqIdFemale = ref(null);
const scrollYAll = ref(0);
const scrollYMale = ref(0);
const scrollYFemale = ref(0);
//
const updateTime = () => {
@ -233,31 +507,31 @@
};
// (使 Transform )
const startContinuousScroll = () => {
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
const startContinuousScroll = (listRef, scrollYRef, reqIdRef) => {
if (reqIdRef.value) cancelAnimationFrame(reqIdRef.value);
const scroll = () => {
if (isRefreshing.value || rankingList.value.length === 0) {
scrollReqId.value = requestAnimationFrame(scroll);
if (isRefreshing.value || listRef.value.length === 0) {
reqIdRef.value = requestAnimationFrame(scroll);
return;
}
// (使)
scrollY.value -= 0.6;
scrollYRef.value -= 0.6;
// 100 * (90px + 12px)
const singleSetHeight = rankingList.value.length * 102;
const singleSetHeight = listRef.value.length * 102;
// 0
if (Math.abs(scrollY.value) >= singleSetHeight) {
scrollY.value = 0;
if (Math.abs(scrollYRef.value) >= singleSetHeight) {
scrollYRef.value = 0;
}
scrollReqId.value = requestAnimationFrame(scroll);
reqIdRef.value = requestAnimationFrame(scroll);
};
setTimeout(() => {
scrollReqId.value = requestAnimationFrame(scroll);
reqIdRef.value = requestAnimationFrame(scroll);
}, 500);
};
@ -289,28 +563,78 @@
}
};
const setGenderMode = (mode) => {
if (mode !== "split" && mode !== "all") return;
if (genderMode.value === mode) return;
genderMode.value = mode;
router.replace({ query: { ...route.query, gender_mode: mode } });
fetchRanking();
};
//
const fetchRanking = async () => {
if (!currentStore.value) return;
isRefreshing.value = true;
try {
if (genderMode.value === "split") {
const [maleRes, femaleRes] = await Promise.all([
getPublicRanking({
store_id: currentStore.value.id,
gender: 1,
pageSize: 100,
is_display: 1,
}),
getPublicRanking({
store_id: currentStore.value.id,
gender: 2,
pageSize: 100,
is_display: 1,
}),
]);
rankingListMale.value = maleRes.data?.list || [];
rankingListFemale.value = femaleRes.data?.list || [];
scrollYMale.value = 0;
scrollYFemale.value = 0;
if (
rankingListMale.value.length > 0 ||
rankingListFemale.value.length > 0
) {
nextTick(() => {
if (rankingListMale.value.length > 0) {
startContinuousScroll(
rankingListMale,
scrollYMale,
scrollReqIdMale,
);
}
if (rankingListFemale.value.length > 0) {
startContinuousScroll(
rankingListFemale,
scrollYFemale,
scrollReqIdFemale,
);
}
});
}
} else {
const res = await getPublicRanking({
store_id: currentStore.value.id,
pageSize: 100, // 100
is_display: 1, //
});
//
const newList = res.data?.list || [];
rankingList.value = newList;
rankingList.value = res.data?.list || [];
scrollYAll.value = 0;
//
if (rankingList.value.length > 0) {
nextTick(() => {
startContinuousScroll();
startContinuousScroll(rankingList, scrollYAll, scrollReqIdAll);
});
}
}
} catch (err) {
console.error("获取排名失败:", err);
} finally {
@ -326,7 +650,13 @@
currentStore.value = store;
showStoreMenu.value = false;
// URL 便/
router.replace({ query: { ...route.query, store_id: store.id } });
router.replace({
query: {
...route.query,
store_id: store.id,
gender_mode: genderMode.value,
},
});
fetchRanking();
};
@ -336,24 +666,27 @@
refreshTimer = setInterval(fetchRanking, 30 * 1000); // 30
//
window.addEventListener("click", () => {
handleWindowClick = () => {
showStoreMenu.value = false;
});
};
window.addEventListener("click", handleWindowClick);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
window.removeEventListener("click", () => {
showStoreMenu.value = false;
});
if (scrollReqIdAll.value) cancelAnimationFrame(scrollReqIdAll.value);
if (scrollReqIdMale.value) cancelAnimationFrame(scrollReqIdMale.value);
if (scrollReqIdFemale.value) cancelAnimationFrame(scrollReqIdFemale.value);
if (handleWindowClick) {
window.removeEventListener("click", handleWindowClick);
}
});
// URL
watch(
() => route.query.store_id,
(newId) => {
() => [route.query.store_id, route.query.gender_mode],
([newId, newMode]) => {
if (newId && stores.value.length > 0) {
const found = stores.value.find((s) => s.id === parseInt(newId));
if (found && found.id !== currentStore.value?.id) {
@ -361,6 +694,12 @@
fetchRanking();
}
}
const mode = newMode === "all" ? "all" : "split";
if (mode !== genderMode.value) {
genderMode.value = mode;
fetchRanking();
}
},
);
</script>
@ -428,11 +767,12 @@
justify-content: space-between;
padding: 0 40px;
position: relative;
z-index: 10;
z-index: 100;
background: linear-gradient(to bottom, rgba(10, 25, 51, 0.8), transparent);
.header-left {
flex: 1;
flex: 0 0 auto;
min-width: 240px;
.current-time {
font-size: 24px;
font-weight: 300;
@ -443,9 +783,14 @@
}
.header-center {
flex: 2;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 60%;
display: flex;
justify-content: center;
pointer-events: none;
.title-bg {
position: relative;
@ -482,11 +827,14 @@
}
.header-right {
flex: 1;
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
flex-wrap: nowrap;
position: relative;
z-index: 30;
.style-switcher {
display: flex;
@ -494,12 +842,26 @@
gap: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.2);
padding: 8px 15px;
height: 38px;
padding: 0 14px;
border-radius: 4px;
cursor: pointer;
color: #a5d8ff;
font-size: 14px;
transition: all 0.3s;
box-sizing: border-box;
flex-shrink: 0;
white-space: nowrap;
span {
line-height: 1;
}
:deep(.el-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
}
&:hover {
background: rgba(0, 242, 255, 0.1);
@ -509,10 +871,48 @@
}
}
.gender-toggle {
display: flex;
height: 38px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.2);
border-radius: 4px;
overflow: hidden;
box-sizing: border-box;
flex-shrink: 0;
.toggle-item {
display: flex;
align-items: center;
padding: 0 12px;
font-size: 14px;
color: #a5d8ff;
cursor: pointer;
user-select: none;
transition: all 0.2s;
border-right: 1px solid rgba(0, 242, 255, 0.12);
line-height: 1;
&:last-child {
border-right: none;
}
&.active {
color: #00f2ff;
background: rgba(0, 242, 255, 0.12);
}
&:hover {
background: rgba(0, 242, 255, 0.08);
}
}
}
.store-selector {
background: rgba(0, 242, 255, 0.1);
border: 1px solid rgba(0, 242, 255, 0.3);
padding: 8px 20px;
height: 38px;
padding: 0 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
@ -520,6 +920,10 @@
gap: 12px;
position: relative;
transition: all 0.3s;
box-sizing: border-box;
user-select: none;
z-index: 40;
flex-shrink: 0;
&:hover {
background: rgba(0, 242, 255, 0.2);
@ -529,15 +933,24 @@
.store-label {
font-size: 14px;
color: #a5d8ff;
line-height: 1;
}
.store-name {
font-size: 20px;
font-size: 16px;
font-weight: 600;
color: #00f2ff;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
}
.arrow-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.3s;
&.rotate {
transform: rotate(180deg);
@ -546,7 +959,7 @@
.store-menu {
position: absolute;
top: 110%;
top: calc(100% + 8px);
right: 0;
width: 200px;
background: #0a1933;
@ -554,12 +967,13 @@
border-radius: 4px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 10px 0;
z-index: 100;
z-index: 200;
.store-item {
padding: 12px 20px;
transition: all 0.3s;
font-size: 16px;
cursor: pointer;
&:hover {
background: rgba(0, 242, 255, 0.1);
@ -586,6 +1000,12 @@
flex-direction: column;
min-height: 0; /* 确保子元素 flex 正确 */
&.split {
flex-direction: row;
gap: 24px;
padding: 20px 40px;
}
.side-decoration {
position: absolute;
top: 50%;
@ -641,7 +1061,7 @@
letter-spacing: 3px;
border-bottom: 2px solid rgba(0, 242, 255, 0.3);
position: relative;
z-index: 10;
z-index: 1;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
div {
@ -662,6 +1082,44 @@
}
}
.split-wrapper {
display: flex;
flex-direction: row;
gap: 24px;
padding: 18px;
box-sizing: border-box;
.split-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.gender-title {
font-size: 18px;
font-weight: 700;
color: #00f2ff;
letter-spacing: 2px;
margin: 0 0 10px 6px;
text-shadow: 0 0 12px rgba(0, 242, 255, 0.25);
opacity: 0.9;
}
.list-header {
border-radius: 10px;
margin-bottom: 10px;
}
.list-body {
border-radius: 10px;
background: rgba(255, 255, 255, 0.01);
border: 1px solid rgba(0, 242, 255, 0.12);
overflow: hidden;
padding: 10px 0;
}
}
.scroll-content {
display: flex;
flex-direction: column;

View File

@ -21,7 +21,21 @@
<el-icon><Monitor /></el-icon>
<span>切换经典风格</span>
</div>
<div class="store-box" @click="showStoreMenu = !showStoreMenu">
<div class="gender-toggle" @click.stop>
<div
class="toggle-item"
:class="{ active: genderMode === 'split' }"
@click.stop="setGenderMode('split')">
男女分榜
</div>
<div
class="toggle-item"
:class="{ active: genderMode === 'all' }"
@click.stop="setGenderMode('all')">
全部
</div>
</div>
<div class="store-box" @click.stop="showStoreMenu = !showStoreMenu">
<span class="label">场馆</span>
<span class="value">{{ currentStore?.name || "未选择" }}</span>
<el-icon :class="{ rotate: showStoreMenu }"><ArrowDown /></el-icon>
@ -42,7 +56,282 @@
</div>
</div>
<div class="main-content">
<div class="main-content" :class="{ split: genderMode === 'split' }">
<div v-if="genderMode === 'split'" class="split-board">
<div class="split-column">
<div class="split-label">男子榜</div>
<div class="split-hof">
<div class="podium mini">
<div v-if="rankingListMale[1]" class="podium-card rank-2">
<div class="rank-label">NO.2</div>
<div class="avatar-wrapper">
<el-avatar
:size="56"
:src="rankingListMale[1].avatar"
class="avatar">
{{ rankingListMale[1].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥈</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListMale[1].realName }}</div>
<div class="score">{{ rankingListMale[1].powerScore }}</div>
</div>
</div>
<div v-if="rankingListMale[0]" class="podium-card rank-1">
<div class="rank-label">NO.1</div>
<div class="avatar-wrapper">
<el-avatar
:size="66"
:src="rankingListMale[0].avatar"
class="avatar">
{{ rankingListMale[0].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">👑</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListMale[0].realName }}</div>
<div class="score">{{ rankingListMale[0].powerScore }}</div>
</div>
</div>
<div v-if="rankingListMale[2]" class="podium-card rank-3">
<div class="rank-label">NO.3</div>
<div class="avatar-wrapper">
<el-avatar
:size="56"
:src="rankingListMale[2].avatar"
class="avatar">
{{ rankingListMale[2].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥉</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListMale[2].realName }}</div>
<div class="score">{{ rankingListMale[2].powerScore }}</div>
</div>
</div>
</div>
</div>
<div class="contenders-zone split">
<div class="panel-header">
<div class="panel-title">MEN</div>
<div class="refresh-tag" v-if="isRefreshing">
<el-icon class="is-loading"><Refresh /></el-icon>
</div>
</div>
<div class="scroll-container">
<div class="scroll-mask">
<div
class="scroll-wrapper"
:style="{ transform: `translateY(${scrollYMale}px)` }">
<div class="grid-layout" ref="groupMale">
<div
v-for="player in rankingListMale.slice(3)"
:key="'mc1-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="46" :src="player.avatar">
{{ player.realName?.[0] || "?" }}
</el-avatar>
</div>
<div class="card-info">
<div class="card-name">{{ player.realName }}</div>
<div class="card-meta">
<span class="card-level">{{ player.levelName }}</span>
<span class="card-wins"
>{{
Math.round(
(player.matchCount * player.winRate) / 100,
)
}}</span
>
<span class="card-winrate"
>{{ player.winRate }}%</span
>
</div>
</div>
<div class="card-score">{{ player.powerScore }}</div>
</div>
</div>
<div class="grid-layout">
<div
v-for="player in rankingListMale.slice(3)"
:key="'mc2-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="46" :src="player.avatar">
{{ player.realName?.[0] || "?" }}
</el-avatar>
</div>
<div class="card-info">
<div class="card-name">{{ player.realName }}</div>
<div class="card-meta">
<span class="card-level">{{ player.levelName }}</span>
<span class="card-wins"
>{{
Math.round(
(player.matchCount * player.winRate) / 100,
)
}}</span
>
<span class="card-winrate"
>{{ player.winRate }}%</span
>
</div>
</div>
<div class="card-score">{{ player.powerScore }}</div>
</div>
</div>
</div>
</div>
<div class="bottom-fade-overlay"></div>
</div>
</div>
</div>
<div class="split-column">
<div class="split-label">女子榜</div>
<div class="split-hof">
<div class="podium mini">
<div v-if="rankingListFemale[1]" class="podium-card rank-2">
<div class="rank-label">NO.2</div>
<div class="avatar-wrapper">
<el-avatar
:size="56"
:src="rankingListFemale[1].avatar"
class="avatar">
{{ rankingListFemale[1].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥈</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListFemale[1].realName }}</div>
<div class="score">{{ rankingListFemale[1].powerScore }}</div>
</div>
</div>
<div v-if="rankingListFemale[0]" class="podium-card rank-1">
<div class="rank-label">NO.1</div>
<div class="avatar-wrapper">
<el-avatar
:size="66"
:src="rankingListFemale[0].avatar"
class="avatar">
{{ rankingListFemale[0].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">👑</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListFemale[0].realName }}</div>
<div class="score">{{ rankingListFemale[0].powerScore }}</div>
</div>
</div>
<div v-if="rankingListFemale[2]" class="podium-card rank-3">
<div class="rank-label">NO.3</div>
<div class="avatar-wrapper">
<el-avatar
:size="56"
:src="rankingListFemale[2].avatar"
class="avatar">
{{ rankingListFemale[2].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥉</div>
</div>
<div class="card-content">
<div class="name">{{ rankingListFemale[2].realName }}</div>
<div class="score">{{ rankingListFemale[2].powerScore }}</div>
</div>
</div>
</div>
</div>
<div class="contenders-zone split">
<div class="panel-header">
<div class="panel-title">WOMEN</div>
<div class="refresh-tag" v-if="isRefreshing">
<el-icon class="is-loading"><Refresh /></el-icon>
</div>
</div>
<div class="scroll-container">
<div class="scroll-mask">
<div
class="scroll-wrapper"
:style="{ transform: `translateY(${scrollYFemale}px)` }">
<div class="grid-layout" ref="groupFemale">
<div
v-for="player in rankingListFemale.slice(3)"
:key="'fc1-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="46" :src="player.avatar">
{{ player.realName?.[0] || "?" }}
</el-avatar>
</div>
<div class="card-info">
<div class="card-name">{{ player.realName }}</div>
<div class="card-meta">
<span class="card-level">{{ player.levelName }}</span>
<span class="card-wins"
>{{
Math.round(
(player.matchCount * player.winRate) / 100,
)
}}</span
>
<span class="card-winrate"
>{{ player.winRate }}%</span
>
</div>
</div>
<div class="card-score">{{ player.powerScore }}</div>
</div>
</div>
<div class="grid-layout">
<div
v-for="player in rankingListFemale.slice(3)"
:key="'fc2-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="46" :src="player.avatar">
{{ player.realName?.[0] || "?" }}
</el-avatar>
</div>
<div class="card-info">
<div class="card-name">{{ player.realName }}</div>
<div class="card-meta">
<span class="card-level">{{ player.levelName }}</span>
<span class="card-wins"
>{{
Math.round(
(player.matchCount * player.winRate) / 100,
)
}}</span
>
<span class="card-winrate"
>{{ player.winRate }}%</span
>
</div>
</div>
<div class="card-score">{{ player.powerScore }}</div>
</div>
</div>
</div>
</div>
<div class="bottom-fade-overlay"></div>
</div>
</div>
</div>
</div>
<template v-else>
<!-- 左侧名人堂 (Top 3) -->
<div class="hall-of-fame">
<div class="panel-title">HALL OF FAME</div>
@ -51,7 +340,10 @@
<div v-if="rankingList[1]" class="podium-card rank-2">
<div class="rank-label">NO.2</div>
<div class="avatar-wrapper">
<el-avatar :size="70" :src="rankingList[1].avatar" class="avatar">
<el-avatar
:size="70"
:src="rankingList[1].avatar"
class="avatar">
{{ rankingList[1].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥈</div>
@ -107,7 +399,10 @@
<div v-if="rankingList[2]" class="podium-card rank-3">
<div class="rank-label">NO.3</div>
<div class="avatar-wrapper">
<el-avatar :size="70" :src="rankingList[2].avatar" class="avatar">
<el-avatar
:size="70"
:src="rankingList[2].avatar"
class="avatar">
{{ rankingList[2].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥉</div>
@ -209,6 +504,7 @@
<div class="bottom-fade-overlay"></div>
</div>
</div>
</template>
</div>
<!-- 底部提示 -->
@ -234,47 +530,52 @@
const stores = ref([]);
const currentStore = ref(null);
const rankingList = ref([]);
const genderMode = ref(route.query.gender_mode === "all" ? "all" : "split");
const rankingListMale = ref([]);
const rankingListFemale = ref([]);
const loading = ref(false);
const showStoreMenu = ref(false);
const scrollContainer = ref(null);
const isRefreshing = ref(false);
let timer = null;
let refreshTimer = null;
let handleWindowClick = null;
const group1 = ref(null);
const scrollReqId = ref(null);
const scrollY = ref(0);
const groupMale = ref(null);
const groupFemale = ref(null);
const scrollReqIdMale = ref(null);
const scrollReqIdFemale = ref(null);
const scrollYMale = ref(0);
const scrollYFemale = ref(0);
const updateTime = () => {
currentTime.value = dayjs().format("HH:mm:ss");
};
const startContinuousScroll = () => {
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
const startContinuousScroll = (listRef, scrollYRef, reqIdRef, groupRef) => {
if (reqIdRef.value) cancelAnimationFrame(reqIdRef.value);
const scroll = () => {
if (
isRefreshing.value ||
rankingList.value.length <= 3 ||
!group1.value
) {
scrollReqId.value = requestAnimationFrame(scroll);
if (isRefreshing.value || listRef.value.length <= 3 || !groupRef.value) {
reqIdRef.value = requestAnimationFrame(scroll);
return;
}
scrollY.value -= 0.6;
scrollYRef.value -= 0.6;
//
const singleSetHeight = group1.value.offsetHeight + 12; // + gap
const singleSetHeight = groupRef.value.offsetHeight + 12;
if (Math.abs(scrollY.value) >= singleSetHeight) {
scrollY.value = 0;
if (Math.abs(scrollYRef.value) >= singleSetHeight) {
scrollYRef.value = 0;
}
scrollReqId.value = requestAnimationFrame(scroll);
reqIdRef.value = requestAnimationFrame(scroll);
};
setTimeout(() => {
scrollReqId.value = requestAnimationFrame(scroll);
reqIdRef.value = requestAnimationFrame(scroll);
}, 1000);
};
@ -296,18 +597,75 @@
}
};
const setGenderMode = (mode) => {
if (mode !== "split" && mode !== "all") return;
if (genderMode.value === mode) return;
genderMode.value = mode;
router.replace({ query: { ...route.query, gender_mode: mode } });
fetchRanking();
};
const fetchRanking = async () => {
if (!currentStore.value) return;
isRefreshing.value = true;
try {
if (genderMode.value === "split") {
const [maleRes, femaleRes] = await Promise.all([
getPublicRanking({
store_id: currentStore.value.id,
gender: 1,
pageSize: 100,
is_display: 1,
}),
getPublicRanking({
store_id: currentStore.value.id,
gender: 2,
pageSize: 100,
is_display: 1,
}),
]);
rankingListMale.value = maleRes.data?.list || [];
rankingListFemale.value = femaleRes.data?.list || [];
scrollYMale.value = 0;
scrollYFemale.value = 0;
if (
rankingListMale.value.length > 3 ||
rankingListFemale.value.length > 3
) {
nextTick(() => {
if (rankingListMale.value.length > 3) {
startContinuousScroll(
rankingListMale,
scrollYMale,
scrollReqIdMale,
groupMale,
);
}
if (rankingListFemale.value.length > 3) {
startContinuousScroll(
rankingListFemale,
scrollYFemale,
scrollReqIdFemale,
groupFemale,
);
}
});
}
} else {
const res = await getPublicRanking({
store_id: currentStore.value.id,
pageSize: 100,
is_display: 1,
});
rankingList.value = res.data?.list || [];
scrollY.value = 0;
if (rankingList.value.length > 3) {
nextTick(() => startContinuousScroll());
nextTick(() => {
startContinuousScroll(rankingList, scrollY, scrollReqId, group1);
});
}
}
} catch (err) {
console.error("Fetch ranking error:", err);
@ -322,7 +680,13 @@
const selectStore = (store) => {
currentStore.value = store;
showStoreMenu.value = false;
router.replace({ query: { ...route.query, store_id: store.id } });
router.replace({
query: {
...route.query,
store_id: store.id,
gender_mode: genderMode.value,
},
});
fetchRanking();
};
@ -330,16 +694,40 @@
fetchStores();
timer = setInterval(updateTime, 1000);
refreshTimer = setInterval(fetchRanking, 30 * 1000);
window.addEventListener("click", () => {
handleWindowClick = () => {
showStoreMenu.value = false;
});
};
window.addEventListener("click", handleWindowClick);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
if (scrollReqIdMale.value) cancelAnimationFrame(scrollReqIdMale.value);
if (scrollReqIdFemale.value) cancelAnimationFrame(scrollReqIdFemale.value);
if (handleWindowClick) {
window.removeEventListener("click", handleWindowClick);
}
});
watch(
() => [route.query.store_id, route.query.gender_mode],
([newStoreId, newMode]) => {
const mode = newMode === "all" ? "all" : "split";
if (mode !== genderMode.value) {
genderMode.value = mode;
fetchRanking();
}
if (!newStoreId) return;
const found = stores.value.find((s) => s.id === parseInt(newStoreId));
if (found && found.id !== currentStore.value?.id) {
currentStore.value = found;
fetchRanking();
}
},
);
</script>
<style lang="scss" scoped>
@ -431,7 +819,7 @@
display: flex;
align-items: center;
gap: 30px;
width: 400px; /* 增加宽度以容纳两个控件 */
width: 560px;
justify-content: flex-end;
.style-switcher {
@ -440,12 +828,15 @@
gap: 8px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
padding: 8px 15px;
height: 38px;
padding: 0 14px;
border-radius: 4px;
cursor: pointer;
color: #ffcc80;
font-size: 14px;
transition: all 0.3s;
box-sizing: border-box;
white-space: nowrap;
&:hover {
background: rgba(255, 152, 0, 0.2);
@ -455,6 +846,42 @@
}
}
.gender-toggle {
display: flex;
height: 38px;
background: rgba(255, 152, 0, 0.08);
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 4px;
overflow: hidden;
box-sizing: border-box;
.toggle-item {
display: flex;
align-items: center;
padding: 0 12px;
font-size: 14px;
color: #ffcc80;
cursor: pointer;
user-select: none;
transition: all 0.2s;
border-right: 1px solid rgba(255, 152, 0, 0.18);
white-space: nowrap;
&:last-child {
border-right: none;
}
&.active {
color: #ff9800;
background: rgba(255, 152, 0, 0.18);
}
&:hover {
background: rgba(255, 152, 0, 0.12);
}
}
}
.store-box {
text-align: right;
cursor: pointer;
@ -499,6 +926,149 @@
}
}
.main-content.split {
display: flex;
gap: 26px;
}
.split-board {
flex: 1;
display: flex;
gap: 26px;
min-width: 0;
}
.split-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.split-label {
font-size: 14px;
color: rgba(255, 204, 128, 0.9);
letter-spacing: 2px;
font-weight: 800;
margin-top: 10px;
}
.split-hof {
border: 1px solid rgba(255, 152, 0, 0.18);
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
padding: 10px 10px 14px;
box-sizing: border-box;
.podium.mini {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
transform: none;
}
.podium-card {
flex: 1;
min-width: 0;
background: linear-gradient(
135deg,
rgba(255, 152, 0, 0.12) 0%,
rgba(255, 152, 0, 0.03) 100%
);
border: 1px solid rgba(255, 152, 0, 0.18);
border-radius: 12px;
padding: 12px 12px;
display: flex;
align-items: center;
gap: 12px;
position: relative;
overflow: hidden;
box-sizing: border-box;
&.rank-1 {
flex: 1.15;
border-color: rgba(255, 215, 0, 0.45);
background: linear-gradient(
135deg,
rgba(255, 215, 0, 0.16) 0%,
rgba(255, 152, 0, 0.04) 100%
);
}
.rank-label {
position: absolute;
top: 8px;
right: 10px;
font-size: 11px;
font-weight: 900;
color: rgba(255, 152, 0, 0.65);
letter-spacing: 1px;
white-space: nowrap;
}
.avatar-wrapper {
position: relative;
flex-shrink: 0;
.avatar {
border: 2px solid #ff9800;
background: #33190a;
font-weight: 900;
font-size: 18px;
}
.crown {
position: absolute;
top: -10px;
right: -8px;
font-size: 22px;
z-index: 5;
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5));
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.name {
font-weight: 900;
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score {
font-weight: 900;
font-family: "Arial Black";
font-size: 18px;
line-height: 1;
color: #ffcc80;
white-space: nowrap;
}
}
}
.podium.mini {
margin-top: 8px;
transform: scale(0.96);
transform-origin: top center;
}
.contenders-zone.split {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Main Content */
.main-content {
flex: 1;

View File

@ -132,7 +132,7 @@
</el-radio-group>
</el-form-item>
<el-form-item label="等级" prop="level">
<el-select v-model="editForm.level" placeholder="选择等级">
<el-select v-model="editForm.level" placeholder="选择等级" @change="handleLevelChange">
<el-option label="Lv1 新锐" :value="1" />
<el-option label="Lv2 精锐" :value="2" />
<el-option label="Lv3 高手" :value="3" />
@ -160,6 +160,14 @@ import { getLadderUsers, createLadderUser, updateLadderUser, deleteLadderUser, g
const userStore = useUserStore()
const LEVEL_POWER_DEFAULTS = {
1: 1000,
2: 1200,
3: 1500,
4: 1800,
5: 2200
}
const loading = ref(false)
const tableData = ref([])
const stores = ref([])
@ -241,6 +249,14 @@ const handleEdit = (row) => {
showEditDialog.value = true
}
const handleLevelChange = (level) => {
if (editForm.value.id) return
const next = LEVEL_POWER_DEFAULTS[level]
if (next !== undefined) {
editForm.value.power_score = next
}
}
const handleSave = async () => {
await editFormRef.value?.validate()

View File

@ -8,10 +8,18 @@
<!-- 搜索表单 -->
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input v-model="searchForm.keyword" placeholder="昵称/手机号/会员码" clearable style="width: 200px" />
<el-input
v-model="searchForm.keyword"
placeholder="昵称/手机号/会员码"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 100px">
<el-select
v-model="searchForm.status"
placeholder="状态"
clearable
style="width: 100px">
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
@ -28,10 +36,12 @@
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div class="user-cell">
<el-avatar :size="40" :src="row.avatar">{{ row.nickname?.[0] }}</el-avatar>
<el-avatar :size="40" :src="row.avatar">{{
row.nickname?.[0]
}}</el-avatar>
<div class="user-info">
<span class="nickname">{{ row.nickname || '-' }}</span>
<span class="phone">{{ row.phone || '未绑定手机' }}</span>
<span class="nickname">{{ row.nickname || "-" }}</span>
<span class="phone">{{ row.phone || "未绑定手机" }}</span>
</div>
</div>
</template>
@ -40,7 +50,7 @@
<el-table-column prop="gender" label="性别" width="80">
<template #default="{ row }">
<span :class="['gender-tag', row.gender === 1 ? 'male' : 'female']">
{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '未知' }}
{{ row.gender === 1 ? "男" : row.gender === 2 ? "女" : "未知" }}
</span>
</template>
</el-table-column>
@ -48,7 +58,7 @@
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
{{ row.status === 1 ? "正常" : "禁用" }}
</el-tag>
</template>
</el-table-column>
@ -57,16 +67,20 @@
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="primary" link @click="handleView(row)"
>查看</el-button
>
<el-button type="primary" link @click="handleAddLadderUser(row)"
>添加为天梯用户</el-button
>
<el-button
:type="row.status === 1 ? 'danger' : 'success'"
link
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
@click="handleToggleStatus(row)">
{{ row.status === 1 ? "禁用" : "启用" }}
</el-button>
</div>
</template>
@ -82,31 +96,53 @@
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
@current-change="fetchData" />
</div>
</div>
<!-- 用户详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="用户详情" width="600px">
<el-descriptions :column="2" border v-if="currentUser">
<el-descriptions-item label="ID">{{ currentUser.id }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ currentUser.nickname }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ currentUser.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="会员码">{{ currentUser.memberCode }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentUser.gender === 1 ? '男' : currentUser.gender === 2 ? '女' : '未知' }}</el-descriptions-item>
<el-descriptions-item label="积分">{{ currentUser.totalPoints }}</el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">{{ formatDate(currentUser.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="ID">{{
currentUser.id
}}</el-descriptions-item>
<el-descriptions-item label="昵称">{{
currentUser.nickname
}}</el-descriptions-item>
<el-descriptions-item label="手机号">{{
currentUser.phone || "-"
}}</el-descriptions-item>
<el-descriptions-item label="会员码">{{
currentUser.memberCode
}}</el-descriptions-item>
<el-descriptions-item label="性别">{{
currentUser.gender === 1
? "男"
: currentUser.gender === 2
? "女"
: "未知"
}}</el-descriptions-item>
<el-descriptions-item label="积分">{{
currentUser.totalPoints
}}</el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">{{
formatDate(currentUser.createdAt)
}}</el-descriptions-item>
</el-descriptions>
<div v-if="currentUser?.ladderUsers?.length" style="margin-top: 20px">
<h4>天梯信息</h4>
<el-table :data="currentUser.ladderUsers" size="small" style="margin-top: 10px">
<el-table
:data="currentUser.ladderUsers"
size="small"
style="margin-top: 10px">
<el-table-column prop="storeName" label="门店" />
<el-table-column prop="realName" label="姓名" />
<el-table-column prop="level" label="等级">
<template #default="{ row }">
<span :class="['level-tag', 'lv' + row.level]">Lv{{ row.level }}</span>
<span :class="['level-tag', 'lv' + row.level]"
>Lv{{ row.level }}</span
>
</template>
</el-table-column>
<el-table-column prop="powerScore" label="战力值" />
@ -114,70 +150,212 @@
</el-table>
</div>
</el-dialog>
<!-- 添加为天梯用户弹窗 -->
<el-dialog v-model="showAddLadderDialog" title="新增天梯用户" width="520px">
<el-form
:model="addLadderForm"
:rules="addLadderRules"
ref="addLadderFormRef"
label-width="100px">
<el-form-item
v-if="userStore.isSuperAdmin"
label="门店"
prop="store_id">
<el-select v-model="addLadderForm.store_id" placeholder="选择门店">
<el-option
v-for="store in stores"
:key="store.id"
:label="store.name"
:value="store.id" />
</el-select>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="addLadderForm.phone"
placeholder="请输入用户手机号" />
</el-form-item>
<el-form-item label="真实姓名" prop="real_name">
<el-input
v-model="addLadderForm.real_name"
placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="addLadderForm.gender">
<el-radio :value="1"></el-radio>
<el-radio :value="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="等级" prop="level">
<el-select
v-model="addLadderForm.level"
placeholder="选择等级"
@change="handleAddLadderLevelChange">
<el-option label="Lv1 新锐" :value="1" />
<el-option label="Lv2 精锐" :value="2" />
<el-option label="Lv3 高手" :value="3" />
<el-option label="Lv4 大师" :value="4" />
<el-option label="Lv5 宗师" :value="5" />
</el-select>
</el-form-item>
<el-form-item label="战力值" prop="power_score">
<el-input-number
v-model="addLadderForm.power_score"
:min="0"
:max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddLadderDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateLadderUser"
>保存</el-button
>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
import { getUsers, getUserDetail, updateUserStatus } from '@/api/admin'
import { ref, onMounted, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import {
getUsers,
getUserDetail,
updateUserStatus,
createLadderUser,
getStores,
} from "@/api/admin";
import { useUserStore } from "@/stores/user";
const loading = ref(false)
const tableData = ref([])
const searchForm = ref({ keyword: '', status: '' })
const pagination = ref({ page: 1, pageSize: 20, total: 0 })
const userStore = useUserStore();
const showDetailDialog = ref(false)
const currentUser = ref(null)
const LEVEL_POWER_DEFAULTS = {
1: 1000,
2: 1200,
3: 1500,
4: 1800,
5: 2200,
};
const formatDate = (date) => dayjs(date).format('YYYY-MM-DD HH:mm')
const loading = ref(false);
const tableData = ref([]);
const searchForm = ref({ keyword: "", status: "" });
const pagination = ref({ page: 1, pageSize: 20, total: 0 });
const fetchData = async () => {
loading.value = true
const showDetailDialog = ref(false);
const currentUser = ref(null);
const stores = ref([]);
const showAddLadderDialog = ref(false);
const addLadderFormRef = ref();
const addLadderForm = ref({
store_id: "",
phone: "",
real_name: "",
gender: 1,
level: 1,
power_score: 1000,
});
const addLadderRules = computed(() => ({
...(userStore.isSuperAdmin
? {
store_id: [
{ required: true, message: "请选择门店", trigger: "change" },
],
}
: {}),
phone: [{ required: true, message: "请输入手机号", trigger: "blur" }],
real_name: [{ required: true, message: "请输入真实姓名", trigger: "blur" }],
gender: [{ required: true, message: "请选择性别", trigger: "change" }],
level: [{ required: true, message: "请选择等级", trigger: "change" }],
power_score: [{ required: true, message: "请输入战力值", trigger: "blur" }],
}));
const formatDate = (date) => dayjs(date).format("YYYY-MM-DD HH:mm");
const fetchData = async () => {
loading.value = true;
try {
const res = await getUsers({
...searchForm.value,
page: pagination.value.page,
pageSize: pagination.value.pageSize
})
tableData.value = res.data.list
pagination.value.total = res.data.pagination.total
pageSize: pagination.value.pageSize,
});
tableData.value = res.data.list;
pagination.value.total = res.data.pagination.total;
} finally {
loading.value = false
loading.value = false;
}
}
};
const resetSearch = () => {
searchForm.value = { keyword: '', status: '' }
pagination.value.page = 1
fetchData()
}
const resetSearch = () => {
searchForm.value = { keyword: "", status: "" };
pagination.value.page = 1;
fetchData();
};
const handleView = async (row) => {
const res = await getUserDetail(row.id)
currentUser.value = res.data
showDetailDialog.value = true
}
const handleView = async (row) => {
const res = await getUserDetail(row.id);
currentUser.value = res.data;
showDetailDialog.value = true;
};
const handleToggleStatus = (row) => {
const newStatus = row.status === 1 ? 0 : 1
const action = newStatus === 1 ? '启用' : '禁用'
const handleToggleStatus = (row) => {
const newStatus = row.status === 1 ? 0 : 1;
const action = newStatus === 1 ? "启用" : "禁用";
ElMessageBox.confirm(`确定要${action}该用户吗?`, '提示', {
type: 'warning'
ElMessageBox.confirm(`确定要${action}该用户吗?`, "提示", {
type: "warning",
}).then(async () => {
await updateUserStatus(row.id, { status: newStatus })
ElMessage.success(`${action}成功`)
fetchData()
})
}
await updateUserStatus(row.id, { status: newStatus });
ElMessage.success(`${action}成功`);
fetchData();
});
};
onMounted(fetchData)
const fetchStores = async () => {
if (userStore.isSuperAdmin) {
const res = await getStores({ pageSize: 100 });
stores.value = res.data.list;
}
};
const handleAddLadderUser = (row) => {
addLadderForm.value = {
store_id: userStore.isSuperAdmin ? "" : userStore.userInfo?.storeId || "",
phone: row.phone || "",
real_name: row.nickname || "",
gender: row.gender === 1 || row.gender === 2 ? row.gender : 1,
level: 1,
power_score: 1000,
};
showAddLadderDialog.value = true;
};
const handleAddLadderLevelChange = (level) => {
const next = LEVEL_POWER_DEFAULTS[level];
if (next !== undefined) {
addLadderForm.value.power_score = next;
}
};
const handleCreateLadderUser = async () => {
await addLadderFormRef.value?.validate();
await createLadderUser(addLadderForm.value);
ElMessage.success("创建成功");
showAddLadderDialog.value = false;
};
onMounted(async () => {
await fetchStores();
fetchData();
});
</script>
<style lang="scss" scoped>
.user-cell {
.user-cell {
display: flex;
align-items: center;
gap: 12px;
@ -195,5 +373,5 @@ onMounted(fetchData)
color: var(--text-secondary);
}
}
}
}
</style>

View File

@ -91,21 +91,30 @@ App({
this.globalData.userInfo = loginRes.data.data.userInfo;
// 处理天梯用户信息
if (loginRes.data.data.userInfo.ladderUsers && loginRes.data.data.userInfo.ladderUsers.length > 0) {
if (
loginRes.data.data.userInfo.ladderUsers &&
loginRes.data.data.userInfo.ladderUsers.length > 0
) {
// 如果有当前门店,优先选择当前门店的天梯用户
if (this.globalData.currentStore && this.globalData.currentStore.storeId) {
const currentStoreLadderUser = loginRes.data.data.userInfo.ladderUsers.find(
lu => lu.storeId === this.globalData.currentStore.storeId
if (
this.globalData.currentStore &&
this.globalData.currentStore.storeId
) {
const currentStoreLadderUser =
loginRes.data.data.userInfo.ladderUsers.find(
(lu) => lu.storeId === this.globalData.currentStore.storeId,
);
if (currentStoreLadderUser) {
this.globalData.ladderUser = currentStoreLadderUser;
} else {
// 当前门店没有天梯用户,取第一个
this.globalData.ladderUser = loginRes.data.data.userInfo.ladderUsers[0];
this.globalData.ladderUser =
loginRes.data.data.userInfo.ladderUsers[0];
}
} else {
// 没有当前门店,取第一个天梯用户
this.globalData.ladderUser = loginRes.data.data.userInfo.ladderUsers[0];
this.globalData.ladderUser =
loginRes.data.data.userInfo.ladderUsers[0];
}
} else {
// 没有天梯用户
@ -139,9 +148,12 @@ App({
// 处理天梯用户信息
if (res.data.ladderUsers && res.data.ladderUsers.length > 0) {
// 如果有当前门店,优先选择当前门店的天梯用户
if (this.globalData.currentStore && this.globalData.currentStore.storeId) {
if (
this.globalData.currentStore &&
this.globalData.currentStore.storeId
) {
const currentStoreLadderUser = res.data.ladderUsers.find(
lu => lu.storeId === this.globalData.currentStore.storeId
(lu) => lu.storeId === this.globalData.currentStore.storeId,
);
if (currentStoreLadderUser) {
this.globalData.ladderUser = currentStoreLadderUser;
@ -183,9 +195,13 @@ App({
this.getLadderUser(res.data.storeId);
} else if (res.data && res.data.storeId) {
// 如果当前门店没有 ladderUserId但用户信息中有该门店的天梯用户使用它
if (this.globalData.userInfo && this.globalData.userInfo.ladderUsers) {
const currentStoreLadderUser = this.globalData.userInfo.ladderUsers.find(
lu => lu.storeId === res.data.storeId
if (
this.globalData.userInfo &&
this.globalData.userInfo.ladderUsers
) {
const currentStoreLadderUser =
this.globalData.userInfo.ladderUsers.find(
(lu) => lu.storeId === res.data.storeId,
);
if (currentStoreLadderUser) {
this.globalData.ladderUser = currentStoreLadderUser;
@ -211,9 +227,13 @@ App({
this.getLadderUser(res.data.storeId);
} else if (res.data && res.data.storeId) {
// 如果当前门店没有 ladderUserId但用户信息中有该门店的天梯用户使用它
if (this.globalData.userInfo && this.globalData.userInfo.ladderUsers) {
const currentStoreLadderUser = this.globalData.userInfo.ladderUsers.find(
lu => lu.storeId === res.data.storeId
if (
this.globalData.userInfo &&
this.globalData.userInfo.ladderUsers
) {
const currentStoreLadderUser =
this.globalData.userInfo.ladderUsers.find(
(lu) => lu.storeId === res.data.storeId,
);
if (currentStoreLadderUser) {
this.globalData.ladderUser = currentStoreLadderUser;
@ -243,7 +263,7 @@ App({
this.globalData.ladderUser = null;
}
return res.data;
}
},
);
},
@ -301,7 +321,10 @@ App({
const currentPage = pages[pages.length - 1];
// 如果当前页面有处理挑战请求的方法,调用它
if (currentPage && typeof currentPage.handleChallengeRequest === 'function') {
if (
currentPage &&
typeof currentPage.handleChallengeRequest === "function"
) {
currentPage.handleChallengeRequest(challengeData);
} else {
// 否则使用系统弹框
@ -317,22 +340,24 @@ App({
match_id: challengeData.matchId,
accept: res.confirm,
},
"POST"
).then(() => {
"POST",
)
.then(() => {
if (res.confirm) {
wx.showToast({ title: '已接受挑战', icon: 'success' });
wx.showToast({ title: "已接受挑战", icon: "success" });
// 跳转到挑战赛详情
setTimeout(() => {
wx.navigateTo({
url: `/pages/match/challenge-detail/index?id=${challengeData.matchId}`
url: `/pages/match/challenge-detail/index?id=${challengeData.matchId}`,
});
}, 1500);
} else {
wx.showToast({ title: '已拒绝挑战', icon: 'success' });
wx.showToast({ title: "已拒绝挑战", icon: "success" });
}
}).catch(err => {
console.error('响应挑战失败:', err);
wx.showToast({ title: '操作失败', icon: 'none' });
})
.catch((err) => {
console.error("响应挑战失败:", err);
wx.showToast({ title: "操作失败", icon: "none" });
});
},
});
@ -340,19 +365,22 @@ App({
break;
case "challenge_accepted":
// 挑战被接受
wx.showToast({ title: '对方已接受挑战', icon: 'success' });
wx.showToast({ title: "对方已接受挑战", icon: "success" });
// 如果当前在挑战赛详情页面,刷新数据
const pages2 = getCurrentPages();
const currentPage2 = pages2[pages2.length - 1];
if (currentPage2 && currentPage2.route === 'pages/match/challenge-detail/index') {
if (typeof currentPage2.loadMatchDetail === 'function') {
if (
currentPage2 &&
currentPage2.route === "pages/match/challenge-detail/index"
) {
if (typeof currentPage2.loadMatchDetail === "function") {
currentPage2.loadMatchDetail();
}
}
break;
case "challenge_rejected":
// 挑战被拒绝
wx.showToast({ title: '对方已拒绝挑战', icon: 'none' });
wx.showToast({ title: "对方已拒绝挑战", icon: "none" });
break;
case "score_confirm_request":
// 收到比分确认请求
@ -368,7 +396,7 @@ App({
game_id: data.data.gameId,
confirm: res.confirm,
},
"POST"
"POST",
);
},
});
@ -387,6 +415,12 @@ App({
// 封装请求
request(url, data = {}, method = "GET") {
return new Promise((resolve, reject) => {
const showErrorToast = (message) => {
const title =
(message && String(message).trim()) || "网络异常,请稍后重试";
wx.showToast({ title, icon: "none" });
};
wx.request({
url: `${this.globalData.baseUrl}${url}`,
method,
@ -401,14 +435,18 @@ App({
// 登录过期
this.globalData.token = null;
wx.removeStorageSync("token");
showErrorToast("登录已过期,请重新登录");
wx.reLaunch({ url: "/pages/user/index" });
reject(res.data);
reject({ message: res.data.message || "登录已过期", ...res.data });
} else {
wx.showToast({ title: res.data.message, icon: "none" });
reject(res.data);
showErrorToast(res.data.message || "请求失败");
reject({ message: res.data.message || "请求失败", ...res.data });
}
},
fail: reject,
fail: (err) => {
showErrorToast("网络异常,请检查网络后重试");
reject({ message: "网络异常,请检查网络后重试", err });
},
});
});
},

View File

@ -1,15 +1,17 @@
const app = getApp();
const util = require("../../utils/util");
const ALL_PAGE_SIZE = 5000;
Page({
data: {
currentStore: null,
gender: "",
list: [],
loading: false,
page: 1,
pageSize: 20,
hasMore: true,
locating: false,
myLadderUserId: null,
pageSize: ALL_PAGE_SIZE,
},
onLoad() {
@ -26,9 +28,8 @@ Page({
if (newStore && newStore.storeId !== oldStoreId) {
this.setData({
currentStore: newStore,
page: 1,
hasMore: true,
list: [],
myLadderUserId: null,
});
this.fetchData();
} else if (app.globalData.storeChanged) {
@ -36,27 +37,20 @@ Page({
app.globalData.storeChanged = false;
this.setData({
currentStore: newStore,
page: 1,
hasMore: true,
list: [],
myLadderUserId: null,
});
this.fetchData();
}
},
onPullDownRefresh() {
this.setData({ page: 1, hasMore: true });
this.setData({ list: [], myLadderUserId: null });
this.fetchData().then(() => {
wx.stopPullDownRefresh();
});
},
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.loadMore();
}
},
async initData() {
// 检查是否已登录(有 token
if (!app.globalData.token) {
@ -88,14 +82,14 @@ Page({
const res = await app.request("/api/ladder/ranking", {
store_id: this.data.currentStore.storeId,
gender: this.data.gender,
page: this.data.page,
page: 1,
pageSize: this.data.pageSize,
no_count: 1,
});
const list = res.data.list || [];
this.setData({
list: this.data.page === 1 ? list : this.data.list.concat(list),
hasMore: list.length >= this.data.pageSize,
list,
});
} catch (e) {
console.error("获取排名失败:", e);
@ -104,14 +98,9 @@ Page({
}
},
loadMore() {
this.setData({ page: this.data.page + 1 });
this.fetchData();
},
setGender(e) {
const gender = e.currentTarget.dataset.gender;
this.setData({ gender, page: 1, hasMore: true });
this.setData({ gender, list: [], myLadderUserId: null });
this.fetchData();
},
@ -119,6 +108,85 @@ Page({
wx.navigateTo({ url: "/pages/store/index" });
},
scrollToMeInMiddle(tryCount = 0) {
const id = this.data.myLadderUserId;
if (!id) return;
const selector = `#player-${id}`;
const systemInfo = wx.getSystemInfoSync();
const windowHeight =
systemInfo && systemInfo.windowHeight ? systemInfo.windowHeight : 0;
wx.createSelectorQuery()
.select(selector)
.boundingClientRect()
.selectViewport()
.scrollOffset()
.exec((res) => {
const rect = res && res[0] ? res[0] : null;
const scroll = res && res[1] ? res[1] : null;
if (!rect || !scroll || typeof scroll.scrollTop !== "number") {
if (tryCount < 10) {
setTimeout(() => {
this.scrollToMeInMiddle(tryCount + 1);
}, 100);
}
return;
}
const targetScrollTop =
scroll.scrollTop + rect.top - windowHeight / 2 + rect.height / 2;
wx.pageScrollTo({
scrollTop: Math.max(0, targetScrollTop),
duration: 300,
});
});
},
async locateMe() {
if (!this.data.currentStore || !this.data.currentStore.storeId) return;
if (this.data.locating) return;
this.setData({ locating: true });
try {
const res = await app.request("/api/ladder/my-rank", {
store_id: this.data.currentStore.storeId,
gender: this.data.gender,
});
const data = res && res.data ? res.data : null;
if (!data) return;
if (data.qualified === false) {
wx.showToast({
title: `本月场次不足(${data.monthlyMatchCount}/${data.minMonthlyMatches}`,
icon: "none",
});
return;
}
if (!data.ladderUserId || !data.page) {
return;
}
const shouldRefetch = !this.data.list || this.data.list.length === 0;
this.setData({ myLadderUserId: data.ladderUserId });
if (shouldRefetch) {
await this.fetchData();
}
setTimeout(() => {
this.scrollToMeInMiddle();
}, 50);
} catch (e) {
console.error("定位我的排名失败:", e);
} finally {
this.setData({ locating: false });
}
},
viewPlayer(e) {
const player = e.currentTarget.dataset.player;
const id = player && player.id ? player.id : e.currentTarget.dataset.id;

View File

@ -1,10 +1,9 @@
<!--天梯排名页面 - 浅色高级感设计-->
<!-- 天梯排名页面 - 浅色高级感设计 -->
<view class="page-container">
<!-- 顶部装饰背景 -->
<view class="hero-section">
<view class="hero-pattern"></view>
<view class="hero-pattern-2"></view>
<!-- 门店信息 -->
<view class="store-header">
<view class="store-info">
@ -16,62 +15,43 @@
<text class="change-store-arrow"></text>
</view>
</view>
<!-- 页面标题 -->
<view class="page-header animate-fadeInUp" style="animation-delay: 0.05s">
<text class="page-title">天梯排名</text>
<text class="page-subtitle">挑战自我,超越巅峰</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="main-content">
<!-- 性别筛选标签 - 吸附在顶部 -->
<view class="filter-bar-wrapper">
<view class="filter-bar animate-fadeInUp" style="animation-delay: 0.1s">
<view
class="filter-item {{gender === '' ? 'active' : ''}}"
bindtap="setGender"
data-gender=""
>
<view class="filter-items">
<view class="filter-item {{gender === '' ? 'active' : ''}}" bindtap="setGender" data-gender="">
全部
</view>
<view
class="filter-item {{gender === '1' ? 'active' : ''}}"
bindtap="setGender"
data-gender="1"
>
<view class="filter-item {{gender === '1' ? 'active' : ''}}" bindtap="setGender" data-gender="1">
男子
</view>
<view
class="filter-item {{gender === '2' ? 'active' : ''}}"
bindtap="setGender"
data-gender="2"
>
<view class="filter-item {{gender === '2' ? 'active' : ''}}" bindtap="setGender" data-gender="2">
女子
</view>
</view>
<view class="locate-btn {{locating ? 'disabled' : ''}}" bindtap="locateMe">
{{locating ? '定位中' : '定位我'}}
</view>
</view>
</view>
<!-- 排名列表 -->
<view class="ranking-list">
<block wx:if="{{list.length > 0}}">
<view
class="ranking-item stagger-item {{index < 3 ? 'top-rank' : ''}} animate-fadeInUp"
wx:for="{{list}}"
wx:key="id"
bindtap="viewPlayer"
data-id="{{item.id}}"
data-player="{{item}}"
>
<view id="player-{{item.id}}" class="ranking-item stagger-item {{index < 3 ? 'top-rank' : ''}} {{item.id === myLadderUserId ? 'is-me' : ''}} animate-fadeInUp" wx:for="{{list}}" wx:key="id" bindtap="viewPlayer" data-id="{{item.id}}" data-player="{{item}}">
<!-- 排名徽章 -->
<view class="rank-badge {{item.rank === 1 ? 'top1' : item.rank === 2 ? 'top2' : item.rank === 3 ? 'top3' : 'normal'}}">
<text>{{item.rank}}</text>
</view>
<!-- 选手头像 -->
<image class="player-avatar" src="{{item.avatar || '/images/avatar-default.svg'}}" mode="aspectFill"></image>
<!-- 选手信息 -->
<view class="player-info">
<text class="player-name">{{item.realName}}</text>
@ -80,7 +60,6 @@
<text class="player-stats">胜率 {{item.winRate}}%</text>
</view>
</view>
<!-- 战力值 -->
<view class="player-power">
<text class="power-value">{{item.powerScore}}</text>
@ -88,21 +67,18 @@
</view>
</view>
</block>
<!-- 空状态 -->
<view wx:elif="{{!loading}}" class="empty-state">
<image class="empty-icon" src="/images/empty-ranking.svg" mode="aspectFit"></image>
<text class="empty-title">暂无排名数据</text>
<text class="empty-desc">每月完成3场比赛即可上榜</text>
</view>
<!-- 加载更多 -->
<view wx:if="{{loading}}" class="loading-state">
<text>加载中...</text>
</view>
<!-- 到底提示 -->
<view wx:if="{{list.length > 0 && !hasMore && !loading}}" class="load-more">
<view wx:if="{{list.length > 0 && !loading}}" class="load-more">
<text>— 已显示全部选手 —</text>
</view>
</view>

View File

@ -132,6 +132,13 @@
/* 筛选标签栏 */
.filter-bar {
display: flex;
align-items: center;
gap: 16rpx;
}
.filter-items {
flex: 1;
display: flex;
gap: 16rpx;
}
@ -159,6 +166,28 @@
transform: scale(0.96);
}
.locate-btn {
flex-shrink: 0;
padding: 20rpx 26rpx;
background: var(--bg-white);
border-radius: var(--radius-full);
text-align: center;
font-size: 26rpx;
color: var(--primary);
box-shadow: var(--shadow-sm);
border: 1rpx solid var(--border-primary);
font-weight: 600;
transition: all 0.3s ease;
}
.locate-btn:active {
transform: scale(0.96);
}
.locate-btn.disabled {
opacity: 0.6;
}
/* 排名列表 */
.ranking-list {
padding: 0 24rpx 40rpx;
@ -186,11 +215,20 @@
}
.ranking-item.top-rank {
background: linear-gradient(135deg, var(--primary-soft) 0%, var(--bg-white) 100%);
background: linear-gradient(
135deg,
var(--primary-soft) 0%,
var(--bg-white) 100%
);
border: 2rpx solid var(--border-primary);
box-shadow: var(--shadow-primary);
}
.ranking-item.is-me {
border: 2rpx solid rgba(255, 107, 53, 0.55);
box-shadow: 0 10rpx 28rpx rgba(255, 107, 53, 0.18);
}
/* 排名徽章 */
.rank-badge {
width: 56rpx;
@ -206,8 +244,8 @@
}
.rank-badge.top1 {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #8B4513;
background: linear-gradient(135deg, #ffd700 0%, #ffa500 100%);
color: #8b4513;
box-shadow: 0 6rpx 20rpx rgba(255, 215, 0, 0.5);
font-size: 32rpx;
width: 64rpx;
@ -215,8 +253,8 @@
}
.rank-badge.top2 {
background: linear-gradient(135deg, #E8E8E8 0%, #C0C0C0 100%);
color: #4A4A4A;
background: linear-gradient(135deg, #e8e8e8 0%, #c0c0c0 100%);
color: #4a4a4a;
box-shadow: 0 4rpx 16rpx rgba(192, 192, 192, 0.5);
font-size: 32rpx;
width: 64rpx;
@ -224,7 +262,7 @@
}
.rank-badge.top3 {
background: linear-gradient(135deg, #CD853F 0%, #B8860B 100%);
background: linear-gradient(135deg, #cd853f 0%, #b8860b 100%);
color: #fff;
box-shadow: 0 4rpx 16rpx rgba(205, 133, 63, 0.5);
font-size: 32rpx;
@ -233,7 +271,11 @@
}
.rank-badge.normal {
background: linear-gradient(135deg, var(--bg-soft) 0%, var(--bg-card-hover) 100%);
background: linear-gradient(
135deg,
var(--bg-soft) 0%,
var(--bg-card-hover) 100%
);
color: var(--text-secondary);
font-weight: 600;
}
@ -284,11 +326,26 @@
line-height: 1.2;
}
.player-level.lv1 { background: #E8F5E9; color: #2E7D32; }
.player-level.lv2 { background: #E3F2FD; color: #1565C0; }
.player-level.lv3 { background: #FFF3E0; color: #E65100; }
.player-level.lv4 { background: #FCE4EC; color: #C2185B; }
.player-level.lv5 { background: #F3E5F5; color: #7B1FA2; }
.player-level.lv1 {
background: #e8f5e9;
color: #2e7d32;
}
.player-level.lv2 {
background: #e3f2fd;
color: #1565c0;
}
.player-level.lv3 {
background: #fff3e0;
color: #e65100;
}
.player-level.lv4 {
background: #fce4ec;
color: #c2185b;
}
.player-level.lv5 {
background: #f3e5f5;
color: #7b1fa2;
}
.player-stats {
font-size: 24rpx;
@ -360,4 +417,3 @@
color: var(--text-muted);
font-size: 26rpx;
}

View File

@ -13,8 +13,26 @@ Page({
profileForm: {
avatar: "",
nickname: "",
gender: 0,
},
isEditMode: false, // true: 编辑模式false: 完善模式(登录时)
showGenderModal: false,
registerGender: 0,
},
normalizeLadderUser(ladderUser) {
if (!ladderUser) return null;
const matchCount = Number(ladderUser.matchCount || 0);
const winCount = Number(ladderUser.winCount || 0);
const loseCount = Math.max(matchCount - winCount, 0);
const winRate = matchCount > 0 ? Math.round((winCount / matchCount) * 100) : 0;
return Object.assign({}, ladderUser, {
matchCount,
winCount,
loseCount,
winRate,
});
},
onLoad() {
@ -30,7 +48,7 @@ Page({
// 同步最新数据
this.setData({
userInfo: app.globalData.userInfo,
ladderUser: app.globalData.ladderUser,
ladderUser: this.normalizeLadderUser(app.globalData.ladderUser),
currentStore: app.globalData.currentStore,
});
}
@ -80,7 +98,7 @@ Page({
this.setData({
userInfo: app.globalData.userInfo,
ladderUser: app.globalData.ladderUser,
ladderUser: this.normalizeLadderUser(app.globalData.ladderUser),
currentStore: app.globalData.currentStore,
});
} catch (e) {
@ -95,16 +113,35 @@ Page({
return;
}
try {
wx.showLoading({ title: "登录中..." });
try {
// 如果没有微信登录信息,先登录
if (!app.globalData.wxLoginInfo) {
await app.wxLogin();
}
// 手机号登录(先不传头像昵称)
await app.phoneLogin(e.detail.encryptedData, e.detail.iv, null);
const needGender =
app.globalData.wxLoginInfo && app.globalData.wxLoginInfo.isNewUser;
if (
needGender &&
!(this.data.registerGender === 1 || this.data.registerGender === 2)
) {
this._pendingPhoneLogin = {
encryptedData: e.detail.encryptedData,
iv: e.detail.iv,
};
wx.hideLoading();
this.setData({ showGenderModal: true });
return;
}
await this.doPhoneLogin(
e.detail.encryptedData,
e.detail.iv,
needGender ? this.data.registerGender : 0,
);
// 获取门店信息
await app.getCurrentStore();
@ -113,12 +150,10 @@ Page({
this.setData({
userInfo: userInfo,
ladderUser: app.globalData.ladderUser,
ladderUser: this.normalizeLadderUser(app.globalData.ladderUser),
currentStore: app.globalData.currentStore,
});
wx.hideLoading();
// 检查是否需要完善资料(没有头像或昵称为默认值)
const needProfile =
!userInfo.avatar ||
@ -143,12 +178,89 @@ Page({
wx.showToast({ title: "登录成功", icon: "success" });
}
} catch (e) {
wx.hideLoading();
console.error("登录失败:", e);
wx.showToast({ title: e.message || "登录失败", icon: "none" });
} finally {
wx.hideLoading();
}
},
async doPhoneLogin(encryptedData, iv, gender) {
const g = gender === 1 || gender === 2 ? gender : 0;
await app.phoneLogin(encryptedData, iv, g ? { gender: g } : null);
this._pendingPhoneLogin = null;
this.setData({ showGenderModal: false });
},
onSelectRegisterGender(e) {
const gender = Number(e.currentTarget.dataset.gender);
if (gender !== 1 && gender !== 2) return;
this.setData({ registerGender: gender });
},
async onConfirmRegisterGender() {
if (!(this.data.registerGender === 1 || this.data.registerGender === 2)) {
wx.showToast({ title: "请选择性别", icon: "none" });
return;
}
const pending = this._pendingPhoneLogin;
if (!pending) {
this.setData({ showGenderModal: false });
return;
}
wx.showLoading({ title: "登录中..." });
try {
await this.doPhoneLogin(
pending.encryptedData,
pending.iv,
this.data.registerGender,
);
await app.getCurrentStore();
const userInfo = app.globalData.userInfo;
this.setData({
userInfo: userInfo,
ladderUser: this.normalizeLadderUser(app.globalData.ladderUser),
currentStore: app.globalData.currentStore,
});
const needProfile =
!userInfo.avatar ||
userInfo.avatar === "" ||
!userInfo.nickname ||
userInfo.nickname === "新用户" ||
userInfo.nickname === "";
if (needProfile) {
this.setData({
showProfileModal: true,
isEditMode: false,
profileForm: {
avatar: userInfo.avatar || "/images/avatar-default.svg",
nickname:
userInfo.nickname === "新用户" ? "" : userInfo.nickname || "",
gender: userInfo.gender || 0,
},
});
wx.showToast({ title: "登录成功,请完善资料", icon: "none" });
} else {
wx.showToast({ title: "登录成功", icon: "success" });
}
} catch (e) {
console.error("登录失败:", e);
wx.showToast({ title: e.message || "登录失败", icon: "none" });
} finally {
wx.hideLoading();
}
},
onCancelRegisterGender() {
this._pendingPhoneLogin = null;
this.setData({ showGenderModal: false, registerGender: 0 });
},
// 点击头像,打开编辑资料弹框
onTapAvatar() {
if (!this.data.userInfo || !this.data.userInfo.phone) return;
@ -159,6 +271,7 @@ Page({
profileForm: {
avatar: this.data.userInfo.avatar || "/images/avatar-default.svg",
nickname: this.data.userInfo.nickname || "",
gender: this.data.userInfo.gender || 0,
},
});
},
@ -178,9 +291,17 @@ Page({
});
},
onProfileGenderSelect(e) {
const gender = Number(e.currentTarget.dataset.gender);
if (gender !== 1 && gender !== 2) return;
this.setData({
"profileForm.gender": gender,
});
},
// 确认保存资料
async saveProfile() {
const { avatar, nickname } = this.data.profileForm;
const { avatar, nickname, gender } = this.data.profileForm;
if (!nickname || nickname.trim() === "") {
wx.showToast({ title: "请输入昵称", icon: "none" });
@ -200,26 +321,28 @@ Page({
}
// 调用更新资料接口
const res = await app.request(
"/api/user/profile",
{
const payload = {
nickname: nickname.trim(),
avatar: avatarUrl,
},
"PUT",
);
};
if (gender === 1 || gender === 2) {
payload.gender = gender;
}
const res = await app.request("/api/user/profile", payload, "PUT");
// 更新本地数据服务端已返回完整URL
const userInfo = Object.assign({}, this.data.userInfo, {
nickname: (res.data && res.data.nickname) || nickname.trim(),
avatar: (res.data && res.data.avatar) || avatarUrl,
gender: (res.data && res.data.gender) || this.data.userInfo.gender || 0,
});
app.globalData.userInfo = userInfo;
this.setData({
userInfo: userInfo,
showProfileModal: false,
profileForm: { avatar: "", nickname: "" },
profileForm: { avatar: "", nickname: "", gender: 0 },
});
wx.hideLoading();

View File

@ -101,15 +101,11 @@
<text class="record-label">胜场</text>
</view>
<view class="record-item">
<text class="record-value">
{{(ladderUser.matchCount || 0) - (ladderUser.winCount || 0)}}
</text>
<text class="record-value">{{ladderUser.loseCount || 0}}</text>
<text class="record-label">负场</text>
</view>
<view class="record-item">
<text class="record-value rate">
{{(ladderUser.matchCount > 0 && ladderUser.winCount !== null && ladderUser.winCount !== undefined) ? Math.round((Number(ladderUser.winCount) || 0) / Number(ladderUser.matchCount) * 100) : 0}}%
</text>
<text class="record-value rate">{{ladderUser.winRate || 0}}%</text>
<text class="record-label">胜率</text>
</view>
</view>
@ -155,6 +151,34 @@
</view>
</view>
</view>
<!-- 注册性别选择弹窗(新用户必填) -->
<view class="gender-overlay {{showGenderModal ? 'show' : ''}}" bindtap="onCancelRegisterGender">
<view class="gender-modal {{showGenderModal ? 'show' : ''}}" catchtap="preventBubble">
<view class="gender-modal-header">
<text class="gender-modal-title">请选择性别</text>
<view class="gender-modal-close" bindtap="onCancelRegisterGender">×</view>
</view>
<view class="gender-modal-body">
<view class="gender-tip">
<text>新用户注册需填写性别</text>
</view>
<view class="gender-options">
<view class="gender-option {{registerGender === 1 ? 'active' : ''}}" bindtap="onSelectRegisterGender" data-gender="1">
<text class="gender-emoji">👦</text>
<text class="gender-text">男</text>
</view>
<view class="gender-option {{registerGender === 2 ? 'active' : ''}}" bindtap="onSelectRegisterGender" data-gender="2">
<text class="gender-emoji">👧</text>
<text class="gender-text">女</text>
</view>
</view>
</view>
<view class="gender-modal-footer">
<button class="gender-btn-cancel" bindtap="onCancelRegisterGender">取消</button>
<button class="gender-btn-confirm" bindtap="onConfirmRegisterGender">确定</button>
</view>
</view>
</view>
<!-- 会员码弹窗 -->
<view class="qrcode-overlay {{showQrcode ? 'show' : ''}}" bindtap="hideQrcode">
<view class="qrcode-modal {{showQrcode ? 'show' : ''}}" catchtap="preventBubble">
@ -220,6 +244,18 @@
<text class="profile-label">昵称</text>
<input class="nickname-input" type="nickname" placeholder="请输入昵称" value="{{profileForm.nickname}}" bindinput="onNicknameInput" maxlength="20" />
</view>
<!-- 性别选择 -->
<view class="profile-gender-section">
<text class="profile-label">性别</text>
<view class="gender-options-inline">
<view class="gender-chip {{profileForm.gender === 1 ? 'active' : ''}}" bindtap="onProfileGenderSelect" data-gender="1">
</view>
<view class="gender-chip {{profileForm.gender === 2 ? 'active' : ''}}" bindtap="onProfileGenderSelect" data-gender="2">
</view>
</view>
</view>
</view>
<view class="profile-modal-footer">
<button class="profile-btn-cancel" bindtap="closeProfileModal">

View File

@ -458,19 +458,20 @@
}
.ladder-record {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
padding-top: 20rpx;
border-top: 1rpx solid var(--border-soft);
}
.record-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-width: 0;
}
.record-value {
@ -479,6 +480,7 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4rpx;
line-height: 1.1;
}
.record-value.win {
@ -492,6 +494,7 @@
.record-label {
font-size: 20rpx;
color: var(--text-muted);
line-height: 1.1;
}
/* ==========================================
@ -862,6 +865,174 @@
transition: all 0.35s ease;
}
/* ==========================================
注册性别选择弹窗
========================================== */
.gender-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
visibility: hidden;
transition: all 0.35s ease;
}
.gender-overlay.show {
visibility: visible;
background: rgba(0, 0, 0, 0.55);
}
.gender-modal {
width: 88%;
max-width: 640rpx;
background: var(--bg-white);
border-radius: var(--radius-xl);
overflow: hidden;
transform: scale(0.85) translateY(40rpx);
opacity: 0;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.2);
}
.gender-modal.show {
transform: scale(1) translateY(0);
opacity: 1;
}
.gender-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
background: linear-gradient(90deg, #fff8f5, var(--bg-white));
border-bottom: 1rpx solid var(--border-soft);
}
.gender-modal-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
}
.gender-modal-close {
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--text-muted);
background: var(--bg-soft);
border-radius: 50%;
transition: all 0.2s;
}
.gender-modal-close:active {
background: var(--border-light);
transform: scale(0.9);
}
.gender-modal-body {
padding: 32rpx;
}
.gender-tip {
padding: 18rpx 22rpx;
background: #fffbf5;
border: 1rpx solid #ffe8d5;
border-radius: var(--radius-lg);
margin-bottom: 26rpx;
color: #b7791f;
font-size: 24rpx;
}
.gender-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx;
}
.gender-option {
height: 120rpx;
border-radius: var(--radius-lg);
border: 2rpx solid var(--border-light);
background: var(--bg-soft);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
transition: all 0.2s;
}
.gender-option:active {
transform: scale(0.98);
}
.gender-option.active {
border-color: var(--primary);
background: #fff8f5;
box-shadow: 0 10rpx 28rpx rgba(255, 107, 53, 0.12);
}
.gender-emoji {
font-size: 42rpx;
line-height: 1;
}
.gender-text {
font-size: 28rpx;
font-weight: 700;
color: var(--text-primary);
}
.gender-modal-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx 32rpx;
background: var(--bg-white);
}
.gender-btn-cancel,
.gender-btn-confirm {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
font-size: 28rpx;
font-weight: 600;
border: none;
transition: all 0.2s;
}
.gender-btn-cancel {
background: var(--bg-soft);
color: var(--text-secondary);
}
.gender-btn-cancel:active {
background: var(--border-light);
}
.gender-btn-confirm {
background: var(--primary-gradient);
color: #fff;
box-shadow: var(--shadow-primary);
}
.gender-btn-confirm:active {
transform: scale(0.96);
box-shadow: none;
}
.profile-overlay.show {
visibility: visible;
background: rgba(0, 0, 0, 0.55);
@ -1019,6 +1190,43 @@
flex-direction: column;
}
.profile-gender-section {
display: flex;
flex-direction: column;
margin-top: 28rpx;
}
.gender-options-inline {
display: flex;
gap: 16rpx;
}
.gender-chip {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--bg-soft);
border: 2rpx solid var(--border-light);
color: var(--text-secondary);
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s;
}
.gender-chip:active {
transform: scale(0.98);
}
.gender-chip.active {
background: var(--primary-gradient);
border-color: rgba(255, 107, 53, 0.25);
color: #fff;
box-shadow: var(--shadow-primary);
}
.nickname-input {
height: 96rpx;
padding: 0 24rpx;

View File

@ -7,7 +7,8 @@
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"db:init": "node src/scripts/initDatabase.js",
"db:mock": "node src/scripts/generateMockLadderData.js"
"db:mock": "node src/scripts/generateMockLadderData.js",
"db:challenge-weight-1": "node src/scripts/setChallengeMatchWeightTo1.js"
},
"dependencies": {
"express": "^4.18.2",

View File

@ -75,7 +75,7 @@ const POWER_CALC = {
// 比赛权重
const MATCH_WEIGHTS = {
DAILY: 1.0, // 日常畅打
CHALLENGE: 1.5, // 挑战赛
CHALLENGE: 1.0, // 挑战赛
MONTHLY: 1.5, // 月度排位赛
SEASONAL: 2.0, // 季度/年度总决赛
};

View File

@ -24,6 +24,7 @@ class LadderController {
page = 1,
pageSize = 50,
is_display,
no_count,
} = req.query;
const { limit, offset } = getPagination(page, pageSize);
@ -50,15 +51,39 @@ class LadderController {
where.level = level;
}
const { rows, count } = await LadderUser.findAndCountAll({
where,
include: [
const include = [
{ model: User, as: "user", attributes: ["nickname", "avatar"] },
],
order: [["power_score", "DESC"]],
];
const order = [["power_score", "DESC"]];
const useNoCount =
no_count === "1" ||
no_count === 1 ||
no_count === true ||
no_count === "true";
let rows = [];
let count = 0;
if (useNoCount) {
rows = await LadderUser.findAll({
where,
include,
order,
limit,
offset,
});
count = offset === 0 ? rows.length : offset + rows.length;
} else {
const result = await LadderUser.findAndCountAll({
where,
include,
order,
limit,
offset,
});
rows = result.rows;
count = result.count;
}
// 添加排名
const startRank = offset + 1;
@ -88,6 +113,99 @@ class LadderController {
}
}
// 获取我的排名(用于小程序定位)
async getMyRank(req, res) {
try {
const user = req.user;
const { store_id, gender, pageSize = 20, is_display } = req.query;
if (!store_id) {
return res.status(400).json(error("缺少门店ID", 400));
}
const normalizedGender =
gender === undefined || gender === null || gender === ""
? null
: Number(gender);
if (
normalizedGender !== null &&
normalizedGender !== 1 &&
normalizedGender !== 2
) {
return res.status(400).json(error("性别参数无效", 400));
}
const normalizedPageSize = Math.min(parseInt(pageSize) || 20, 100);
const ladderUser = await LadderUser.findOne({
where: {
store_id,
user_id: user.id,
status: 1,
},
});
if (!ladderUser) {
return res.status(404).json(error("未加入该门店天梯", 404));
}
if (
!is_display &&
(ladderUser.monthly_match_count || 0) < POWER_CALC.MIN_MONTHLY_MATCHES
) {
return res.json(
success({
qualified: false,
ladderUserId: ladderUser.id,
monthlyMatchCount: ladderUser.monthly_match_count || 0,
minMonthlyMatches: POWER_CALC.MIN_MONTHLY_MATCHES,
}),
);
}
const where = {
store_id,
status: 1,
};
if (!is_display) {
where.monthly_match_count = {
[Op.gte]: POWER_CALC.MIN_MONTHLY_MATCHES,
};
}
if (normalizedGender) {
where.gender = normalizedGender;
}
const higherCount = await LadderUser.count({
where: {
...where,
power_score: { [Op.gt]: ladderUser.power_score },
},
});
const rank = higherCount + 1;
const page = Math.floor((rank - 1) / normalizedPageSize) + 1;
const indexInPage = (rank - 1) % normalizedPageSize;
res.json(
success({
qualified: true,
ladderUserId: ladderUser.id,
rank,
page,
indexInPage,
powerScore: ladderUser.power_score,
gender: ladderUser.gender,
}),
);
} catch (err) {
console.error("获取我的排名失败:", err);
res.status(500).json(error("获取失败"));
}
}
// 获取天梯用户详情
async getUserDetail(req, res) {
try {

View File

@ -186,7 +186,23 @@ class MatchAdminController {
return res.status(404).json(error('比赛不存在', 404));
}
await match.update({ name, weight, referee_id });
const updateData = {};
if (name !== undefined) updateData.name = name;
if (referee_id !== undefined) updateData.referee_id = referee_id;
if (weight !== undefined) {
const normalizedWeight = parseFloat(weight);
if (!Number.isFinite(normalizedWeight) || normalizedWeight <= 0) {
return res.status(400).json(error('权重参数无效', 400));
}
updateData.weight = normalizedWeight;
}
if (match.type === MATCH_TYPES.CHALLENGE) {
updateData.weight = 1.0;
}
await match.update(updateData);
res.json(success(null, '更新成功'));
} catch (err) {
console.error('更新比赛失败:', err);

View File

@ -1310,6 +1310,70 @@ class MatchController {
}
}
// 大屏:进行中/近7天比赛列表公开
async getDisplayList(req, res) {
try {
const { store_id, days = 7, limit = 20 } = req.query;
const normalizedDays = Math.min(Math.max(parseInt(days) || 7, 1), 30);
const normalizedLimit = Math.min(Math.max(parseInt(limit) || 20, 1), 100);
const sinceTime = new Date(Date.now() - normalizedDays * 24 * 60 * 60 * 1000);
const where = {
status: { [Op.in]: [MATCH_STATUS.PENDING, MATCH_STATUS.ONGOING, MATCH_STATUS.FINISHED] },
[Op.or]: [
{ status: { [Op.in]: [MATCH_STATUS.PENDING, MATCH_STATUS.ONGOING] } },
{ created_at: { [Op.gte]: sinceTime } },
{ start_time: { [Op.gte]: sinceTime } },
],
};
if (store_id) where.store_id = store_id;
const matches = await Match.findAll({
where,
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
order: [
["status", "ASC"],
["start_time", "DESC"],
["created_at", "DESC"],
],
limit: normalizedLimit,
});
const list = matches.map((match) => ({
id: match.id,
matchCode: match.match_code,
type: match.type,
typeName: match.type === MATCH_TYPES.CHALLENGE ? "挑战赛" : "排位赛",
name: match.name,
status: match.status,
statusName:
match.status === MATCH_STATUS.PENDING
? "待开始"
: match.status === MATCH_STATUS.ONGOING
? "进行中"
: match.status === MATCH_STATUS.FINISHED
? "已结束"
: "已取消",
stage: match.stage,
stageName:
match.type === MATCH_TYPES.RANKING
? ["报名中", "循环赛", "淘汰赛", "已结束"][match.stage]
: null,
startTime: match.start_time,
endTime: match.end_time,
createdAt: match.created_at,
storeId: match.store?.id,
storeName: match.store?.name,
}));
res.json(success(list));
} catch (err) {
console.error("获取大屏比赛列表失败:", err);
res.status(500).json(error("获取失败"));
}
}
// 获取待确认的比赛
async getPendingConfirm(req, res) {
try {

View File

@ -1,9 +1,21 @@
const { User, PointAction, PointRecord, PointProduct, PointOrder, Store } = require('../models');
const { ORDER_STATUS, POINTS_CALC } = require('../config/constants');
const { success, error, getPagination, pageResult } = require('../utils/helper');
const PowerCalculator = require('../services/powerCalculator');
const { Op } = require('sequelize');
const sequelize = require('../config/database');
const {
User,
PointAction,
PointRecord,
PointProduct,
PointOrder,
Store,
} = require("../models");
const { ORDER_STATUS, POINTS_CALC } = require("../config/constants");
const {
success,
error,
getPagination,
pageResult,
} = require("../utils/helper");
const PowerCalculator = require("../services/powerCalculator");
const { Op } = require("sequelize");
const sequelize = require("../config/database");
class PointsAdminController {
// === 积分行为管理 ===
@ -14,7 +26,7 @@ class PointsAdminController {
const admin = req.admin;
const where = { status: 1 };
if (admin.role === 'super_admin') {
if (admin.role === "super_admin") {
if (store_id) where.store_id = store_id;
} else {
where.store_id = admin.store_id;
@ -22,13 +34,18 @@ class PointsAdminController {
const { rows, count } = await PointAction.findAndCountAll({
where,
include: [{ model: Store, as: 'store', attributes: ['id', 'name'] }],
order: [['show_quick', 'DESC'], ['created_at', 'DESC']],
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
order: [
["show_quick", "DESC"],
["created_at", "DESC"],
],
limit,
offset
offset,
});
res.json(pageResult(rows.map(action => ({
res.json(
pageResult(
rows.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
@ -36,22 +53,29 @@ class PointsAdminController {
isConsume: action.is_consume,
showQuick: action.show_quick,
storeId: action.store_id,
storeName: action.store?.name
})), count, page, pageSize));
storeName: action.store?.name,
})),
count,
page,
pageSize,
),
);
} catch (err) {
console.error('获取积分行为列表失败:', err);
res.status(500).json(error('获取失败'));
console.error("获取积分行为列表失败:", err);
res.status(500).json(error("获取失败"));
}
}
async createAction(req, res) {
try {
const { store_id, name, description, points, is_consume, show_quick } = req.body;
const { store_id, name, description, points, is_consume, show_quick } =
req.body;
const admin = req.admin;
const targetStoreId = admin.role === 'super_admin' ? store_id : admin.store_id;
const targetStoreId =
admin.role === "super_admin" ? store_id : admin.store_id;
if (!targetStoreId) {
return res.status(400).json(error('请选择门店', 400));
return res.status(400).json(error("请选择门店", 400));
}
const action = await PointAction.create({
@ -61,24 +85,25 @@ class PointsAdminController {
points: points || 0,
is_consume: is_consume || 0,
show_quick: show_quick || 0,
status: 1
status: 1,
});
res.json(success({ id: action.id }, '创建成功'));
res.json(success({ id: action.id }, "创建成功"));
} catch (err) {
console.error('创建积分行为失败:', err);
res.status(500).json(error('创建失败'));
console.error("创建积分行为失败:", err);
res.status(500).json(error("创建失败"));
}
}
async updateAction(req, res) {
try {
const { id } = req.params;
const { name, description, points, is_consume, show_quick, status } = req.body;
const { name, description, points, is_consume, show_quick, status } =
req.body;
const action = await PointAction.findByPk(id);
if (!action) {
return res.status(404).json(error('行为不存在', 404));
return res.status(404).json(error("行为不存在", 404));
}
await action.update({
@ -87,13 +112,13 @@ class PointsAdminController {
points: points !== undefined ? points : action.points,
is_consume: is_consume !== undefined ? is_consume : action.is_consume,
show_quick: show_quick !== undefined ? show_quick : action.show_quick,
status: status !== undefined ? status : action.status
status: status !== undefined ? status : action.status,
});
res.json(success(null, '更新成功'));
res.json(success(null, "更新成功"));
} catch (err) {
console.error('更新积分行为失败:', err);
res.status(500).json(error('更新失败'));
console.error("更新积分行为失败:", err);
res.status(500).json(error("更新失败"));
}
}
@ -103,14 +128,14 @@ class PointsAdminController {
const action = await PointAction.findByPk(id);
if (!action) {
return res.status(404).json(error('行为不存在', 404));
return res.status(404).json(error("行为不存在", 404));
}
await action.update({ status: 0 });
res.json(success(null, '删除成功'));
res.json(success(null, "删除成功"));
} catch (err) {
console.error('删除积分行为失败:', err);
res.status(500).json(error('删除失败'));
console.error("删除积分行为失败:", err);
res.status(500).json(error("删除失败"));
}
}
@ -121,52 +146,95 @@ class PointsAdminController {
const { action_id, member_code, consume_amount } = req.body;
const admin = req.admin;
if (!action_id) {
await t.rollback();
return res.status(400).json(error("缺少行为ID", 400));
}
if (!member_code) {
await t.rollback();
return res.status(400).json(error("缺少用户信息", 400));
}
// 获取行为
const action = await PointAction.findByPk(action_id);
if (!action || action.status !== 1) {
await t.rollback();
return res.status(404).json(error('行为不存在', 404));
return res.status(404).json(error("行为不存在", 404));
}
if (
admin.role !== "super_admin" &&
String(action.store_id) !== String(admin.store_id)
) {
await t.rollback();
return res.status(403).json(error("无权限操作该门店行为", 403));
}
// 通过会员码、手机号或昵称获取用户
let user = null;
if (member_code) {
const keyword = String(member_code).trim();
// 先尝试通过会员码查询
user = await User.findOne({ where: { member_code } });
user = await User.findOne({
where: { member_code: keyword, status: 1 },
});
// 如果会员码查询失败,尝试通过手机号查询
if (!user && /^1[3-9]\d{9}$/.test(member_code)) {
user = await User.findOne({ where: { phone: member_code } });
if (!user && /^1[3-9]\d{9}$/.test(keyword)) {
user = await User.findOne({ where: { phone: keyword, status: 1 } });
}
image.png
// 如果还是没找到,尝试通过昵称查询
if (!user) {
user = await User.findOne({
where: {
nickname: { [Op.like]: `%${member_code}%` },
status: 1
}
nickname: { [Op.like]: `%${keyword}%` },
status: 1,
},
});
}
}
if (!user) {
await t.rollback();
return res.status(404).json(error('用户不存在', 404));
return res.status(404).json(error("用户不存在", 404));
}
// 计算积分
let points = action.points;
if (action.is_consume === 1 && consume_amount) {
points = PowerCalculator.calculateConsumePoints(parseFloat(consume_amount));
let points = Number(action.points);
if (!Number.isFinite(points)) {
await t.rollback();
return res.status(400).json(error("行为积分配置无效", 400));
}
if (
action.is_consume === 1 &&
consume_amount !== undefined &&
consume_amount !== null &&
consume_amount !== ""
) {
const amount = Number(consume_amount);
if (!Number.isFinite(amount) || amount < 0) {
await t.rollback();
return res.status(400).json(error("消费金额无效", 400));
}
points = PowerCalculator.calculateConsumePoints(amount);
}
// 更新用户积分
const newBalance = user.total_points + points;
const currentBalance = Number(user.total_points) || 0;
const newBalance = currentBalance + points;
if (!Number.isFinite(newBalance)) {
await t.rollback();
return res.status(400).json(error("积分计算失败", 400));
}
if (newBalance < 0) {
await t.rollback();
return res.status(400).json(error("积分不足", 400));
}
await user.update({ total_points: newBalance }, { transaction: t });
// 记录积分变动
await PointRecord.create({
await PointRecord.create(
{
user_id: user.id,
store_id: action.store_id,
action_id: action.id,
@ -174,21 +242,28 @@ class PointsAdminController {
points,
balance: newBalance,
consume_amount: action.is_consume === 1 ? consume_amount : null,
operator_id: admin.id
}, { transaction: t });
operator_id: admin.id,
},
{ transaction: t },
);
await t.commit();
res.json(success({
res.json(
success(
{
userId: user.id,
nickname: user.nickname,
points,
newBalance
}, '操作成功'));
newBalance,
},
"操作成功",
),
);
} catch (err) {
await t.rollback();
console.error('执行积分行为失败:', err);
res.status(500).json(error('操作失败'));
console.error("执行积分行为失败:", err);
res.status(500).json(error("操作失败"));
}
}
@ -200,24 +275,29 @@ class PointsAdminController {
const admin = req.admin;
const where = {};
if (admin.role === 'super_admin') {
if (admin.role === "super_admin") {
if (store_id) where.store_id = store_id;
} else {
where.store_id = admin.store_id;
}
if (status !== undefined && status !== '') {
if (status !== undefined && status !== "") {
where.status = status;
}
const { rows, count } = await PointProduct.findAndCountAll({
where,
include: [{ model: Store, as: 'store', attributes: ['id', 'name'] }],
order: [['sort_order', 'ASC'], ['created_at', 'DESC']],
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
order: [
["sort_order", "ASC"],
["created_at", "DESC"],
],
limit,
offset
offset,
});
res.json(pageResult(rows.map(product => ({
res.json(
pageResult(
rows.map((product) => ({
id: product.id,
name: product.name,
description: product.description,
@ -230,22 +310,37 @@ class PointsAdminController {
status: product.status,
storeId: product.store_id,
storeName: product.store?.name,
createdAt: product.created_at
})), count, page, pageSize));
createdAt: product.created_at,
})),
count,
page,
pageSize,
),
);
} catch (err) {
console.error('获取商品列表失败:', err);
res.status(500).json(error('获取失败'));
console.error("获取商品列表失败:", err);
res.status(500).json(error("获取失败"));
}
}
async createProduct(req, res) {
try {
const { store_id, name, description, image, points_required, original_price, stock, sort_order } = req.body;
const {
store_id,
name,
description,
image,
points_required,
original_price,
stock,
sort_order,
} = req.body;
const admin = req.admin;
const targetStoreId = admin.role === 'super_admin' ? store_id : admin.store_id;
const targetStoreId =
admin.role === "super_admin" ? store_id : admin.store_id;
if (!targetStoreId) {
return res.status(400).json(error('请选择门店', 400));
return res.status(400).json(error("请选择门店", 400));
}
const product = await PointProduct.create({
@ -257,13 +352,13 @@ class PointsAdminController {
original_price,
stock: stock || 0,
sort_order: sort_order || 0,
status: 1
status: 1,
});
res.json(success({ id: product.id }, '创建成功'));
res.json(success({ id: product.id }, "创建成功"));
} catch (err) {
console.error('创建商品失败:', err);
res.status(500).json(error('创建失败'));
console.error("创建商品失败:", err);
res.status(500).json(error("创建失败"));
}
}
@ -274,14 +369,14 @@ class PointsAdminController {
const product = await PointProduct.findByPk(id);
if (!product) {
return res.status(404).json(error('商品不存在', 404));
return res.status(404).json(error("商品不存在", 404));
}
await product.update(data);
res.json(success(null, '更新成功'));
res.json(success(null, "更新成功"));
} catch (err) {
console.error('更新商品失败:', err);
res.status(500).json(error('更新失败'));
console.error("更新商品失败:", err);
res.status(500).json(error("更新失败"));
}
}
@ -291,14 +386,14 @@ class PointsAdminController {
const product = await PointProduct.findByPk(id);
if (!product) {
return res.status(404).json(error('商品不存在', 404));
return res.status(404).json(error("商品不存在", 404));
}
await product.update({ status: 0 });
res.json(success(null, '删除成功'));
res.json(success(null, "删除成功"));
} catch (err) {
console.error('删除商品失败:', err);
res.status(500).json(error('删除失败'));
console.error("删除商品失败:", err);
res.status(500).json(error("删除失败"));
}
}
@ -310,34 +405,44 @@ class PointsAdminController {
const admin = req.admin;
const where = {};
if (admin.role === 'super_admin') {
if (admin.role === "super_admin") {
if (store_id) where.store_id = store_id;
} else {
where.store_id = admin.store_id;
}
if (status !== undefined && status !== '') {
if (status !== undefined && status !== "") {
where.status = status;
}
if (keyword) {
where[Op.or] = [
{ order_no: { [Op.like]: `%${keyword}%` } },
{ exchange_code: { [Op.like]: `%${keyword}%` } }
{ exchange_code: { [Op.like]: `%${keyword}%` } },
];
}
const { rows, count } = await PointOrder.findAndCountAll({
where,
include: [
{ model: User, as: 'user', attributes: ['id', 'nickname', 'phone', 'member_code'] },
{ model: PointProduct, as: 'product', attributes: ['id', 'name', 'image'] },
{ model: Store, as: 'store', attributes: ['id', 'name'] }
{
model: User,
as: "user",
attributes: ["id", "nickname", "phone", "member_code"],
},
{
model: PointProduct,
as: "product",
attributes: ["id", "name", "image"],
},
{ model: Store, as: "store", attributes: ["id", "name"] },
],
order: [['created_at', 'DESC']],
order: [["created_at", "DESC"]],
limit,
offset
offset,
});
res.json(pageResult(rows.map(order => ({
res.json(
pageResult(
rows.map((order) => ({
id: order.id,
orderNo: order.order_no,
userId: order.user_id,
@ -353,11 +458,16 @@ class PointsAdminController {
storeId: order.store_id,
storeName: order.store?.name,
createdAt: order.created_at,
verifiedAt: order.verified_at
})), count, page, pageSize));
verifiedAt: order.verified_at,
})),
count,
page,
pageSize,
),
);
} catch (err) {
console.error('获取订单列表失败:', err);
res.status(500).json(error('获取失败'));
console.error("获取订单列表失败:", err);
res.status(500).json(error("获取失败"));
}
}
@ -369,23 +479,23 @@ class PointsAdminController {
const order = await PointOrder.findByPk(id);
if (!order) {
return res.status(404).json(error('订单不存在', 404));
return res.status(404).json(error("订单不存在", 404));
}
if (order.status !== ORDER_STATUS.PENDING) {
return res.status(400).json(error('订单状态无效', 400));
return res.status(400).json(error("订单状态无效", 400));
}
await order.update({
status: ORDER_STATUS.COMPLETED,
verified_at: new Date(),
verified_by: admin.id
verified_by: admin.id,
});
res.json(success(null, '核销成功'));
res.json(success(null, "核销成功"));
} catch (err) {
console.error('核销订单失败:', err);
res.status(500).json(error('核销失败'));
console.error("核销订单失败:", err);
res.status(500).json(error("核销失败"));
}
}
@ -398,39 +508,46 @@ class PointsAdminController {
const order = await PointOrder.findOne({
where: { exchange_code },
include: [
{ model: User, as: 'user' },
{ model: PointProduct, as: 'product' }
]
{ model: User, as: "user" },
{ model: PointProduct, as: "product" },
],
});
if (!order) {
return res.status(404).json(error('订单不存在', 404));
return res.status(404).json(error("订单不存在", 404));
}
// 验证门店权限
if (admin.role !== 'super_admin' && order.store_id !== admin.store_id) {
return res.status(403).json(error('该订单不属于您的门店', 403));
if (admin.role !== "super_admin" && order.store_id !== admin.store_id) {
return res.status(403).json(error("该订单不属于您的门店", 403));
}
if (order.status !== ORDER_STATUS.PENDING) {
return res.status(400).json(error(`订单${order.status === 1 ? '已核销' : '已取消'}`, 400));
return res
.status(400)
.json(error(`订单${order.status === 1 ? "已核销" : "已取消"}`, 400));
}
await order.update({
status: ORDER_STATUS.COMPLETED,
verified_at: new Date(),
verified_by: admin.id
verified_by: admin.id,
});
res.json(success({
res.json(
success(
{
orderId: order.id,
orderNo: order.order_no,
productName: order.product_name,
userNickname: order.user?.nickname
}, '核销成功'));
userNickname: order.user?.nickname,
},
"核销成功",
),
);
} catch (err) {
console.error('核销订单失败:', err);
res.status(500).json(error('核销失败'));
console.error("核销订单失败:", err);
res.status(500).json(error("核销失败"));
}
}
}

View File

@ -1,11 +1,18 @@
const jwt = require('jsonwebtoken');
const axios = require('axios');
const crypto = require('crypto');
const QRCode = require('qrcode');
const { User, LadderUser, Store, Match, MatchGame } = require('../models');
const { generateMemberCode, success, error, calculateDistance, getFullUrl, normalizeAvatarUrl } = require('../utils/helper');
const { LADDER_LEVEL_NAMES } = require('../config/constants');
const { Op } = require('sequelize');
const jwt = require("jsonwebtoken");
const axios = require("axios");
const crypto = require("crypto");
const QRCode = require("qrcode");
const { User, LadderUser, Store, Match, MatchGame } = require("../models");
const {
generateMemberCode,
success,
error,
calculateDistance,
getFullUrl,
normalizeAvatarUrl,
} = require("../utils/helper");
const { LADDER_LEVEL_NAMES } = require("../config/constants");
const { Op } = require("sequelize");
class UserController {
// 微信登录(获取 session_key用于后续手机号解密
@ -14,21 +21,26 @@ class UserController {
const { code } = req.body;
if (!code) {
return res.status(400).json(error('缺少登录code', 400));
return res.status(400).json(error("缺少登录code", 400));
}
// 获取微信openid和session_key
const wxRes = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
const wxRes = await axios.get(
"https://api.weixin.qq.com/sns/jscode2session",
{
params: {
appid: process.env.WX_APPID,
secret: process.env.WX_SECRET,
js_code: code,
grant_type: 'authorization_code'
}
});
grant_type: "authorization_code",
},
},
);
if (wxRes.data.errcode) {
return res.status(400).json(error('微信登录失败: ' + wxRes.data.errmsg, 400));
return res
.status(400)
.json(error("微信登录失败: " + wxRes.data.errmsg, 400));
}
const { openid, unionid, session_key } = wxRes.data;
@ -46,55 +58,77 @@ class UserController {
// 这里为了简化流程使用加密后的session_key
const encryptedSessionKey = this.encryptSessionKey(session_key, openid);
res.json(success({
res.json(
success(
{
openid,
unionid,
sessionKey: encryptedSessionKey,
isNewUser,
hasPhone: user?.phone ? true : false,
userInfo: user ? {
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 ? '请授权手机号完成注册' : '登录成功'));
totalPoints: user.total_points,
}
: null,
},
isNewUser ? "请授权手机号完成注册" : "登录成功",
),
);
} catch (err) {
console.error('登录失败:', err);
res.status(500).json(error('登录失败'));
}
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 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');
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 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');
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
let decrypted = decipher.update(encryptedSessionKey, "base64", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
// 手机号授权登录(解密手机号并完成注册/登录)
phoneLogin = async (req, res) => {
try {
const { openid, unionid, sessionKey, encryptedData, iv, nickname, avatar, gender } = req.body;
const {
openid,
unionid,
sessionKey,
encryptedData,
iv,
nickname,
avatar,
gender,
} = req.body;
if (!openid || !sessionKey || !encryptedData || !iv) {
return res.status(400).json(error('参数不完整', 400));
return res.status(400).json(error("参数不完整", 400));
}
// 解密session_key
@ -102,7 +136,7 @@ class UserController {
try {
realSessionKey = this.decryptSessionKey(sessionKey, openid);
} catch (e) {
return res.status(400).json(error('会话已过期,请重新登录', 400));
return res.status(400).json(error("会话已过期,请重新登录", 400));
}
// 解密手机号
@ -110,12 +144,24 @@ class UserController {
try {
phone = this.decryptPhoneNumber(realSessionKey, encryptedData, iv);
} catch (e) {
console.error('手机号解密失败:', e);
return res.status(400).json(error('手机号解密失败,请重新授权', 400));
console.error("手机号解密失败:", e);
return res.status(400).json(error("手机号解密失败,请重新授权", 400));
}
if (!phone) {
return res.status(400).json(error('获取手机号失败', 400));
return res.status(400).json(error("获取手机号失败", 400));
}
const normalizedGender =
gender === undefined || gender === null || gender === ""
? undefined
: Number(gender);
if (
normalizedGender !== undefined &&
normalizedGender !== 1 &&
normalizedGender !== 2
) {
return res.status(400).json(error("性别参数无效", 400));
}
// 查找或创建用户
@ -126,33 +172,47 @@ class UserController {
// 只有当 openid 是自动生成的格式时才允许合并
const existingUser = await User.findOne({ where: { phone } });
if (existingUser && existingUser.openid.startsWith('AUTO_GEN_')) {
if (existingUser && existingUser.openid.startsWith("AUTO_GEN_")) {
// 合并账号:更新 openid 为真实的微信 openid
console.log(`合并账号: 将自动生成用户 ${existingUser.id} (${existingUser.openid}) 更新为微信用户 ${openid}`);
console.log(
`合并账号: 将自动生成用户 ${existingUser.id} (${existingUser.openid}) 更新为微信用户 ${openid}`,
);
const updateData = {
openid,
unionid
unionid,
};
if (nickname) updateData.nickname = nickname;
if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req);
if (gender !== undefined) updateData.gender = gender;
if (
(existingUser.gender === 0 || existingUser.gender === null) &&
normalizedGender === undefined
) {
return res.status(400).json(error("性别必填", 400));
}
if (normalizedGender !== undefined)
updateData.gender = normalizedGender;
await existingUser.update(updateData);
user = existingUser;
} else {
// 新用户注册
if (normalizedGender === undefined) {
return res.status(400).json(error("性别必填", 400));
}
// 规范化头像URL如果是 https 则保持 https否则是 http
const normalizedAvatar = avatar ? normalizeAvatarUrl(avatar, req) : '';
const normalizedAvatar = avatar
? normalizeAvatarUrl(avatar, req)
: "";
user = await User.create({
openid,
unionid,
phone,
member_code: generateMemberCode(),
nickname: nickname || '新用户',
nickname: nickname || "新用户",
avatar: normalizedAvatar,
gender: gender || 0,
status: 1
gender: normalizedGender,
status: 1,
});
// 关联已存在的天梯用户(通过手机号)
@ -164,7 +224,8 @@ class UserController {
if (nickname) updateData.nickname = nickname;
// 规范化头像URL如果是 https 则保持 https否则是 http
if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req);
if (gender !== undefined) updateData.gender = gender;
if (normalizedGender !== undefined)
updateData.gender = normalizedGender;
await user.update(updateData);
@ -173,19 +234,19 @@ class UserController {
}
// 生成token
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
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'] }]
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
});
res.json(success({
res.json(
success(
{
token,
userInfo: {
id: user.id,
@ -195,34 +256,41 @@ class UserController {
gender: user.gender,
memberCode: user.member_code,
totalPoints: user.total_points,
ladderUsers: ladderUsers.map(lu => ({
ladderUsers: ladderUsers.map((lu) => ({
id: lu.id,
storeId: lu.store_id,
storeName: lu.store?.name,
realName: lu.real_name,
level: lu.level,
levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`,
powerScore: lu.power_score
}))
}
}, '登录成功'));
powerScore: lu.power_score,
})),
},
},
"登录成功",
),
);
} catch (err) {
console.error('手机号登录失败:', err);
res.status(500).json(error('登录失败'));
}
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 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);
const decipher = crypto.createDecipheriv(
"aes-128-cbc",
sessionKeyBuffer,
ivBuffer,
);
decipher.setAutoPadding(true);
let decoded = decipher.update(encryptedDataBuffer, 'binary', 'utf8');
decoded += decipher.final('utf8');
let decoded = decipher.update(encryptedDataBuffer, "binary", "utf8");
decoded += decipher.final("utf8");
const result = JSON.parse(decoded);
return result.phoneNumber || result.purePhoneNumber;
@ -235,17 +303,19 @@ class UserController {
where: {
phone,
user_id: null,
status: 1
}
status: 1,
},
});
// 批量更新关联
if (unlinkedLadderUsers.length > 0) {
await LadderUser.update(
{ user_id: userId },
{ where: { phone, user_id: null } }
{ where: { phone, user_id: null } },
);
console.log(
`已关联 ${unlinkedLadderUsers.length} 个天梯用户到用户 ${userId}`,
);
console.log(`已关联 ${unlinkedLadderUsers.length} 个天梯用户到用户 ${userId}`);
}
}
@ -259,20 +329,31 @@ class UserController {
if (nickname) updateData.nickname = nickname;
// 规范化头像URL如果是 https 则保持 https否则是 http
if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req);
if (gender !== undefined) updateData.gender = gender;
if (gender !== undefined) {
const normalizedGender = Number(gender);
if (normalizedGender !== 1 && normalizedGender !== 2) {
return res.status(400).json(error("性别参数无效", 400));
}
updateData.gender = normalizedGender;
}
await user.update(updateData);
res.json(success({
res.json(
success(
{
nickname: user.nickname,
avatar: getFullUrl(user.avatar, req),
gender: user.gender
}, '更新成功'));
gender: user.gender,
},
"更新成功",
),
);
} catch (err) {
console.error('更新资料失败:', err);
res.status(500).json(error('更新失败'));
}
console.error("更新资料失败:", err);
res.status(500).json(error("更新失败"));
}
};
// 获取用户信息
getInfo = async (req, res) => {
@ -282,10 +363,11 @@ class UserController {
// 获取天梯信息
const ladderUsers = await LadderUser.findAll({
where: { user_id: user.id, status: 1 },
include: [{ model: Store, as: 'store', attributes: ['id', 'name'] }]
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
});
res.json(success({
res.json(
success({
id: user.id,
nickname: user.nickname,
avatar: getFullUrl(user.avatar, req),
@ -293,7 +375,7 @@ class UserController {
gender: user.gender,
memberCode: user.member_code,
totalPoints: user.total_points,
ladderUsers: ladderUsers.map(lu => ({
ladderUsers: ladderUsers.map((lu) => ({
id: lu.id,
storeId: lu.store_id,
storeName: lu.store?.name,
@ -302,14 +384,15 @@ class UserController {
levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`,
powerScore: lu.power_score,
matchCount: lu.match_count,
winCount: lu.win_count
}))
}));
winCount: lu.win_count,
})),
}),
);
} catch (err) {
console.error('获取用户信息失败:', err);
res.status(500).json(error('获取用户信息失败'));
}
console.error("获取用户信息失败:", err);
res.status(500).json(error("获取用户信息失败"));
}
};
// 更新用户信息
updateInfo = async (req, res) => {
@ -321,27 +404,29 @@ class UserController {
nickname: nickname || user.nickname,
avatar: avatar || user.avatar,
phone: phone || user.phone,
gender: gender !== undefined ? gender : user.gender
gender: gender !== undefined ? gender : user.gender,
});
res.json(success(null, '更新成功'));
res.json(success(null, "更新成功"));
} catch (err) {
console.error('更新用户信息失败:', err);
res.status(500).json(error('更新失败'));
}
console.error("更新用户信息失败:", err);
res.status(500).json(error("更新失败"));
}
};
// 获取会员码
getMemberCode = async (req, res) => {
try {
res.json(success({
memberCode: req.user.member_code
}));
res.json(
success({
memberCode: req.user.member_code,
}),
);
} catch (err) {
console.error('获取会员码失败:', err);
res.status(500).json(error('获取失败'));
}
console.error("获取会员码失败:", err);
res.status(500).json(error("获取失败"));
}
};
// 通过会员码查询用户
getByMemberCode = async (req, res) => {
@ -350,38 +435,42 @@ class UserController {
const { store_id } = req.query;
const user = await User.findOne({
where: { member_code: code, status: 1 }
where: { member_code: code, status: 1 },
});
if (!user) {
return res.status(404).json(error('用户不存在', 404));
return res.status(404).json(error("用户不存在", 404));
}
// 获取该门店的天梯信息
let ladderUser = null;
if (store_id) {
ladderUser = await LadderUser.findOne({
where: { user_id: user.id, store_id, status: 1 }
where: { user_id: user.id, store_id, status: 1 },
});
}
res.json(success({
res.json(
success({
id: user.id,
nickname: user.nickname,
avatar: user.avatar,
gender: user.gender,
ladderUser: ladderUser ? {
ladderUser: ladderUser
? {
id: ladderUser.id,
realName: ladderUser.real_name,
level: ladderUser.level,
powerScore: ladderUser.power_score
} : null
}));
powerScore: ladderUser.power_score,
}
: null,
}),
);
} catch (err) {
console.error('查询用户失败:', err);
res.status(500).json(error('查询失败'));
}
console.error("查询用户失败:", err);
res.status(500).json(error("查询失败"));
}
};
// 获取用户天梯信息
getLadderInfo = async (req, res) => {
@ -396,10 +485,12 @@ class UserController {
const ladderUsers = await LadderUser.findAll({
where,
include: [{ model: Store, as: 'store', attributes: ['id', 'name'] }]
include: [{ model: Store, as: "store", attributes: ["id", "name"] }],
});
res.json(success(ladderUsers.map(lu => ({
res.json(
success(
ladderUsers.map((lu) => ({
id: lu.id,
storeId: lu.store_id,
storeName: lu.store?.name,
@ -411,13 +502,15 @@ class UserController {
matchCount: lu.match_count,
winCount: lu.win_count,
monthlyMatchCount: lu.monthly_match_count,
lastMatchTime: lu.last_match_time
}))));
lastMatchTime: lu.last_match_time,
})),
),
);
} catch (err) {
console.error('获取天梯信息失败:', err);
res.status(500).json(error('获取失败'));
}
console.error("获取天梯信息失败:", err);
res.status(500).json(error("获取失败"));
}
};
// 获取当前门店
getCurrentStore = async (req, res) => {
@ -428,26 +521,28 @@ class UserController {
// 获取用户参与天梯的门店
const ladderUsers = await LadderUser.findAll({
where: { user_id: user.id, status: 1 },
include: [{ model: Store, as: 'store' }],
order: [['last_match_time', 'DESC']]
include: [{ model: Store, as: "store" }],
order: [["last_match_time", "DESC"]],
});
if (ladderUsers.length > 0) {
// 有参与天梯的门店,返回最近参与比赛的门店
const lu = ladderUsers[0];
return res.json(success({
return res.json(
success({
storeId: lu.store_id,
storeName: lu.store?.name,
storeAddress: lu.store?.address,
ladderUserId: lu.id,
source: 'last_match'
}));
source: "last_match",
}),
);
}
// 没有天梯门店,返回最近的门店
if (latitude && longitude) {
const stores = await Store.findAll({
where: { status: 1 }
where: { status: 1 },
});
if (stores.length > 0) {
@ -460,7 +555,7 @@ class UserController {
parseFloat(latitude),
parseFloat(longitude),
parseFloat(store.latitude),
parseFloat(store.longitude)
parseFloat(store.longitude),
);
if (dist < minDistance) {
minDistance = dist;
@ -469,30 +564,38 @@ class UserController {
}
}
return res.json(success({
return res.json(
success({
storeId: nearestStore.id,
storeName: nearestStore.name,
storeAddress: nearestStore.address,
ladderUserId: null,
source: 'nearest'
}));
source: "nearest",
}),
);
}
}
// 返回第一个门店
const firstStore = await Store.findOne({ where: { status: 1 } });
res.json(success(firstStore ? {
res.json(
success(
firstStore
? {
storeId: firstStore.id,
storeName: firstStore.name,
storeAddress: firstStore.address,
ladderUserId: null,
source: 'default'
} : null));
source: "default",
}
: null,
),
);
} catch (err) {
console.error('获取当前门店失败:', err);
res.status(500).json(error('获取失败'));
}
console.error("获取当前门店失败:", err);
res.status(500).json(error("获取失败"));
}
};
// 生成会员二维码图片
getQrcode = async (req, res) => {
@ -500,33 +603,35 @@ class UserController {
const user = req.user;
if (!user.member_code) {
return res.status(400).json(error('会员码不存在', 400));
return res.status(400).json(error("会员码不存在", 400));
}
// 生成二维码配置
const qrOptions = {
errorCorrectionLevel: 'M',
type: 'image/png',
errorCorrectionLevel: "M",
type: "image/png",
margin: 2,
width: 300,
color: {
dark: '#1A1A1A',
light: '#FFFFFF'
}
dark: "#1A1A1A",
light: "#FFFFFF",
},
};
// 生成二维码为 base64
const qrcodeDataUrl = await QRCode.toDataURL(user.member_code, qrOptions);
res.json(success({
res.json(
success({
memberCode: user.member_code,
qrcode: qrcodeDataUrl
}));
qrcode: qrcodeDataUrl,
}),
);
} catch (err) {
console.error('生成二维码失败:', err);
res.status(500).json(error('生成二维码失败'));
}
console.error("生成二维码失败:", err);
res.status(500).json(error("生成二维码失败"));
}
};
// 直接返回二维码图片
getQrcodeImage = async (req, res) => {
@ -534,32 +639,32 @@ class UserController {
const user = req.user;
if (!user.member_code) {
return res.status(400).send('会员码不存在');
return res.status(400).send("会员码不存在");
}
// 生成二维码配置
const qrOptions = {
errorCorrectionLevel: 'M',
type: 'png',
errorCorrectionLevel: "M",
type: "png",
margin: 2,
width: 300,
color: {
dark: '#1A1A1A',
light: '#FFFFFF'
}
dark: "#1A1A1A",
light: "#FFFFFF",
},
};
// 设置响应头
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "public, max-age=86400");
// 直接输出二维码图片
await QRCode.toFileStream(res, user.member_code, qrOptions);
} catch (err) {
console.error('生成二维码图片失败:', err);
res.status(500).send('生成二维码失败');
}
console.error("生成二维码图片失败:", err);
res.status(500).send("生成二维码失败");
}
};
}
module.exports = new UserController();

View File

@ -1,18 +1,21 @@
const express = require('express');
const express = require("express");
const router = express.Router();
const ladderController = require('../controllers/ladderController');
const { authUser } = require('../middlewares/auth');
const ladderController = require("../controllers/ladderController");
const { authUser } = require("../middlewares/auth");
// 获取天梯排名列表
router.get('/ranking', ladderController.getRanking);
router.get("/ranking", ladderController.getRanking);
// 获取我的排名(用于小程序定位)
router.get("/my-rank", authUser, ladderController.getMyRank);
// 获取天梯用户详情
router.get('/user/:id', ladderController.getUserDetail);
router.get("/user/:id", ladderController.getUserDetail);
// 选手详情(兼容小程序端:/api/ladder/player?id=xxx
router.get('/player', ladderController.getPlayerDetail);
router.get("/player", ladderController.getPlayerDetail);
// 获取等级说明
router.get('/levels', ladderController.getLevelInfo);
router.get("/levels", ladderController.getLevelInfo);
module.exports = router;

View File

@ -1,60 +1,87 @@
const express = require('express');
const express = require("express");
const router = express.Router();
const matchController = require('../controllers/matchController');
const { authUser, requireLadderUser } = require('../middlewares/auth');
const matchController = require("../controllers/matchController");
const { authUser, requireLadderUser } = require("../middlewares/auth");
// === 挑战赛 ===
// 检查是否可以发起挑战
router.get('/challenge/check/:targetMemberCode', authUser, matchController.checkChallenge);
router.get(
"/challenge/check/:targetMemberCode",
authUser,
matchController.checkChallenge,
);
// 发起挑战
router.post('/challenge/create', authUser, matchController.createChallenge);
router.post("/challenge/create", authUser, matchController.createChallenge);
// 响应挑战
router.post('/challenge/respond', authUser, matchController.respondChallenge);
router.post("/challenge/respond", authUser, matchController.respondChallenge);
// 提交比分
router.post('/challenge/submit-score', authUser, matchController.submitScore);
router.post("/challenge/submit-score", authUser, matchController.submitScore);
// 确认比分
router.post('/challenge/confirm-score', authUser, matchController.confirmScore);
router.post("/challenge/confirm-score", authUser, matchController.confirmScore);
// === 排位赛 ===
// 扫码加入排位赛
router.post('/ranking/join', authUser, matchController.joinRankingMatch);
router.post("/ranking/join", authUser, matchController.joinRankingMatch);
// 获取排位赛详情
router.get('/ranking/:matchCode', authUser, matchController.getRankingDetail);
router.get("/ranking/:matchCode", authUser, matchController.getRankingDetail);
// 获取我的当前对局
router.get('/ranking/:matchCode/my-game', authUser, matchController.getMyCurrentGame);
router.get(
"/ranking/:matchCode/my-game",
authUser,
matchController.getMyCurrentGame,
);
// 提交排位赛比分
router.post('/ranking/submit-score', authUser, matchController.submitRankingScore);
router.post(
"/ranking/submit-score",
authUser,
matchController.submitRankingScore,
);
// 确认排位赛比分
router.post('/ranking/confirm-score', authUser, matchController.confirmRankingScore);
router.post(
"/ranking/confirm-score",
authUser,
matchController.confirmRankingScore,
);
// === 裁判操作 ===
// 裁判修改比分
router.post('/referee/update-score', authUser, matchController.refereeUpdateScore);
router.post(
"/referee/update-score",
authUser,
matchController.refereeUpdateScore,
);
// 裁判开始淘汰赛
router.post('/referee/start-elimination', authUser, matchController.refereeStartElimination);
router.post(
"/referee/start-elimination",
authUser,
matchController.refereeStartElimination,
);
// === 通用 ===
// 获取正在进行中的比赛
router.get('/ongoing', authUser, matchController.getOngoingMatches);
router.get("/ongoing", authUser, matchController.getOngoingMatches);
// 大屏:进行中/近7天比赛列表公开
router.get("/display-list", matchController.getDisplayList);
// 获取选手比赛记录(用于选手详情页)
router.get('/history', authUser, matchController.getPlayerHistory);
router.get("/history", authUser, matchController.getPlayerHistory);
// 获取我的比赛记录
router.get('/my-matches', authUser, matchController.getMyMatches);
router.get("/my-matches", authUser, matchController.getMyMatches);
// 获取待确认的比赛
router.get('/pending-confirm', authUser, matchController.getPendingConfirm);
router.get("/pending-confirm", authUser, matchController.getPendingConfirm);
// 获取比赛详情
router.get('/:id', authUser, matchController.getMatchDetail);
router.get("/:id", authUser, matchController.getMatchDetail);
module.exports = router;

View File

@ -0,0 +1,23 @@
const sequelize = require("../config/database");
const { Match } = require("../models");
const { MATCH_TYPES } = require("../config/constants");
async function run() {
try {
const [affected] = await Match.update(
{ weight: 1.0 },
{ where: { type: MATCH_TYPES.CHALLENGE } },
);
console.log(`已更新挑战赛权重为 1.0,影响行数: ${affected}`);
await sequelize.close();
process.exit(0);
} catch (err) {
console.error("更新挑战赛权重失败:", err);
try {
await sequelize.close();
} catch (e) {}
process.exit(1);
}
}
run();