feat: 实现天梯排行数字大屏页面并统一品牌名称为英飒俱乐部

- 新增无需登录的数字大屏页面,包含蓝色和橙色主题
- 在管理后台仪表盘添加大屏入口快速按钮
- 扩展天梯排名接口,支持大屏显示模式(绕过最低参赛场次限制)
- 统一将项目品牌名称从“影杀/羽动俱乐部”更新为“英飒俱乐部”
- 更新相关配置文件、文档和界面中的品牌名称
- 添加公开数据接口用于获取门店列表和天梯排名
This commit is contained in:
ethanfly 2026-01-30 00:59:26 +08:00
parent 90505cba5f
commit e0713c3fd8
21 changed files with 2722 additions and 691 deletions

View File

@ -0,0 +1,69 @@
## 任务目标
在管理后台项目中实现一个无需登录、效果炫酷的“天梯排行”数字大屏页面。支持切换不同门店(羽毛球馆)查看实时排名。
## 技术实现方案
### 1. 路由与权限配置
* **新建路由**: 在 [router/index.js](file:///e:/workspace/yingsha/admin/src/router/index.js) 中添加顶级路由 `/display/ranking`
* **免登访问**: 设置 `meta: { public: true }`,使其绕过全局路由守卫的登录检查。
* **独立布局**: 不使用 `MainLayout`,采用全新的全屏容器。
### 2. 公共数据接口
* **新建接口定义**: 创建 `src/api/display.js`,封装后端公开的 API 接口:
* `getPublicStores()`: 调用 `/store/list` 获取门店列表。
* `getPublicRanking(params)`: 调用 `/ladder/ranking` 获取天梯实时排行。
### 3. 数字大屏 UI 设计
* **视觉风格**:
* 采用深色系背景(科技感深蓝/纯黑),搭配青色、橙色、紫色等荧光色点缀。
* 使用大尺寸字体,增强远距离阅读性。
* 添加背景粒子效果或动态渐变背景。
* **页面布局**:
* **顶部**: 动态时间显示 + 酷炫的标题装饰(英飒俱乐部天梯战力榜)。
* **侧边/浮动**: 门店切换菜单,采用科技感按钮或自动轮播逻辑。
* **主体**: 排行榜列表。
* **前三名**: 特殊动效和皇冠/奖牌图标。
* **列表项**: 包含排名、头像、昵称、战力值(大字显示)、胜率及等级标识。
* **交互动效**:
* 列表入场时的交替淡入效果。
* 排名数据变动时的数字滚动动画。
* 自动轮播排行榜(如果数据超过一屏)。
### 4. 关键功能实现
* **门店切换**: 顶部或侧边提供门店选择器并且url也要能够接受门店参数实现每个门店单独的url。
* **自适应适配**: 采用 `transform: scale()``vw/vh` 方案,确保在各种分辨率的大屏电视或显示器上都能完美展示。
* **自动刷新**: 页面每 5 分钟自动拉取一次最新数据,确保排名实时性。
## 实施步骤
1. **创建 API 模块**: 定义公共访问接口。
2. **编写大屏组件**: 实现 HTML/CSS 结构与动画效果。
3. **配置路由**: 注册页面并开启免登权限。
4. **集成门店切换**: 实现手动切换与数据联动逻辑。
5. **整体视觉优化**: 调整色彩、光效和动画细节。
确认此方案后,我将开始代码编写。

View File

@ -1,6 +1,6 @@
# 羽动俱乐部 - 羽毛球/网球俱乐部管理系统 # 英飒俱乐部 - 羽毛球/网球俱乐部管理系统
一个完整的羽毛球/网球俱乐部会员管理系统,包含微信小程序端和后台管理界面。 一个完整的英飒俱乐部会员管理系统,包含微信小程序端和后台管理界面。
## 项目结构 ## 项目结构
@ -146,7 +146,7 @@ npm run dev
| `VITE_API_URL` | 否 | `/api` | 后端 API 地址 | | `VITE_API_URL` | 否 | `/api` | 后端 API 地址 |
| `VITE_AMAP_KEY` | 是\* | 无 | 高德地图 JS API Key | | `VITE_AMAP_KEY` | 是\* | 无 | 高德地图 JS API Key |
| `VITE_AMAP_SECURITY_CODE` | 是\* | 无 | 高德地图安全密钥 | | `VITE_AMAP_SECURITY_CODE` | 是\* | 无 | 高德地图安全密钥 |
| `VITE_APP_TITLE` | 否 | `羽动俱乐部管理后台` | 应用标题 | | `VITE_APP_TITLE` | 否 | `英飒俱乐部管理后台` | 应用标题 |
> \*注:如不使用门店地址定位功能,可不配置高德地图相关参数 > \*注:如不使用门店地址定位功能,可不配置高德地图相关参数
@ -161,7 +161,7 @@ VITE_AMAP_KEY=your_amap_js_api_key
VITE_AMAP_SECURITY_CODE=your_security_code VITE_AMAP_SECURITY_CODE=your_security_code
# 应用标题 # 应用标题
VITE_APP_TITLE=羽动俱乐部管理后台 VITE_APP_TITLE=英飒俱乐部管理后台
``` ```
#### 高德地图配置说明 #### 高德地图配置说明
@ -224,8 +224,8 @@ VITE_APP_TITLE=羽动俱乐部管理后台
| Lv1 | 新锐 | 掌握基础动作,能进行多拍回合 | | Lv1 | 新锐 | 掌握基础动作,能进行多拍回合 |
| Lv2 | 精锐 | 技术较全面,具备初步战术意识 | | Lv2 | 精锐 | 技术较全面,具备初步战术意识 |
| Lv3 | 高手 | 技术稳定,战术意图清晰 | | Lv3 | 高手 | 技术稳定,战术意图清晰 |
| Lv4 | 大师 | 俱乐部顶尖战力 | | Lv4 | 大师 | 英飒俱乐部顶尖战力 |
| Lv5 | 宗师 | 技术全面,俱乐部标杆 | | Lv5 | 宗师 | 技术全面,英飒俱乐部标杆 |
## 主题配色 ## 主题配色

View File

@ -1,10 +1,10 @@
# ========================================== # ==========================================
# 羽动俱乐部管理后台 - 环境配置模板 # 英飒俱乐部管理后台 - 环境配置模板
# ========================================== # ==========================================
# 使用说明: # 使用说明:
# 1. 复制此文件为 .env.local开发环境或 .env.production生产环境 1. 复制此文件为 .env.local开发环境或 .env.production生产环境
# 2. 根据实际情况修改各项配置 2. 根据实际情况修改各项配置
# 3. 环境文件不应提交到版本控制 3. 环境文件不应提交到版本控制
# ========================================== # ==========================================
# ------------------------------------------ # ------------------------------------------
@ -24,10 +24,10 @@ VITE_API_URL=/api
# ------------------------------------------ # ------------------------------------------
# 高德地图 JS API 配置 # 高德地图 JS API 配置
# 申请步骤: # 申请步骤:
# 1. 访问 https://console.amap.com/ 注册账号 1. 访问 https://console.amap.com/ 注册账号
# 2. 进入「应用管理」->「我的应用」->「创建新应用」 2. 进入「应用管理」->「我的应用」->「创建新应用」
# 3. 添加 Key选择服务平台为「Web端(JS API)」 3. 添加 Key选择服务平台为「Web端(JS API)」
# 4. 复制生成的 Key 和安全密钥填入下方 4. 复制生成的 Key 和安全密钥填入下方
# 注意:高德地图提供免费配额,个人开发足够使用 # 注意:高德地图提供免费配额,个人开发足够使用
# 高德地图 Key # 高德地图 Key
@ -41,7 +41,7 @@ VITE_AMAP_SECURITY_CODE=your_amap_security_code_here
# 其他配置(可选) # 其他配置(可选)
# ------------------------------------------ # ------------------------------------------
# 应用标题 # 应用标题
VITE_APP_TITLE=羽动俱乐部管理后台 VITE_APP_TITLE=英飒俱乐部管理后台
# 是否开启 Mock 数据(开发调试用) # 是否开启 Mock 数据(开发调试用)
# VITE_USE_MOCK=false # VITE_USE_MOCK=false

View File

@ -1,10 +1,10 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>羽毛球俱乐部管理系统</title> <title>英飒俱乐部管理系统</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

7
admin/src/api/display.js Normal file
View File

@ -0,0 +1,7 @@
import request from './request'
// 获取公开门店列表
export const getPublicStores = () => request.get('/store/list')
// 获取公开天梯排名
export const getPublicRanking = (params) => request.get('/ladder/ranking', { params })

View File

@ -4,9 +4,9 @@
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar"> <el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="logo"> <div class="logo">
<img src="/favicon.svg" alt="logo" class="logo-icon" /> <img src="/favicon.svg" alt="logo" class="logo-icon" />
<span v-show="!isCollapse" class="logo-text">羽动俱乐部</span> <span v-show="!isCollapse" class="logo-text">英飒俱乐部</span>
</div> </div>
<el-menu <el-menu
:default-active="currentRoute" :default-active="currentRoute"
:collapse="isCollapse" :collapse="isCollapse"
@ -14,8 +14,7 @@
background-color="#1A1A2E" background-color="#1A1A2E"
text-color="#A0A0A0" text-color="#A0A0A0"
active-text-color="#FF6B35" active-text-color="#FF6B35"
router router>
>
<template v-for="item in menuRoutes" :key="item.path"> <template v-for="item in menuRoutes" :key="item.path">
<el-menu-item :index="'/' + item.path"> <el-menu-item :index="'/' + item.path">
<el-icon><component :is="item.meta?.icon" /></el-icon> <el-icon><component :is="item.meta?.icon" /></el-icon>
@ -24,49 +23,52 @@
</template> </template>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container class="main-container"> <el-container class="main-container">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<el-header class="header"> <el-header class="header">
<div class="header-left"> <div class="header-left">
<el-icon <el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
class="collapse-btn"
@click="isCollapse = !isCollapse"
>
<Fold v-if="!isCollapse" /> <Fold v-if="!isCollapse" />
<Expand v-else /> <Expand v-else />
</el-icon> </el-icon>
<el-breadcrumb separator="/"> <el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item> <el-breadcrumb-item v-if="currentTitle">{{
currentTitle
}}</el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<span class="store-name" v-if="userStore.userInfo?.storeName"> <span class="store-name" v-if="userStore.userInfo?.storeName">
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
{{ userStore.userInfo.storeName }} {{ userStore.userInfo.storeName }}
</span> </span>
<el-dropdown trigger="click" @command="handleCommand"> <el-dropdown trigger="click" @command="handleCommand">
<div class="user-info"> <div class="user-info">
<el-avatar :size="32" :src="userStore.userInfo?.avatar"> <el-avatar :size="32" :src="userStore.userInfo?.avatar">
{{ userStore.userInfo?.realName?.[0] || 'A' }} {{ userStore.userInfo?.realName?.[0] || "A" }}
</el-avatar> </el-avatar>
<span class="user-name">{{ userStore.userInfo?.realName || userStore.userInfo?.username }}</span> <span class="user-name">{{
userStore.userInfo?.realName || userStore.userInfo?.username
}}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="profile">个人设置</el-dropdown-item> <el-dropdown-item command="profile">个人设置</el-dropdown-item>
<el-dropdown-item command="password">修改密码</el-dropdown-item> <el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item> <el-dropdown-item divided command="logout"
>退出登录</el-dropdown-item
>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
</div> </div>
</el-header> </el-header>
<!-- 主内容区 --> <!-- 主内容区 -->
<el-main class="main-content"> <el-main class="main-content">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
@ -77,18 +79,31 @@
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
<!-- 修改密码弹窗 --> <!-- 修改密码弹窗 -->
<el-dialog v-model="showPasswordDialog" title="修改密码" width="400px"> <el-dialog v-model="showPasswordDialog" title="修改密码" width="400px">
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordFormRef" label-width="80px"> <el-form
:model="passwordForm"
:rules="passwordRules"
ref="passwordFormRef"
label-width="80px">
<el-form-item label="旧密码" prop="old_password"> <el-form-item label="旧密码" prop="old_password">
<el-input v-model="passwordForm.old_password" type="password" show-password /> <el-input
v-model="passwordForm.old_password"
type="password"
show-password />
</el-form-item> </el-form-item>
<el-form-item label="新密码" prop="new_password"> <el-form-item label="新密码" prop="new_password">
<el-input v-model="passwordForm.new_password" type="password" show-password /> <el-input
v-model="passwordForm.new_password"
type="password"
show-password />
</el-form-item> </el-form-item>
<el-form-item label="确认密码" prop="confirm_password"> <el-form-item label="确认密码" prop="confirm_password">
<el-input v-model="passwordForm.confirm_password" type="password" show-password /> <el-input
v-model="passwordForm.confirm_password"
type="password"
show-password />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -99,198 +114,205 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from "element-plus";
import { useUserStore } from '@/stores/user' import { useUserStore } from "@/stores/user";
import { updatePassword } from '@/api/admin' import { updatePassword } from "@/api/admin";
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const userStore = useUserStore() const userStore = useUserStore();
const isCollapse = ref(false) const isCollapse = ref(false);
const showPasswordDialog = ref(false) const showPasswordDialog = ref(false);
const passwordFormRef = ref() const passwordFormRef = ref();
const passwordForm = ref({ const passwordForm = ref({
old_password: '', old_password: "",
new_password: '', new_password: "",
confirm_password: '' confirm_password: "",
}) });
const passwordRules = { const passwordRules = {
old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }], old_password: [
new_password: [ { required: true, message: "请输入旧密码", trigger: "blur" },
{ required: true, message: '请输入新密码', trigger: 'blur' }, ],
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' } new_password: [
], { required: true, message: "请输入新密码", trigger: "blur" },
confirm_password: [ { min: 6, message: "密码长度不能少于6位", trigger: "blur" },
{ required: true, message: '请确认新密码', trigger: 'blur' }, ],
{ confirm_password: [
validator: (rule, value, callback) => { { required: true, message: "请确认新密码", trigger: "blur" },
if (value !== passwordForm.value.new_password) { {
callback(new Error('两次输入的密码不一致')) validator: (rule, value, callback) => {
} else { if (value !== passwordForm.value.new_password) {
callback() callback(new Error("两次输入的密码不一致"));
} } else {
callback();
}
},
trigger: "blur",
}, },
trigger: 'blur' ],
};
const currentRoute = computed(() => route.path);
const currentTitle = computed(() => route.meta?.title);
const menuRoutes = computed(() => {
const mainRoute = router.options.routes.find((r) => r.path === "/");
const routes = mainRoute?.children || [];
return routes.filter((r) => {
if (r.meta?.hidden) return false;
if (r.meta?.superAdmin && !userStore.isSuperAdmin) return false;
return true;
});
});
const handleCommand = (command) => {
switch (command) {
case "profile":
//
break;
case "password":
showPasswordDialog.value = true;
passwordForm.value = {
old_password: "",
new_password: "",
confirm_password: "",
};
break;
case "logout":
ElMessageBox.confirm("确定要退出登录吗?", "提示", {
type: "warning",
}).then(() => {
userStore.logout();
});
break;
} }
] };
}
const currentRoute = computed(() => route.path) const handleUpdatePassword = async () => {
const currentTitle = computed(() => route.meta?.title) await passwordFormRef.value?.validate();
await updatePassword({
old_password: passwordForm.value.old_password,
new_password: passwordForm.value.new_password,
});
ElMessage.success("密码修改成功,请重新登录");
showPasswordDialog.value = false;
userStore.logout();
};
const menuRoutes = computed(() => { onMounted(() => {
const routes = router.options.routes[1]?.children || [] userStore.fetchProfile();
return routes.filter(r => { });
if (r.meta?.hidden) return false
if (r.meta?.superAdmin && !userStore.isSuperAdmin) return false
return true
})
})
const handleCommand = (command) => {
switch (command) {
case 'profile':
//
break
case 'password':
showPasswordDialog.value = true
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' }
break
case 'logout':
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
type: 'warning'
}).then(() => {
userStore.logout()
})
break
}
}
const handleUpdatePassword = async () => {
await passwordFormRef.value?.validate()
await updatePassword({
old_password: passwordForm.value.old_password,
new_password: passwordForm.value.new_password
})
ElMessage.success('密码修改成功,请重新登录')
showPasswordDialog.value = false
userStore.logout()
}
onMounted(() => {
userStore.fetchProfile()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-layout { .main-layout {
height: 100vh; height: 100vh;
} }
.sidebar { .sidebar {
background: #1A1A2E; background: #1a1a2e;
transition: width 0.3s; transition: width 0.3s;
overflow: hidden; overflow: hidden;
.logo { .logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
.logo-icon {
width: 32px;
height: 32px;
}
.logo-text {
margin-left: 10px;
font-size: 18px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
}
.el-menu {
border-right: none;
}
}
.main-container {
flex-direction: column;
background: #f5f7fa;
}
.header {
height: 60px; height: 60px;
background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
padding: 0 16px; padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
.logo-icon {
width: 32px;
height: 32px;
}
.logo-text {
margin-left: 10px;
font-size: 18px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
}
.el-menu {
border-right: none;
}
}
.main-container { .header-left {
flex-direction: column; display: flex;
background: #F5F7FA; align-items: center;
}
.header { .collapse-btn {
height: 60px; font-size: 20px;
background: #fff; cursor: pointer;
display: flex; margin-right: 16px;
align-items: center; color: #666;
justify-content: space-between;
padding: 0 20px; &:hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); color: var(--primary-color);
}
.header-left {
display: flex;
align-items: center;
.collapse-btn {
font-size: 20px;
cursor: pointer;
margin-right: 16px;
color: #666;
&:hover {
color: var(--primary-color);
} }
} }
}
.header-right {
.header-right {
display: flex;
align-items: center;
gap: 20px;
.store-name {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 20px;
color: var(--secondary-color);
font-size: 14px; .store-name {
} display: flex;
align-items: center;
.user-info { gap: 4px;
display: flex; color: var(--secondary-color);
align-items: center;
gap: 8px;
cursor: pointer;
.user-name {
font-size: 14px; font-size: 14px;
color: #333; }
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.user-name {
font-size: 14px;
color: #333;
}
} }
} }
} }
}
.main-content { .main-content {
padding: 20px; padding: 20px;
overflow: auto; overflow: auto;
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@ -1,108 +1,120 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import { useUserStore } from '@/stores/user' import { useUserStore } from "@/stores/user";
const routes = [ const routes = [
{ {
path: '/login', path: "/login",
name: 'Login', name: "Login",
component: () => import('@/views/login/index.vue'), component: () => import("@/views/login/index.vue"),
meta: { title: '登录', public: true } meta: { title: "登录", public: true },
}, },
{ {
path: '/', path: "/",
component: () => import('@/layouts/MainLayout.vue'), component: () => import("@/layouts/MainLayout.vue"),
redirect: '/dashboard', redirect: "/dashboard",
children: [ children: [
{ {
path: 'dashboard', path: "dashboard",
name: 'Dashboard', name: "Dashboard",
component: () => import('@/views/dashboard/index.vue'), component: () => import("@/views/dashboard/index.vue"),
meta: { title: '首页', icon: 'HomeFilled' } meta: { title: "首页", icon: "HomeFilled" },
}, },
{ {
path: 'users', path: "users",
name: 'Users', name: "Users",
component: () => import('@/views/user/index.vue'), component: () => import("@/views/user/index.vue"),
meta: { title: '用户管理', icon: 'User', superAdmin: true } meta: { title: "用户管理", icon: "User", superAdmin: true },
}, },
{ {
path: 'stores', path: "stores",
name: 'Stores', name: "Stores",
component: () => import('@/views/store/index.vue'), component: () => import("@/views/store/index.vue"),
meta: { title: '门店管理', icon: 'OfficeBuilding', superAdmin: true } meta: { title: "门店管理", icon: "OfficeBuilding", superAdmin: true },
}, },
{ {
path: 'ladder', path: "ladder",
name: 'Ladder', name: "Ladder",
component: () => import('@/views/ladder/index.vue'), component: () => import("@/views/ladder/index.vue"),
meta: { title: '天梯用户', icon: 'TrendCharts' } meta: { title: "天梯用户", icon: "TrendCharts" },
}, },
{ {
path: 'matches', path: "matches",
name: 'Matches', name: "Matches",
component: () => import('@/views/match/index.vue'), component: () => import("@/views/match/index.vue"),
meta: { title: '比赛管理', icon: 'Trophy' } meta: { title: "比赛管理", icon: "Trophy" },
}, },
{ {
path: 'matches/:id', path: "matches/:id",
name: 'MatchDetail', name: "MatchDetail",
component: () => import('@/views/match/detail.vue'), component: () => import("@/views/match/detail.vue"),
meta: { title: '比赛详情', hidden: true } meta: { title: "比赛详情", hidden: true },
}, },
{ {
path: 'points/actions', path: "points/actions",
name: 'PointActions', name: "PointActions",
component: () => import('@/views/points/actions.vue'), component: () => import("@/views/points/actions.vue"),
meta: { title: '积分行为', icon: 'StarFilled' } meta: { title: "积分行为", icon: "StarFilled" },
}, },
{ {
path: 'points/products', path: "points/products",
name: 'PointProducts', name: "PointProducts",
component: () => import('@/views/points/products.vue'), component: () => import("@/views/points/products.vue"),
meta: { title: '积分商品', icon: 'GoodsFilled' } meta: { title: "积分商品", icon: "GoodsFilled" },
}, },
{ {
path: 'points/orders', path: "points/orders",
name: 'PointOrders', name: "PointOrders",
component: () => import('@/views/points/orders.vue'), component: () => import("@/views/points/orders.vue"),
meta: { title: '兑换订单', icon: 'List' } meta: { title: "兑换订单", icon: "List" },
}, },
{ {
path: 'system/users', path: "system/users",
name: 'SystemUsers', name: "SystemUsers",
component: () => import('@/views/system/users.vue'), component: () => import("@/views/system/users.vue"),
meta: { title: '系统用户', icon: 'UserFilled', superAdmin: true } meta: { title: "系统用户", icon: "UserFilled", superAdmin: true },
} },
] ],
} },
] {
path: "/display/ranking",
name: "RankingBoard",
component: () => import("@/views/display/RankingBoard.vue"),
meta: { title: "天梯排行大屏", public: true },
},
{
path: "/display/ranking-orange",
name: "RankingBoardOrange",
component: () => import("@/views/display/RankingBoardOrange.vue"),
meta: { title: "天梯排行大屏(橙色)", public: true },
},
];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
}) });
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
document.title = `${to.meta.title || ''} - 羽毛球俱乐部管理系统` document.title = `${to.meta.title || ""} - 英飒俱乐部管理系统`;
if (to.meta.public) {
next()
return
}
const userStore = useUserStore()
if (!userStore.token) {
next('/login')
return
}
// 超管权限检查
if (to.meta.superAdmin && userStore.userInfo?.role !== 'super_admin') {
next('/dashboard')
return
}
next()
})
export default router if (to.meta.public) {
next();
return;
}
const userStore = useUserStore();
if (!userStore.token) {
next("/login");
return;
}
// 超管权限检查
if (to.meta.superAdmin && userStore.userInfo?.role !== "super_admin") {
next("/dashboard");
return;
}
next();
});
export default router;

View File

@ -51,6 +51,14 @@
<el-icon class="icon"><View /></el-icon> <el-icon class="icon"><View /></el-icon>
<span class="label">扫码核销</span> <span class="label">扫码核销</span>
</div> </div>
<div class="quick-action-btn" @click="$router.push('/display/ranking')">
<el-icon class="icon"><Monitor /></el-icon>
<span class="label">天梯大屏()</span>
</div>
<div class="quick-action-btn" @click="$router.push('/display/ranking-orange')">
<el-icon class="icon"><Monitor /></el-icon>
<span class="label">天梯大屏()</span>
</div>
</div> </div>
</div> </div>
@ -263,7 +271,7 @@
<script setup> <script setup>
import { ref, onMounted, nextTick, watch } from 'vue' import { ref, onMounted, nextTick, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { View, Loading, InfoFilled, Search, User, Check } from '@element-plus/icons-vue' import { View, Loading, InfoFilled, Search, User, Check, Monitor } from '@element-plus/icons-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getDashboard, getPointActions, executePointAction, getMatches, getPointOrders, verifyOrder, verifyByCode, searchUsers } from '@/api/admin' import { getDashboard, getPointActions, executePointAction, getMatches, getPointOrders, verifyOrder, verifyByCode, searchUsers } from '@/api/admin'

View File

@ -0,0 +1,989 @@
<template>
<div class="ranking-board-container">
<!-- 顶部标题栏 -->
<div class="board-header">
<div class="header-left">
<div class="current-time">{{ currentTime }}</div>
</div>
<div class="header-center">
<div class="title-bg">
<h1 class="main-title">英飒俱乐部 · 天梯战力榜</h1>
</div>
</div>
<div class="header-right">
<div
class="style-switcher"
@click="
router.push({ path: '/display/ranking-orange', query: route.query })
">
<el-icon><Monitor /></el-icon>
<span>切换 Premium 风格</span>
</div>
<div class="store-selector" @click="showStoreMenu = !showStoreMenu">
<span class="store-label">当前场馆:</span>
<span class="store-name">{{ currentStore?.name || "请选择" }}</span>
<el-icon class="arrow-icon" :class="{ rotate: showStoreMenu }"
><ArrowDown
/></el-icon>
<!-- 门店切换菜单 -->
<transition name="fade">
<div v-if="showStoreMenu" class="store-menu" @click.stop>
<div
v-for="store in stores"
:key="store.id"
class="store-item"
:class="{ active: currentStore?.id === store.id }"
@click="selectStore(store)">
{{ store.name }}
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 主体内容区 -->
<div class="board-body">
<!-- 左右装饰线条 -->
<div class="side-decoration left"></div>
<div class="side-decoration right"></div>
<!-- 数据刷新提示 -->
<transition name="fade">
<div v-if="isRefreshing" class="refresh-indicator">
<el-icon class="is-loading"><Refresh /></el-icon>
<span>同步最新战力数据...</span>
</div>
</transition>
<!-- 排行榜主体 -->
<div class="ranking-list-wrapper">
<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" ref="scrollContainer">
<div
class="scroll-content"
:style="{ transform: `translateY(${scrollY}px)` }">
<div
v-for="(player, index) in rankingList"
:key="'p1-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="scan-line"></div>
<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, index) in rankingList"
:key="'p2-' + player.id"
class="ranking-item"
:class="'rank-' + player.rank">
<div class="scan-line"></div>
<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 && rankingList.length === 0" class="empty-state">
暂无排行数据
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<span>数据加载中...</span>
</div>
</div>
</div>
</div>
<!-- 底部装饰 -->
<div class="board-footer">
<div class="footer-hint">数据实时同步 · 每30秒刷新一次</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import dayjs from "dayjs";
import { getPublicStores, getPublicRanking } from "@/api/display";
import { ArrowDown, Refresh, Monitor } from "@element-plus/icons-vue";
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 rankingList = 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
//
const updateTime = () => {
currentTime.value = dayjs().format("YYYY-MM-DD HH:mm:ss");
};
// (使 Transform )
const startContinuousScroll = () => {
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
const scroll = () => {
if (isRefreshing.value || rankingList.value.length === 0) {
scrollReqId.value = requestAnimationFrame(scroll);
return;
}
// (使)
scrollY.value -= 0.6;
// 100 * (90px + 12px)
const singleSetHeight = rankingList.value.length * 102;
// 0
if (Math.abs(scrollY.value) >= singleSetHeight) {
scrollY.value = 0;
}
scrollReqId.value = requestAnimationFrame(scroll);
};
setTimeout(() => {
scrollReqId.value = requestAnimationFrame(scroll);
}, 500);
};
//
const fetchStores = async () => {
try {
const res = await getPublicStores();
stores.value = res.data?.list || [];
// URL store_id使
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];
}
if (currentStore.value) {
fetchRanking();
}
} catch (err) {
console.error("获取门店失败:", err);
}
};
//
const fetchRanking = async () => {
if (!currentStore.value) return;
isRefreshing.value = true;
try {
const res = await getPublicRanking({
store_id: currentStore.value.id,
pageSize: 100, // 100
is_display: 1, //
});
//
const newList = res.data?.list || [];
rankingList.value = newList;
//
if (rankingList.value.length > 0) {
nextTick(() => {
startContinuousScroll();
});
}
} catch (err) {
console.error("获取排名失败:", err);
} finally {
loading.value = false;
setTimeout(() => {
isRefreshing.value = false;
}, 1000);
}
};
//
const selectStore = (store) => {
currentStore.value = store;
showStoreMenu.value = false;
// URL 便/
router.replace({ query: { ...route.query, store_id: store.id } });
fetchRanking();
};
onMounted(() => {
fetchStores();
timer = setInterval(updateTime, 1000);
refreshTimer = setInterval(fetchRanking, 30 * 1000); // 30
//
window.addEventListener("click", () => {
showStoreMenu.value = false;
});
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
window.removeEventListener("click", () => {
showStoreMenu.value = false;
});
});
// URL
watch(
() => route.query.store_id,
(newId) => {
if (newId && stores.value.length > 0) {
const found = stores.value.find((s) => s.id === parseInt(newId));
if (found && found.id !== currentStore.value?.id) {
currentStore.value = found;
fetchRanking();
}
}
},
);
</script>
<style lang="scss" scoped>
.ranking-board-container {
width: 100vw;
height: 100vh;
background-color: #050a18;
background-image:
radial-gradient(
circle at 50% 0%,
rgba(26, 63, 131, 0.4) 0%,
transparent 50%
),
radial-gradient(
circle at 0% 100%,
rgba(18, 32, 64, 0.3) 0%,
transparent 40%
),
radial-gradient(
circle at 100% 100%,
rgba(18, 32, 64, 0.3) 0%,
transparent 40%
);
color: #fff;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
&::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M54.826 10.531c1.007 0 1.823.816 1.823 1.823 0 1.006-.816 1.822-1.823 1.822-1.006 0-1.822-.816-1.822-1.822 0-1.007.816-1.823 1.822-1.823zM4.785 4.785c1.007 0 1.823.816 1.823 1.823 0 1.006-.816 1.822-1.823 1.822-1.006 0-1.822-.816-1.822-1.822 0-1.007.816-1.823 1.822-1.823z' fill='%23ffffff' fill-opacity='0.03' fill-rule='evenodd'/%3E%3C/svg%3E");
pointer-events: none;
animation: pulse 8s ease-in-out infinite;
}
}
.refresh-indicator {
position: absolute;
top: 10px;
right: 60px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 242, 255, 0.15);
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
color: #00f2ff;
border: 1px solid rgba(0, 242, 255, 0.3);
z-index: 20;
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
}
/* 顶部样式 */
.board-header {
height: 100px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
position: relative;
z-index: 10;
background: linear-gradient(to bottom, rgba(10, 25, 51, 0.8), transparent);
.header-left {
flex: 1;
.current-time {
font-size: 24px;
font-weight: 300;
color: #00f2ff;
letter-spacing: 2px;
text-shadow: 0 0 10px rgba(0, 242, 255, 0.5);
}
}
.header-center {
flex: 2;
display: flex;
justify-content: center;
.title-bg {
position: relative;
padding: 10px 60px;
&::before,
&::after {
content: "";
position: absolute;
top: 50%;
width: 100px;
height: 2px;
background: linear-gradient(to right, transparent, #00f2ff);
}
&::before {
left: -110px;
}
&::after {
right: -110px;
transform: rotate(180deg);
}
.main-title {
font-size: 48px;
margin: 0;
letter-spacing: 8px;
font-weight: 800;
background: linear-gradient(to bottom, #fff, #a5d8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px rgba(0, 242, 255, 0.3);
}
}
}
.header-right {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
.style-switcher {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.2);
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
color: #a5d8ff;
font-size: 14px;
transition: all 0.3s;
&:hover {
background: rgba(0, 242, 255, 0.1);
border-color: #00f2ff;
color: #00f2ff;
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
}
}
.store-selector {
background: rgba(0, 242, 255, 0.1);
border: 1px solid rgba(0, 242, 255, 0.3);
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
position: relative;
transition: all 0.3s;
&:hover {
background: rgba(0, 242, 255, 0.2);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
}
.store-label {
font-size: 14px;
color: #a5d8ff;
}
.store-name {
font-size: 20px;
font-weight: 600;
color: #00f2ff;
}
.arrow-icon {
transition: transform 0.3s;
&.rotate {
transform: rotate(180deg);
}
}
.store-menu {
position: absolute;
top: 110%;
right: 0;
width: 200px;
background: #0a1933;
border: 1px solid #00f2ff;
border-radius: 4px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 10px 0;
z-index: 100;
.store-item {
padding: 12px 20px;
transition: all 0.3s;
font-size: 16px;
&:hover {
background: rgba(0, 242, 255, 0.1);
color: #00f2ff;
}
&.active {
color: #00f2ff;
background: rgba(0, 242, 255, 0.15);
font-weight: 600;
}
}
}
}
}
}
/* 主体样式 */
.board-body {
flex: 1;
padding: 20px 60px 20px 60px; /* 底部内边距调整为 50px */
position: relative;
display: flex;
flex-direction: column;
min-height: 0; /* 确保子元素 flex 正确 */
.side-decoration {
position: absolute;
top: 50%;
width: 4px;
height: 60%;
background: linear-gradient(to bottom, transparent, #00f2ff, transparent);
transform: translateY(-50%);
opacity: 0.3;
&.left {
left: 20px;
}
&.right {
right: 20px;
}
}
}
.ranking-list-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.01);
border: 1px solid rgba(0, 242, 255, 0.15);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 0 40px rgba(0, 0, 0, 0.6),
inset 0 0 30px rgba(0, 242, 255, 0.03);
position: relative;
/* 移除 margin-bottom由父级 padding 处理 */
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 150px; /* 加深底部遮挡,强化视觉渐变 */
background: linear-gradient(to top, #050a18 10%, transparent);
pointer-events: none;
z-index: 5;
}
.list-header {
display: flex;
background: rgba(0, 242, 255, 0.12);
padding: 25px 20px;
font-size: 22px;
color: #00f2ff;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 3px;
border-bottom: 2px solid rgba(0, 242, 255, 0.3);
position: relative;
z-index: 10;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
div {
padding: 0 10px;
}
}
.list-body {
flex: 1;
overflow-y: auto; /* 允许滚动,但隐藏滚动条 */
padding: 10px 0;
scroll-behavior: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
&::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
}
.scroll-content {
display: flex;
flex-direction: column;
}
.ranking-item {
display: flex;
align-items: center;
height: 90px; /* 稍微增加行高 */
padding: 0 30px;
margin: 0 20px 12px 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
transition: all 0.4s ease;
position: relative;
overflow: hidden;
&:hover {
background: rgba(0, 242, 255, 0.1);
border-color: rgba(0, 242, 255, 0.4);
transform: scale(1.01) translateX(5px);
box-shadow: 0 0 25px rgba(0, 242, 255, 0.15);
}
.scan-line {
display: none;
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(
to right,
transparent,
rgba(0, 242, 255, 0.1),
transparent
);
transform: skewX(-25deg);
animation: scanMove 2s infinite;
}
/* 前三名特殊样式 */
&.rank-1 {
background: linear-gradient(
to right,
rgba(255, 215, 0, 0.15),
rgba(255, 215, 0, 0.05)
);
border-color: rgba(255, 215, 0, 0.3);
.rank-badge {
background: #ffd700;
color: #000;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
}
.score-value {
color: #ffd700;
font-size: 32px;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
}
&.rank-2 {
background: linear-gradient(
to right,
rgba(192, 192, 192, 0.15),
rgba(192, 192, 192, 0.05)
);
border-color: rgba(192, 192, 192, 0.3);
.rank-badge {
background: #c0c0c0;
color: #000;
}
.score-value {
color: #c0c0c0;
font-size: 28px;
}
}
&.rank-3 {
background: linear-gradient(
to right,
rgba(205, 127, 50, 0.15),
rgba(205, 127, 50, 0.05)
);
border-color: rgba(205, 127, 50, 0.3);
.rank-badge {
background: #cd7f32;
color: #000;
}
.score-value {
color: #cd7f32;
font-size: 26px;
}
}
}
/* 列宽度分配 */
.col-rank {
width: 100px;
display: flex;
justify-content: center;
align-items: center;
}
.col-player {
flex: 2;
}
.col-level {
width: 150px;
text-align: center;
}
.col-matches {
width: 200px;
text-align: center;
}
.col-score {
flex: 1.5;
}
.rank-badge {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 20px;
}
.player-info {
display: flex;
align-items: center;
gap: 20px;
.player-avatar {
border: 2px solid rgba(0, 242, 255, 0.3);
background: #1a2f4d;
font-weight: 600;
}
.player-names {
display: flex;
flex-direction: column;
gap: 2px;
.real-name {
font-size: 24px;
font-weight: 700;
color: #fff;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
}
.nickname {
font-size: 14px;
color: #00f2ff;
opacity: 0.6;
}
}
}
.level-tag {
padding: 4px 16px;
border-radius: 4px; /* 赛博朋克风改用方角或微圆 */
font-size: 14px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.1);
&.lv1 {
background: rgba(46, 125, 50, 0.2);
color: #81c784;
border: 1px solid #2e7d32;
}
&.lv2 {
background: rgba(21, 101, 192, 0.2);
color: #64b5f6;
border: 1px solid #1565c0;
}
&.lv3 {
background: rgba(230, 81, 0, 0.2);
color: #ffb74d;
border: 1px solid #e65100;
}
&.lv4 {
background: rgba(194, 24, 91, 0.2);
color: #f06292;
border: 1px solid #c2185b;
}
&.lv5 {
background: rgba(123, 31, 162, 0.2);
color: #ba68c8;
border: 1px solid #7b1fa2;
}
}
.match-stats {
display: flex;
flex-direction: column;
gap: 2px;
.count {
font-size: 20px;
font-weight: 600;
color: #fff;
}
.rate {
font-size: 14px;
color: #00f2ff;
opacity: 0.8;
font-family: "Courier New", monospace;
}
}
.score-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
.score-value {
font-size: 32px;
font-weight: 900;
color: #00f2ff;
text-align: right;
font-family: "Arial Black", sans-serif;
text-shadow: 0 0 15px rgba(0, 242, 255, 0.5);
line-height: 1;
}
.score-bar {
height: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
.bar-fill {
height: 100%;
background: linear-gradient(to right, #00f2ff, #0072ff, #00f2ff);
background-size: 200% 100%;
animation: barGlow 2s linear infinite;
box-shadow: 0 0 15px rgba(0, 242, 255, 0.6);
}
}
}
@keyframes barGlow {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
/* 底部样式 */
.board-footer {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 10, 24, 0.9); /* 加深底部背景,增强边界感 */
border-top: 1px solid rgba(0, 242, 255, 0.1);
position: relative;
z-index: 20;
.footer-hint {
font-size: 14px;
color: #5c78a7;
letter-spacing: 2px;
}
}
/* 动画效果 */
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-item {
transition: all 0.8s ease;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.empty-state,
.loading-state {
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 20px;
color: #5c78a7;
gap: 20px;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(0, 242, 255, 0.1);
border-top-color: #00f2ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes scanMove {
0% {
left: -100%;
}
100% {
left: 200%;
}
}
</style>

View File

@ -0,0 +1,858 @@
<template>
<div class="ranking-board-orange-v2">
<!-- 背景特效层 -->
<div class="bg-glow"></div>
<div class="bg-grid"></div>
<!-- 顶部控制条 -->
<div class="top-bar">
<div class="time-box">{{ currentTime }}</div>
<div class="title-box">
<span class="title-icon">🏆</span>
<h1 class="main-title">英飒俱乐部 · 荣耀天梯</h1>
<span class="title-tag">PREMIUM</span>
</div>
<div class="right-controls">
<div
class="style-switcher"
@click="
router.push({ path: '/display/ranking', query: route.query })
">
<el-icon><Monitor /></el-icon>
<span>切换经典风格</span>
</div>
<div class="store-box" @click="showStoreMenu = !showStoreMenu">
<span class="label">场馆</span>
<span class="value">{{ currentStore?.name || "未选择" }}</span>
<el-icon :class="{ rotate: showStoreMenu }"><ArrowDown /></el-icon>
<transition name="slide-down">
<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="main-content">
<!-- 左侧名人堂 (Top 3) -->
<div class="hall-of-fame">
<div class="panel-title">HALL OF FAME</div>
<div class="podium">
<!-- 第二名 -->
<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">
{{ rankingList[1].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥈</div>
</div>
<div class="card-content">
<div class="name">{{ rankingList[1].realName }}</div>
<div class="score">{{ rankingList[1].powerScore }}</div>
<div class="stats-row">
<span class="level">{{ rankingList[1].levelName }}</span>
<span class="wins"
>{{
Math.round(
(rankingList[1].matchCount * rankingList[1].winRate) /
100,
)
}}</span
>
</div>
</div>
</div>
<!-- 第一名 -->
<div v-if="rankingList[0]" class="podium-card rank-1">
<div class="rank-label">CHAMPION</div>
<div class="avatar-wrapper">
<el-avatar
:size="100"
:src="rankingList[0].avatar"
class="avatar">
{{ rankingList[0].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">👑</div>
</div>
<div class="card-content">
<div class="name">{{ rankingList[0].realName }}</div>
<div class="score">{{ rankingList[0].powerScore }}</div>
<div class="stats-row">
<span class="level">{{ rankingList[0].levelName }}</span>
<span class="wins"
>{{
Math.round(
(rankingList[0].matchCount * rankingList[0].winRate) /
100,
)
}}</span
>
</div>
</div>
<div class="fire-effect"></div>
</div>
<!-- 第三名 -->
<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">
{{ rankingList[2].realName?.[0] || "?" }}
</el-avatar>
<div class="crown">🥉</div>
</div>
<div class="card-content">
<div class="name">{{ rankingList[2].realName }}</div>
<div class="score">{{ rankingList[2].powerScore }}</div>
<div class="stats-row">
<span class="level">{{ rankingList[2].levelName }}</span>
<span class="wins"
>{{
Math.round(
(rankingList[2].matchCount * rankingList[2].winRate) /
100,
)
}}</span
>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧争夺区 (Rank 4-100) -->
<div class="contenders-zone">
<div class="panel-header">
<div class="panel-title">CONTENDERS</div>
<div class="refresh-tag" v-if="isRefreshing">
<el-icon class="is-loading"><Refresh /></el-icon>
</div>
</div>
<div class="scroll-container" ref="scrollContainer">
<div class="scroll-mask">
<div
class="scroll-wrapper"
:style="{ transform: `translateY(${scrollY}px)` }">
<!-- 第一组数据 -->
<div class="grid-layout" ref="group1">
<div
v-for="player in rankingList.slice(3)"
:key="'c1-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="50" :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 rankingList.slice(3)"
:key="'c2-' + player.id"
class="contender-card">
<div class="card-rank">{{ player.rank }}</div>
<div class="card-avatar">
<el-avatar :size="50" :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="footer-bar">
<span>REAL-TIME DATA SYNC ACTIVE</span>
<div class="dot"></div>
<span>REFRESH EVERY 30 SECONDS</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import dayjs from "dayjs";
import { getPublicStores, getPublicRanking } from "@/api/display";
import { ArrowDown, Refresh, Monitor } from "@element-plus/icons-vue";
const route = useRoute();
const router = useRouter();
const currentTime = ref(dayjs().format("HH:mm:ss"));
const stores = ref([]);
const currentStore = ref(null);
const rankingList = ref([]);
const loading = ref(false);
const showStoreMenu = ref(false);
const scrollContainer = ref(null);
const isRefreshing = ref(false);
let timer = null;
let refreshTimer = null;
const group1 = ref(null);
const scrollReqId = ref(null);
const scrollY = ref(0);
const updateTime = () => {
currentTime.value = dayjs().format("HH:mm:ss");
};
const startContinuousScroll = () => {
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
const scroll = () => {
if (
isRefreshing.value ||
rankingList.value.length <= 3 ||
!group1.value
) {
scrollReqId.value = requestAnimationFrame(scroll);
return;
}
scrollY.value -= 0.6;
//
const singleSetHeight = group1.value.offsetHeight + 12; // + gap
if (Math.abs(scrollY.value) >= singleSetHeight) {
scrollY.value = 0;
}
scrollReqId.value = requestAnimationFrame(scroll);
};
setTimeout(() => {
scrollReqId.value = requestAnimationFrame(scroll);
}, 1000);
};
const fetchStores = async () => {
try {
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];
}
if (currentStore.value) fetchRanking();
} catch (err) {
console.error("Fetch stores error:", err);
}
};
const fetchRanking = async () => {
if (!currentStore.value) return;
isRefreshing.value = true;
try {
const res = await getPublicRanking({
store_id: currentStore.value.id,
pageSize: 100,
is_display: 1,
});
rankingList.value = res.data?.list || [];
if (rankingList.value.length > 3) {
nextTick(() => startContinuousScroll());
}
} catch (err) {
console.error("Fetch ranking error:", err);
} finally {
loading.value = false;
setTimeout(() => {
isRefreshing.value = false;
}, 1000);
}
};
const selectStore = (store) => {
currentStore.value = store;
showStoreMenu.value = false;
router.replace({ query: { ...route.query, store_id: store.id } });
fetchRanking();
};
onMounted(() => {
fetchStores();
timer = setInterval(updateTime, 1000);
refreshTimer = setInterval(fetchRanking, 30 * 1000);
window.addEventListener("click", () => {
showStoreMenu.value = false;
});
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (refreshTimer) clearInterval(refreshTimer);
if (scrollReqId.value) cancelAnimationFrame(scrollReqId.value);
});
</script>
<style lang="scss" scoped>
.ranking-board-orange-v2 {
width: 100vw;
height: 100vh;
background: #0a0806;
color: #fff;
font-family: "Oswald", "PingFang SC", sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
padding: 0 40px;
box-sizing: border-box;
.bg-glow {
position: absolute;
top: -20%;
left: 50%;
transform: translateX(-50%);
width: 1000px;
height: 600px;
background: radial-gradient(
circle,
rgba(255, 111, 0, 0.15) 0%,
transparent 70%
);
pointer-events: none;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 152, 0, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 152, 0, 0.02) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
}
}
/* Top Bar */
.top-bar {
height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 152, 0, 0.2);
position: relative;
z-index: 100;
.time-box {
font-size: 32px;
color: #ff9800;
font-weight: 200;
font-family: monospace;
width: 200px;
}
.title-box {
display: flex;
align-items: center;
gap: 15px;
.title-icon {
font-size: 36px;
}
.main-title {
font-size: 42px;
margin: 0;
letter-spacing: 4px;
background: linear-gradient(to bottom, #fff, #ffcc80);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 900;
}
.title-tag {
background: #ff9800;
color: #000;
padding: 2px 8px;
font-size: 12px;
font-weight: 900;
border-radius: 2px;
}
}
.right-controls {
display: flex;
align-items: center;
gap: 30px;
width: 400px; /* 增加宽度以容纳两个控件 */
justify-content: flex-end;
.style-switcher {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
color: #ffcc80;
font-size: 14px;
transition: all 0.3s;
&:hover {
background: rgba(255, 152, 0, 0.2);
border-color: #ff9800;
color: #ff9800;
box-shadow: 0 0 15px rgba(255, 152, 0, 0.2);
}
}
.store-box {
text-align: right;
cursor: pointer;
position: relative;
.label {
color: #888;
font-size: 14px;
}
.value {
color: #ff9800;
font-size: 20px;
font-weight: 600;
}
.rotate {
transform: rotate(180deg);
}
.store-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #1a140f;
border: 1px solid #ff9800;
padding: 10px 0;
min-width: 180px;
border-radius: 4px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
.dropdown-item {
padding: 10px 20px;
text-align: left;
&:hover {
background: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
&.active {
color: #ff9800;
font-weight: 900;
}
}
}
}
}
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
gap: 40px;
padding: 30px 0;
min-height: 0;
}
/* Hall of Fame */
.hall-of-fame {
width: 550px; /* 增加宽度以容纳横向布局 */
display: flex;
flex-direction: column;
gap: 20px;
.panel-title {
font-size: 20px;
color: #ff9800;
letter-spacing: 5px;
font-weight: 900;
opacity: 0.5;
}
.podium {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
justify-content: center;
.podium-card {
background: linear-gradient(
135deg,
rgba(255, 152, 0, 0.1) 0%,
rgba(255, 152, 0, 0.02) 100%
);
border: 1px solid rgba(255, 152, 0, 0.2);
padding: 0 30px;
border-radius: 15px;
display: flex;
align-items: center;
gap: 30px;
position: relative;
overflow: hidden;
&.rank-1 {
height: 180px;
border-color: #ffd700;
background: linear-gradient(
135deg,
rgba(255, 215, 0, 0.2) 0%,
rgba(255, 152, 0, 0.05) 100%
);
.name {
font-size: 32px;
color: #ffd700;
}
.score {
font-size: 54px;
color: #fff;
text-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
}
}
&.rank-2,
&.rank-3 {
height: 130px;
.name {
font-size: 24px;
}
.score {
font-size: 42px;
color: #ffcc80;
}
}
.rank-label {
position: absolute;
top: 15px;
right: 25px;
font-size: 14px;
font-weight: 900;
color: rgba(255, 152, 0, 0.6);
letter-spacing: 1px;
}
.avatar-wrapper {
position: relative;
flex-shrink: 0;
.avatar {
border: 3px solid #ff9800;
background: #33190a;
font-weight: 900;
font-size: 24px;
}
.crown {
position: absolute;
top: -10px;
right: -10px; /* 移动到右上角 */
font-size: 32px; /* 稍微放大 */
z-index: 5;
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5));
}
}
.card-content {
display: flex;
flex-direction: row;
align-items: center;
gap: 30px;
flex: 1;
}
.name {
font-weight: 900;
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score {
font-weight: 900;
font-family: "Arial Black";
line-height: 1;
white-space: nowrap;
}
.stats-row {
display: flex;
flex-direction: column;
align-items: flex-end; /* 内容靠右对齐 */
gap: 5px;
margin-left: auto; /* 容器靠右对齐 */
.level {
background: rgba(255, 152, 0, 0.2);
color: #ff9800;
padding: 2px 10px;
border-radius: 4px;
font-size: 14px;
font-weight: 800;
border: 1px solid rgba(255, 152, 0, 0.3);
white-space: nowrap;
}
.wins {
color: #888;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
margin-right: 4px; /* 右侧微调对齐 */
}
}
}
}
}
/* Contenders Zone */
.contenders-zone {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.panel-title {
font-size: 20px;
color: #ff9800;
letter-spacing: 5px;
font-weight: 900;
opacity: 0.5;
}
.refresh-tag {
color: #ff9800;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
}
.scroll-container {
flex: 1;
overflow: visible;
position: relative;
margin-top: 10px;
.scroll-mask {
position: absolute;
/* 四周扩展 20px 给 hover 缩放留出空间 */
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
padding: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bottom-fade-overlay {
position: absolute;
bottom: -20px;
left: -20px;
right: -20px;
height: 120px;
/* 使用 rgba 确保颜色一致性,并调整渐变比例 */
background: linear-gradient(
to top,
#0a0806 0%,
#0a0806 15%,
rgba(10, 8, 6, 0) 100%
);
pointer-events: none;
z-index: 20;
}
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding-bottom: 20px; /* 移除超大 padding由 reset 逻辑控制 */
}
.contender-card {
height: 80px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 152, 0, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
padding: 0 20px;
gap: 15px;
transition: all 0.3s;
&:hover {
background: rgba(255, 152, 0, 0.15);
border-color: #ff9800;
transform: scale(1.03);
z-index: 10; /* 确保悬停卡片在最上层 */
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
.card-rank {
color: #ff9800;
}
}
.card-rank {
font-size: 24px;
font-weight: 900;
color: rgba(255, 152, 0, 0.3);
width: 40px;
}
.card-avatar {
width: 50px;
height: 50px;
flex-shrink: 0;
.el-avatar {
border: 2px solid #ff9800;
background: #33190a;
color: #ff9800;
font-weight: 900;
}
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-name {
font-size: 18px;
font-weight: 700;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.card-meta {
font-size: 12px;
color: #888;
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
white-space: nowrap; /* 禁止换行 */
.card-level {
color: #ff9800;
font-weight: 700;
white-space: nowrap;
}
.card-wins {
color: #aaa;
white-space: nowrap;
}
.card-winrate {
color: #666;
white-space: nowrap;
}
}
.card-score {
font-size: 24px;
font-weight: 900;
color: #ff9800;
font-family: "Arial Black";
margin-left: auto;
}
}
}
/* Footer */
.footer-bar {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
font-size: 12px;
color: #444;
letter-spacing: 2px;
font-weight: 900;
.dot {
width: 4px;
height: 4px;
background: #ff9800;
border-radius: 50%;
}
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
}
@keyframes pulse {
0%,
100% {
opacity: 0.03;
}
50% {
opacity: 0.08;
}
}
</style>

View File

@ -5,57 +5,53 @@
<div class="bg-circle circle2"></div> <div class="bg-circle circle2"></div>
<div class="bg-circle circle3"></div> <div class="bg-circle circle3"></div>
</div> </div>
<div class="login-container"> <div class="login-container">
<div class="login-header"> <div class="login-header">
<div class="logo"> <div class="logo">
<div class="logo-icon">🏸</div> <div class="logo-icon">🏸</div>
<h1>羽动俱乐部</h1> <h1>英飒俱乐部</h1>
</div> </div>
<p class="subtitle">会员管理系统</p> <p class="subtitle">会员管理系统</p>
</div> </div>
<el-form <el-form
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="rules"
class="login-form" class="login-form"
@submit.prevent="handleLogin" @submit.prevent="handleLogin">
>
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
v-model="form.username" v-model="form.username"
placeholder="请输入用户名" placeholder="请输入用户名"
prefix-icon="User" prefix-icon="User"
size="large" size="large" />
/>
</el-form-item> </el-form-item>
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="请输入密码" placeholder="请输入密码"
prefix-icon="Lock" prefix-icon="Lock"
size="large" size="large"
show-password show-password
@keyup.enter="handleLogin" @keyup.enter="handleLogin" />
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button <el-button
type="primary" type="primary"
size="large" size="large"
:loading="loading" :loading="loading"
class="login-btn" class="login-btn"
@click="handleLogin" @click="handleLogin">
> {{ loading ? "登录中..." : "登 录" }}
{{ loading ? '登录中...' : '登 录' }}
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="login-footer"> <div class="login-footer">
<p>默认账号: admin / admin123</p> <p>默认账号: admin / admin123</p>
</div> </div>
@ -64,158 +60,170 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from "vue";
import { useRouter } from 'vue-router' import { useRouter } from "vue-router";
import { ElMessage } from 'element-plus' import { ElMessage } from "element-plus";
import { useUserStore } from '@/stores/user' import { useUserStore } from "@/stores/user";
const router = useRouter() const router = useRouter();
const userStore = useUserStore() const userStore = useUserStore();
const formRef = ref() const formRef = ref();
const loading = ref(false) const loading = ref(false);
const form = ref({ const form = ref({
username: '', username: "",
password: '' password: "",
}) });
const rules = { const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }] password: [{ required: true, message: "请输入密码", trigger: "blur" }],
} };
const handleLogin = async () => { const handleLogin = async () => {
await formRef.value?.validate() await formRef.value?.validate();
loading.value = true loading.value = true;
try { try {
await userStore.login(form.value.username, form.value.password) await userStore.login(form.value.username, form.value.password);
ElMessage.success('登录成功') ElMessage.success("登录成功");
router.push('/dashboard') router.push("/dashboard");
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: linear-gradient(135deg, #1A1A2E 0%, #16213E 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.login-bg { .login-bg {
position: absolute;
inset: 0;
.bg-circle {
position: absolute; position: absolute;
border-radius: 50%; inset: 0;
filter: blur(60px);
opacity: 0.4;
}
.circle1 {
width: 400px;
height: 400px;
background: var(--primary-color);
top: -100px;
right: -100px;
}
.circle2 {
width: 300px;
height: 300px;
background: var(--secondary-color);
bottom: -50px;
left: -50px;
}
.circle3 {
width: 200px;
height: 200px;
background: var(--accent-color);
top: 50%;
left: 30%;
}
}
.login-container { .bg-circle {
width: 420px; position: absolute;
background: rgba(255, 255, 255, 0.95); border-radius: 50%;
border-radius: 20px; filter: blur(60px);
padding: 48px 40px; opacity: 0.4;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 1;
.login-header {
text-align: center;
margin-bottom: 40px;
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.logo-icon {
font-size: 40px;
}
h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
} }
.subtitle { .circle1 {
margin-top: 8px; width: 400px;
color: var(--text-secondary); height: 400px;
font-size: 14px; background: var(--primary-color);
top: -100px;
right: -100px;
}
.circle2 {
width: 300px;
height: 300px;
background: var(--secondary-color);
bottom: -50px;
left: -50px;
}
.circle3 {
width: 200px;
height: 200px;
background: var(--accent-color);
top: 50%;
left: 30%;
} }
} }
.login-form { .login-container {
.el-input { width: 420px;
--el-input-bg-color: #F8F9FA; background: rgba(255, 255, 255, 0.95);
--el-input-border-color: transparent; border-radius: 20px;
padding: 48px 40px;
:deep(.el-input__wrapper) { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 1;
.login-header {
text-align: center;
margin-bottom: 40px;
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.logo-icon {
font-size: 40px;
}
h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(
135deg,
var(--primary-color),
var(--secondary-color)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.subtitle {
margin-top: 8px;
color: var(--text-secondary);
font-size: 14px;
}
}
.login-form {
.el-input {
--el-input-bg-color: #f8f9fa;
--el-input-border-color: transparent;
:deep(.el-input__wrapper) {
border-radius: 10px;
padding: 4px 16px;
}
}
.login-btn {
width: 100%;
height: 48px;
border-radius: 10px; border-radius: 10px;
padding: 4px 16px; font-size: 16px;
font-weight: 600;
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-light)
);
border: none;
&:hover {
background: linear-gradient(
135deg,
var(--primary-light),
var(--primary-color)
);
}
} }
} }
.login-btn { .login-footer {
width: 100%; text-align: center;
height: 48px; margin-top: 24px;
border-radius: 10px; color: var(--text-secondary);
font-size: 16px; font-size: 12px;
font-weight: 600;
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
border: none;
&:hover {
background: linear-gradient(135deg, var(--primary-light), var(--primary-color));
}
} }
} }
.login-footer {
text-align: center;
margin-top: 24px;
color: var(--text-secondary);
font-size: 12px;
}
}
</style> </style>

View File

@ -14,7 +14,7 @@
"window": { "window": {
"backgroundTextStyle": "dark", "backgroundTextStyle": "dark",
"navigationBarBackgroundColor": "#FF6B35", "navigationBarBackgroundColor": "#FF6B35",
"navigationBarTitleText": "羽动俱乐部", "navigationBarTitleText": "英飒俱乐部",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white"
}, },
"tabBar": { "tabBar": {

View File

@ -1,57 +1,68 @@
/* ========================================== /* ==========================================
影沙俱乐部 - 全局样式 英飒俱乐部 - 全局样式
设计理念:浅色高级感 · 橙色点缀 · 流畅动画 设计理念:浅色高级感 · 橙色点缀 · 流畅动画
========================================== */ ========================================== */
page { page {
/* 主色调:活力橙系 */ /* 主色调:活力橙系 */
--primary: #FF6B35; --primary: #ff6b35;
--primary-dark: #E85A28; --primary-dark: #e85a28;
--primary-light: #FF8C5A; --primary-light: #ff8c5a;
--primary-soft: #FFF0EB; --primary-soft: #fff0eb;
--primary-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 50%, #FFBA08 100%); --primary-gradient: linear-gradient(
--primary-gradient-soft: linear-gradient(135deg, rgba(255,107,53,0.1) 0%, rgba(255,186,8,0.05) 100%); 135deg,
#ff6b35 0%,
#ff8c5a 50%,
#ffba08 100%
);
--primary-gradient-soft: linear-gradient(
135deg,
rgba(255, 107, 53, 0.1) 0%,
rgba(255, 186, 8, 0.05) 100%
);
/* 强调色 */ /* 强调色 */
--accent: #00C9A7; --accent: #00c9a7;
--accent-light: #E6FBF7; --accent-light: #e6fbf7;
--accent-soft: rgba(0, 201, 167, 0.1); --accent-soft: rgba(0, 201, 167, 0.1);
/* 浅色背景系 */ /* 浅色背景系 */
--bg-page: #F7F8FA; --bg-page: #f7f8fa;
--bg-white: #FFFFFF; --bg-white: #ffffff;
--bg-card: #FFFFFF; --bg-card: #ffffff;
--bg-card-hover: #FAFBFC; --bg-card-hover: #fafbfc;
--bg-soft: #F2F3F5; --bg-soft: #f2f3f5;
--bg-warm: #FFFBF8; --bg-warm: #fffbf8;
/* 文字颜色 */ /* 文字颜色 */
--text-primary: #1A1A1A; --text-primary: #1a1a1a;
--text-secondary: #5C5C5C; --text-secondary: #5c5c5c;
--text-muted: #8C8C8C; --text-muted: #8c8c8c;
--text-hint: #BFBFBF; --text-hint: #bfbfbf;
--text-white: #FFFFFF; --text-white: #ffffff;
/* 边框 */ /* 边框 */
--border-light: #EBEDF0; --border-light: #ebedf0;
--border-soft: #F0F1F2; --border-soft: #f0f1f2;
--border-primary: rgba(255, 107, 53, 0.2); --border-primary: rgba(255, 107, 53, 0.2);
/* 阴影 */ /* 阴影 */
--shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); --shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
--shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.06); --shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8rpx 32rpx rgba(0, 0, 0, 0.08); --shadow-lg: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
--shadow-primary: 0 8rpx 24rpx rgba(255, 107, 53, 0.25); --shadow-primary: 0 8rpx 24rpx rgba(255, 107, 53, 0.25);
--shadow-card: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); --shadow-card: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
/* 圆角 */ /* 圆角 */
--radius-sm: 12rpx; --radius-sm: 12rpx;
--radius-md: 16rpx; --radius-md: 16rpx;
--radius-lg: 24rpx; --radius-lg: 24rpx;
--radius-xl: 32rpx; --radius-xl: 32rpx;
--radius-full: 100rpx; --radius-full: 100rpx;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC",
"Helvetica Neue", sans-serif;
font-size: 28rpx; font-size: 28rpx;
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-page); background: var(--bg-page);
@ -68,11 +79,26 @@ page {
background: var(--bg-page); background: var(--bg-page);
} }
.flex { display: flex; } .flex {
.flex-center { display: flex; align-items: center; justify-content: center; } display: flex;
.flex-between { display: flex; align-items: center; justify-content: space-between; } }
.flex-column { display: flex; flex-direction: column; } .flex-center {
.flex-1 { flex: 1; } display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
/* ========================================== /* ==========================================
卡片组件 - 高级感设计 卡片组件 - 高级感设计
@ -96,11 +122,13 @@ page {
.card-highlight { .card-highlight {
background: var(--bg-white); background: var(--bg-white);
border: 1rpx solid var(--border-primary); border: 1rpx solid var(--border-primary);
box-shadow: var(--shadow-card), 0 0 0 1rpx rgba(255, 107, 53, 0.05); box-shadow:
var(--shadow-card),
0 0 0 1rpx rgba(255, 107, 53, 0.05);
} }
.card-highlight::before { .card-highlight::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -133,19 +161,29 @@ page {
} }
.btn-primary::after { .btn-primary::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: -100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: btn-shimmer 2.5s ease-in-out infinite; animation: btn-shimmer 2.5s ease-in-out infinite;
} }
@keyframes btn-shimmer { @keyframes btn-shimmer {
0% { left: -100%; } 0% {
50%, 100% { left: 100%; } left: -100%;
}
50%,
100% {
left: 100%;
}
} }
.btn-secondary { .btn-secondary {
@ -188,28 +226,28 @@ page {
} }
.level-tag.lv1 { .level-tag.lv1 {
background: linear-gradient(135deg, #E8F5E9, #C8E6C9); background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #2E7D32; color: #2e7d32;
} }
.level-tag.lv2 { .level-tag.lv2 {
background: linear-gradient(135deg, #E3F2FD, #BBDEFB); background: linear-gradient(135deg, #e3f2fd, #bbdefb);
color: #1565C0; color: #1565c0;
} }
.level-tag.lv3 { .level-tag.lv3 {
background: linear-gradient(135deg, #FFF3E0, #FFE0B2); background: linear-gradient(135deg, #fff3e0, #ffe0b2);
color: #E65100; color: #e65100;
} }
.level-tag.lv4 { .level-tag.lv4 {
background: linear-gradient(135deg, #FCE4EC, #F8BBD9); background: linear-gradient(135deg, #fce4ec, #f8bbd9);
color: #C2185B; color: #c2185b;
} }
.level-tag.lv5 { .level-tag.lv5 {
background: linear-gradient(135deg, #F3E5F5, #E1BEE7); background: linear-gradient(135deg, #f3e5f5, #e1bee7);
color: #7B1FA2; color: #7b1fa2;
} }
/* ========================================== /* ==========================================
@ -228,20 +266,20 @@ page {
} }
.rank-badge.top1 { .rank-badge.top1 {
background: linear-gradient(135deg, #FFD700 0%, #FFAA00 100%); background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
color: #8B4513; color: #8b4513;
box-shadow: 0 4rpx 16rpx rgba(255, 170, 0, 0.4); box-shadow: 0 4rpx 16rpx rgba(255, 170, 0, 0.4);
} }
.rank-badge.top2 { .rank-badge.top2 {
background: linear-gradient(135deg, #E8E8E8 0%, #C0C0C0 100%); background: linear-gradient(135deg, #e8e8e8 0%, #c0c0c0 100%);
color: #4A4A4A; color: #4a4a4a;
box-shadow: 0 4rpx 12rpx rgba(192, 192, 192, 0.4); box-shadow: 0 4rpx 12rpx rgba(192, 192, 192, 0.4);
} }
.rank-badge.top3 { .rank-badge.top3 {
background: linear-gradient(135deg, #DEB887 0%, #CD853F 100%); background: linear-gradient(135deg, #deb887 0%, #cd853f 100%);
color: #5C4033; color: #5c4033;
box-shadow: 0 4rpx 12rpx rgba(205, 133, 63, 0.4); box-shadow: 0 4rpx 12rpx rgba(205, 133, 63, 0.4);
} }
@ -355,13 +393,13 @@ page {
} }
.tag-warning { .tag-warning {
background: #FFF8E6; background: #fff8e6;
color: #FA8C16; color: #fa8c16;
} }
.tag-danger { .tag-danger {
background: #FFF1F0; background: #fff1f0;
color: #FF4D4F; color: #ff4d4f;
} }
/* ========================================== /* ==========================================
@ -377,7 +415,7 @@ page {
} }
.power-change.negative { .power-change.negative {
color: #FF4D4F; color: #ff4d4f;
} }
/* ========================================== /* ==========================================
@ -474,7 +512,8 @@ page {
/* 脉冲效果 */ /* 脉冲效果 */
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
@ -486,7 +525,8 @@ page {
/* 呼吸发光 */ /* 呼吸发光 */
@keyframes breathe { @keyframes breathe {
0%, 100% { 0%,
100% {
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.25); box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.25);
} }
50% { 50% {
@ -496,28 +536,50 @@ page {
/* 渐变流动 */ /* 渐变流动 */
@keyframes gradientFlow { @keyframes gradientFlow {
0% { background-position: 0% 50%; } 0% {
50% { background-position: 100% 50%; } background-position: 0% 50%;
100% { background-position: 0% 50%; } }
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} }
/* 旋转 */ /* 旋转 */
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
/* 摇摆 */ /* 摇摆 */
@keyframes swing { @keyframes swing {
0%, 100% { transform: rotate(0deg); } 0%,
25% { transform: rotate(3deg); } 100% {
75% { transform: rotate(-3deg); } transform: rotate(0deg);
}
25% {
transform: rotate(3deg);
}
75% {
transform: rotate(-3deg);
}
} }
/* 闪烁 */ /* 闪烁 */
@keyframes blink { @keyframes blink {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.6; } 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
} }
/* 波纹扩散 */ /* 波纹扩散 */
@ -566,11 +628,21 @@ page {
} }
/* 延迟类 */ /* 延迟类 */
.delay-1 { animation-delay: 0.1s; } .delay-1 {
.delay-2 { animation-delay: 0.2s; } animation-delay: 0.1s;
.delay-3 { animation-delay: 0.3s; } }
.delay-4 { animation-delay: 0.4s; } .delay-2 {
.delay-5 { animation-delay: 0.5s; } animation-delay: 0.2s;
}
.delay-3 {
animation-delay: 0.3s;
}
.delay-4 {
animation-delay: 0.4s;
}
.delay-5 {
animation-delay: 0.5s;
}
/* ========================================== /* ==========================================
页面过渡效果 页面过渡效果
@ -580,16 +652,36 @@ page {
} }
/* 列表项依次入场 */ /* 列表项依次入场 */
.stagger-item:nth-child(1) { animation-delay: 0s; } .stagger-item:nth-child(1) {
.stagger-item:nth-child(2) { animation-delay: 0.05s; } animation-delay: 0s;
.stagger-item:nth-child(3) { animation-delay: 0.1s; } }
.stagger-item:nth-child(4) { animation-delay: 0.15s; } .stagger-item:nth-child(2) {
.stagger-item:nth-child(5) { animation-delay: 0.2s; } animation-delay: 0.05s;
.stagger-item:nth-child(6) { animation-delay: 0.25s; } }
.stagger-item:nth-child(7) { animation-delay: 0.3s; } .stagger-item:nth-child(3) {
.stagger-item:nth-child(8) { animation-delay: 0.35s; } animation-delay: 0.1s;
.stagger-item:nth-child(9) { animation-delay: 0.4s; } }
.stagger-item:nth-child(10) { animation-delay: 0.45s; } .stagger-item:nth-child(4) {
animation-delay: 0.15s;
}
.stagger-item:nth-child(5) {
animation-delay: 0.2s;
}
.stagger-item:nth-child(6) {
animation-delay: 0.25s;
}
.stagger-item:nth-child(7) {
animation-delay: 0.3s;
}
.stagger-item:nth-child(8) {
animation-delay: 0.35s;
}
.stagger-item:nth-child(9) {
animation-delay: 0.4s;
}
.stagger-item:nth-child(10) {
animation-delay: 0.45s;
}
/* ========================================== /* ==========================================
装饰元素 装饰元素

View File

@ -1,5 +1,5 @@
================================================================================ ================================================================================
影沙俱乐部小程序配置说明 英飒俱乐部小程序配置说明
================================================================================ ================================================================================
【配置文件位置】 【配置文件位置】

View File

@ -1,10 +1,9 @@
<!--个人中心页面 - 运动感设计--> <!-- 个人中心页面 - 运动感设计 -->
<view class="page-container"> <view class="page-container">
<!-- 顶部装饰背景 --> <!-- 顶部装饰背景 -->
<view class="hero-bg"> <view class="hero-bg">
<view class="hero-pattern"></view> <view class="hero-pattern"></view>
</view> </view>
<!-- 用户信息区域 --> <!-- 用户信息区域 -->
<view class="user-section"> <view class="user-section">
<!-- 已登录状态 --> <!-- 已登录状态 -->
@ -17,7 +16,6 @@
<text class="edit-icon">✏️</text> <text class="edit-icon">✏️</text>
</view> </view>
</view> </view>
<view class="user-info"> <view class="user-info">
<text class="nickname">{{userInfo.nickname || '新用户'}}</text> <text class="nickname">{{userInfo.nickname || '新用户'}}</text>
<view class="user-stats"> <view class="user-stats">
@ -38,7 +36,6 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 会员码卡片 --> <!-- 会员码卡片 -->
<view class="member-card" bindtap="showMemberCode"> <view class="member-card" bindtap="showMemberCode">
<view class="member-card-bg"></view> <view class="member-card-bg"></view>
@ -58,7 +55,6 @@
<view class="scan-hint">出示给对方扫码挑战</view> <view class="scan-hint">出示给对方扫码挑战</view>
</view> </view>
</block> </block>
<!-- 未登录状态 --> <!-- 未登录状态 -->
<block wx:else> <block wx:else>
<view class="login-card animate-fadeInUp"> <view class="login-card animate-fadeInUp">
@ -66,31 +62,23 @@
<image class="login-avatar" src="/images/avatar-default.svg" mode="aspectFill"></image> <image class="login-avatar" src="/images/avatar-default.svg" mode="aspectFill"></image>
<view class="login-avatar-pulse"></view> <view class="login-avatar-pulse"></view>
</view> </view>
<view class="login-content"> <view class="login-content">
<text class="login-title">开启你的运动之旅</text> <text class="login-title">开启你的运动之旅</text>
<text class="login-subtitle">授权手机号,加入影沙俱乐部</text> <text class="login-subtitle">授权手机号,加入英飒俱乐部</text>
</view> </view>
<button class="login-btn-primary" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
<button
class="login-btn-primary"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
<text class="btn-icon">📱</text> <text class="btn-icon">📱</text>
<text class="btn-text">手机号快捷登录</text> <text class="btn-text">手机号快捷登录</text>
</button> </button>
</view> </view>
</block> </block>
</view> </view>
<!-- 天梯信息卡片 --> <!-- 天梯信息卡片 -->
<view class="ladder-section animate-fadeInUp" style="animation-delay: 0.2s" wx:if="{{ladderUser}}"> <view class="ladder-section animate-fadeInUp" style="animation-delay: 0.2s" wx:if="{{ladderUser}}">
<view class="section-header"> <view class="section-header">
<text class="section-title">天梯战绩</text> <text class="section-title">天梯战绩</text>
<text class="section-badge">{{currentStore.storeName}}</text> <text class="section-badge">{{currentStore.storeName}}</text>
</view> </view>
<view class="ladder-stats"> <view class="ladder-stats">
<view class="ladder-stat-item"> <view class="ladder-stat-item">
<view class="stat-icon lv{{ladderUser.level}}"> <view class="stat-icon lv{{ladderUser.level}}">
@ -98,7 +86,6 @@
</view> </view>
<text class="stat-name">{{ladderUser.realName}}</text> <text class="stat-name">{{ladderUser.realName}}</text>
</view> </view>
<view class="ladder-progress"> <view class="ladder-progress">
<view class="progress-info"> <view class="progress-info">
<text class="progress-label">战力值</text> <text class="progress-label">战力值</text>
@ -108,24 +95,26 @@
<view class="progress-fill" style="width: {{ladderUser.powerScore / 30}}%;"></view> <view class="progress-fill" style="width: {{ladderUser.powerScore / 30}}%;"></view>
</view> </view>
</view> </view>
<view class="ladder-record"> <view class="ladder-record">
<view class="record-item"> <view class="record-item">
<text class="record-value win">{{ladderUser.winCount || 0}}</text> <text class="record-value win">{{ladderUser.winCount || 0}}</text>
<text class="record-label">胜场</text> <text class="record-label">胜场</text>
</view> </view>
<view class="record-item"> <view class="record-item">
<text class="record-value">{{(ladderUser.matchCount || 0) - (ladderUser.winCount || 0)}}</text> <text class="record-value">
{{(ladderUser.matchCount || 0) - (ladderUser.winCount || 0)}}
</text>
<text class="record-label">负场</text> <text class="record-label">负场</text>
</view> </view>
<view class="record-item"> <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.matchCount > 0 && ladderUser.winCount !== null && ladderUser.winCount !== undefined) ? Math.round((Number(ladderUser.winCount) || 0) / Number(ladderUser.matchCount) * 100) : 0}}%
</text>
<text class="record-label">胜率</text> <text class="record-label">胜率</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 未加入天梯提示 --> <!-- 未加入天梯提示 -->
<view class="notice-card animate-fadeInUp" style="animation-delay: 0.2s" wx:elif="{{userInfo && userInfo.phone}}"> <view class="notice-card animate-fadeInUp" style="animation-delay: 0.2s" wx:elif="{{userInfo && userInfo.phone}}">
<view class="notice-icon">🏸</view> <view class="notice-icon">🏸</view>
@ -134,7 +123,6 @@
<text class="notice-desc">请联系门店工作人员,开启你的天梯之旅</text> <text class="notice-desc">请联系门店工作人员,开启你的天梯之旅</text>
</view> </view>
</view> </view>
<!-- 功能菜单 --> <!-- 功能菜单 -->
<view class="menu-section animate-fadeInUp" style="animation-delay: 0.3s"> <view class="menu-section animate-fadeInUp" style="animation-delay: 0.3s">
<view class="menu-grid"> <view class="menu-grid">
@ -144,21 +132,18 @@
</view> </view>
<text class="menu-text">比赛记录</text> <text class="menu-text">比赛记录</text>
</view> </view>
<view class="menu-item" bindtap="goTo" data-url="/pages/points/records/index"> <view class="menu-item" bindtap="goTo" data-url="/pages/points/records/index">
<view class="menu-icon points"> <view class="menu-icon points">
<image src="/images/icon-points.svg" mode="aspectFit"></image> <image src="/images/icon-points.svg" mode="aspectFit"></image>
</view> </view>
<text class="menu-text">积分记录</text> <text class="menu-text">积分记录</text>
</view> </view>
<view class="menu-item" bindtap="goTo" data-url="/pages/points/order/index"> <view class="menu-item" bindtap="goTo" data-url="/pages/points/order/index">
<view class="menu-icon order"> <view class="menu-icon order">
<image src="/images/icon-order.svg" mode="aspectFit"></image> <image src="/images/icon-order.svg" mode="aspectFit"></image>
</view> </view>
<text class="menu-text">兑换订单</text> <text class="menu-text">兑换订单</text>
</view> </view>
<view class="menu-item" bindtap="goTo" data-url="/pages/store/index"> <view class="menu-item" bindtap="goTo" data-url="/pages/store/index">
<view class="menu-icon store"> <view class="menu-icon store">
<image src="/images/icon-store.svg" mode="aspectFit"></image> <image src="/images/icon-store.svg" mode="aspectFit"></image>
@ -168,7 +153,6 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 会员码弹窗 --> <!-- 会员码弹窗 -->
<view class="qrcode-overlay {{showQrcode ? 'show' : ''}}" bindtap="hideQrcode"> <view class="qrcode-overlay {{showQrcode ? 'show' : ''}}" bindtap="hideQrcode">
<view class="qrcode-modal {{showQrcode ? 'show' : ''}}" catchtap="preventBubble"> <view class="qrcode-modal {{showQrcode ? 'show' : ''}}" catchtap="preventBubble">
@ -176,7 +160,6 @@
<text class="modal-title">我的会员码</text> <text class="modal-title">我的会员码</text>
<view class="modal-close" bindtap="hideQrcode">×</view> <view class="modal-close" bindtap="hideQrcode">×</view>
</view> </view>
<view class="qrcode-wrapper"> <view class="qrcode-wrapper">
<view class="qrcode-border"> <view class="qrcode-border">
<!-- 加载中 --> <!-- 加载中 -->
@ -185,13 +168,7 @@
<text class="loading-text">生成中...</text> <text class="loading-text">生成中...</text>
</view> </view>
<!-- 二维码图片 --> <!-- 二维码图片 -->
<image <image wx:else class="qrcode-image" src="{{qrcodeImage}}" mode="aspectFit" show-menu-by-longpress="{{true}}"></image>
wx:else
class="qrcode-image"
src="{{qrcodeImage}}"
mode="aspectFit"
show-menu-by-longpress="{{true}}"
></image>
</view> </view>
<view class="qrcode-corners"> <view class="qrcode-corners">
<view class="corner tl"></view> <view class="corner tl"></view>
@ -200,12 +177,10 @@
<view class="corner br"></view> <view class="corner br"></view>
</view> </view>
</view> </view>
<view class="qrcode-info"> <view class="qrcode-info">
<text class="code-label">会员码</text> <text class="code-label">会员码</text>
<text class="code-value">{{userInfo.memberCode}}</text> <text class="code-value">{{userInfo.memberCode}}</text>
</view> </view>
<view class="qrcode-tips"> <view class="qrcode-tips">
<view class="tip-item"> <view class="tip-item">
<text class="tip-icon">📱</text> <text class="tip-icon">📱</text>
@ -214,7 +189,6 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 完善资料弹窗 --> <!-- 完善资料弹窗 -->
<view class="profile-overlay {{showProfileModal ? 'show' : ''}}" bindtap="closeProfileModal"> <view class="profile-overlay {{showProfileModal ? 'show' : ''}}" bindtap="closeProfileModal">
<view class="profile-modal {{showProfileModal ? 'show' : ''}}" catchtap="preventBubble"> <view class="profile-modal {{showProfileModal ? 'show' : ''}}" catchtap="preventBubble">
@ -222,47 +196,34 @@
<text class="profile-modal-title">{{isEditMode ? '修改资料' : '完善个人资料'}}</text> <text class="profile-modal-title">{{isEditMode ? '修改资料' : '完善个人资料'}}</text>
<view class="profile-modal-close" bindtap="closeProfileModal">×</view> <view class="profile-modal-close" bindtap="closeProfileModal">×</view>
</view> </view>
<view class="profile-modal-body"> <view class="profile-modal-body">
<!-- 提示信息 --> <!-- 提示信息 -->
<view class="profile-tips" wx:if="{{!isEditMode}}"> <view class="profile-tips" wx:if="{{!isEditMode}}">
<text class="tips-icon">💡</text> <text class="tips-icon">💡</text>
<text class="tips-text">完善资料后,好友可以更容易找到你</text> <text class="tips-text">完善资料后,好友可以更容易找到你</text>
</view> </view>
<!-- 头像选择 --> <!-- 头像选择 -->
<view class="profile-avatar-section"> <view class="profile-avatar-section">
<text class="profile-label">头像</text> <text class="profile-label">头像</text>
<button class="avatar-choose-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatarNew"> <button class="avatar-choose-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatarNew">
<image <image class="profile-avatar-preview" src="{{profileForm.avatar || '/images/avatar-default.svg'}}" mode="aspectFill"></image>
class="profile-avatar-preview"
src="{{profileForm.avatar || '/images/avatar-default.svg'}}"
mode="aspectFill"
></image>
<view class="avatar-choose-badge"> <view class="avatar-choose-badge">
<text class="choose-icon">📷</text> <text class="choose-icon">📷</text>
</view> </view>
</button> </button>
<text class="avatar-tip">点击更换头像</text> <text class="avatar-tip">点击更换头像</text>
</view> </view>
<!-- 昵称输入 --> <!-- 昵称输入 -->
<view class="profile-nickname-section"> <view class="profile-nickname-section">
<text class="profile-label">昵称</text> <text class="profile-label">昵称</text>
<input <input class="nickname-input" type="nickname" placeholder="请输入昵称" value="{{profileForm.nickname}}" bindinput="onNicknameInput" maxlength="20" />
class="nickname-input"
type="nickname"
placeholder="请输入昵称"
value="{{profileForm.nickname}}"
bindinput="onNicknameInput"
maxlength="20"
/>
</view> </view>
</view> </view>
<view class="profile-modal-footer"> <view class="profile-modal-footer">
<button class="profile-btn-cancel" bindtap="closeProfileModal">{{isEditMode ? '取消' : '跳过'}}</button> <button class="profile-btn-cancel" bindtap="closeProfileModal">
{{isEditMode ? '取消' : '跳过'}}
</button>
<button class="profile-btn-confirm" bindtap="saveProfile">保存</button> <button class="profile-btn-confirm" bindtap="saveProfile">保存</button>
</view> </view>
</view> </view>
</view> </view>

View File

@ -1,5 +1,5 @@
{ {
"description": "羽毛球俱乐部小程序", "description": "英飒俱乐部小程序",
"packOptions": { "packOptions": {
"ignore": [], "ignore": [],
"include": [] "include": []
@ -56,4 +56,4 @@
"tabSize": 2 "tabSize": 2
}, },
"simulatorPluginLibVersion": {} "simulatorPluginLibVersion": {}
} }

View File

@ -1,73 +1,74 @@
// 格式化日期 // 格式化日期
const formatDate = (date, format = 'YYYY-MM-DD HH:mm') => { const formatDate = (date, format = "YYYY-MM-DD HH:mm") => {
if (!date) return '' if (!date) return "";
const d = new Date(date) const d = new Date(date);
const year = d.getFullYear() const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, "0");
const hour = String(d.getHours()).padStart(2, '0') const hour = String(d.getHours()).padStart(2, "0");
const minute = String(d.getMinutes()).padStart(2, '0') const minute = String(d.getMinutes()).padStart(2, "0");
const second = String(d.getSeconds()).padStart(2, '0') const second = String(d.getSeconds()).padStart(2, "0");
return format return format
.replace('YYYY', year) .replace("YYYY", year)
.replace('MM', month) .replace("MM", month)
.replace('DD', day) .replace("DD", day)
.replace('HH', hour) .replace("HH", hour)
.replace('mm', minute) .replace("mm", minute)
.replace('ss', second) .replace("ss", second);
} };
// 等级名称 // 等级名称
const levelNames = { const levelNames = {
1: '新锐', 1: "新锐",
2: '精锐', 2: "精锐",
3: '高手', 3: "高手",
4: '大师', 4: "大师",
5: '宗师' 5: "宗师",
} };
const getLevelName = (level) => levelNames[level] || '未知' const getLevelName = (level) => levelNames[level] || "未知";
// 等级描述 // 等级描述
const levelDescs = { const levelDescs = {
1: '掌握基础动作,能进行多拍回合。', 1: "掌握基础动作,能进行多拍回合。",
2: '技术较全面,具备初步的战术意识。', 2: "技术较全面,具备初步的战术意识。",
3: '技术稳定,战术意图清晰,比赛经验丰富。', 3: "技术稳定,战术意图清晰,比赛经验丰富。",
4: '在俱乐部内属顶尖战力,具备较强掌控力。', 4: "在英飒俱乐部内属顶尖战力,具备较强掌控力。",
5: '技术全面,战术素养高,是俱乐部标杆。' 5: "技术全面,战术素养高,是英飒俱乐部标杆。",
} };
const getLevelDesc = (level) => levelDescs[level] || '' const getLevelDesc = (level) => levelDescs[level] || "";
// 比赛状态 // 比赛状态
const matchStatusTexts = { const matchStatusTexts = {
0: '待开始', 0: "待开始",
1: '进行中', 1: "进行中",
2: '已结束', 2: "已结束",
3: '已取消' 3: "已取消",
} };
const getMatchStatusText = (status) => matchStatusTexts[status] || '未知' const getMatchStatusText = (status) => matchStatusTexts[status] || "未知";
// 订单状态 // 订单状态
const orderStatusTexts = { const orderStatusTexts = {
0: '待核销', 0: "待核销",
1: '已完成', 1: "已完成",
2: '已取消' 2: "已取消",
} };
const getOrderStatusText = (status) => orderStatusTexts[status] || '未知' const getOrderStatusText = (status) => orderStatusTexts[status] || "未知";
// 生成随机字符串 // 生成随机字符串
const randomString = (len = 16) => { const randomString = (len = 16) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' const chars =
let result = '' "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)) result += chars.charAt(Math.floor(Math.random() * chars.length));
} }
return result return result;
} };
module.exports = { module.exports = {
formatDate, formatDate,
@ -75,5 +76,5 @@ module.exports = {
getLevelDesc, getLevelDesc,
getMatchStatusText, getMatchStatusText,
getOrderStatusText, getOrderStatusText,
randomString randomString,
} };

View File

@ -1,5 +1,5 @@
# ========================================== # ==========================================
# 影杀俱乐部管理系统 - 环境配置模板 # 英飒俱乐部管理系统 - 环境配置模板
# ========================================== # ==========================================
# 使用说明: # 使用说明:
# 1. 复制此文件为 .env # 1. 复制此文件为 .env

View File

@ -1,7 +1,7 @@
{ {
"name": "yingsha-server", "name": "yingsha-server",
"version": "1.0.0", "version": "1.0.0",
"description": "羽毛球俱乐部管理系统后端服务", "description": "英飒俱乐部管理系统后端服务",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
"start": "node src/app.js", "start": "node src/app.js",

View File

@ -1,88 +1,88 @@
// 用户等级 // 用户等级
const LADDER_LEVELS = { const LADDER_LEVELS = {
LV1_ROOKIE: 1, // 新锐 LV1_ROOKIE: 1, // 新锐
LV2_ELITE: 2, // 精锐 LV2_ELITE: 2, // 精锐
LV3_EXPERT: 3, // 高手 LV3_EXPERT: 3, // 高手
LV4_MASTER: 4, // 大师 LV4_MASTER: 4, // 大师
LV5_GRANDMASTER: 5 // 宗师 LV5_GRANDMASTER: 5, // 宗师
}; };
const LADDER_LEVEL_NAMES = { const LADDER_LEVEL_NAMES = {
1: '新锐', 1: "新锐",
2: '精锐', 2: "精锐",
3: '高手', 3: "高手",
4: '大师', 4: "大师",
5: '宗师' 5: "宗师",
}; };
const LADDER_LEVEL_DESC = { const LADDER_LEVEL_DESC = {
1: '掌握基础动作,能进行多拍回合。', 1: "掌握基础动作,能进行多拍回合。",
2: '技术较全面,具备初步的战术意识。', 2: "技术较全面,具备初步的战术意识。",
3: '技术稳定,战术意图清晰,比赛经验丰富。', 3: "技术稳定,战术意图清晰,比赛经验丰富。",
4: '在俱乐部内属顶尖战力,具备较强掌控力。', 4: "在英飒俱乐部内属顶尖战力,具备较强掌控力。",
5: '技术全面,战术素养高,是俱乐部标杆。' 5: "技术全面,战术素养高,是英飒俱乐部标杆。",
}; };
// 比赛类型 // 比赛类型
const MATCH_TYPES = { const MATCH_TYPES = {
CHALLENGE: 1, // 挑战赛 CHALLENGE: 1, // 挑战赛
RANKING: 2 // 排位赛 RANKING: 2, // 排位赛
}; };
// 比赛状态 // 比赛状态
const MATCH_STATUS = { const MATCH_STATUS = {
PENDING: 0, // 待开始 PENDING: 0, // 待开始
ONGOING: 1, // 进行中 ONGOING: 1, // 进行中
FINISHED: 2, // 已结束 FINISHED: 2, // 已结束
CANCELLED: 3 // 已取消 CANCELLED: 3, // 已取消
}; };
// 排位赛阶段 // 排位赛阶段
const RANKING_STAGE = { const RANKING_STAGE = {
SIGNUP: 0, // 报名中 SIGNUP: 0, // 报名中
ROUND_ROBIN: 1, // 循环赛 ROUND_ROBIN: 1, // 循环赛
ELIMINATION: 2, // 淘汰赛 ELIMINATION: 2, // 淘汰赛
FINISHED: 3 // 已结束 FINISHED: 3, // 已结束
}; };
// 比赛确认状态 // 比赛确认状态
const CONFIRM_STATUS = { const CONFIRM_STATUS = {
PENDING: 0, // 待确认 PENDING: 0, // 待确认
CONFIRMED: 1, // 已确认 CONFIRMED: 1, // 已确认
DISPUTED: 2 // 有争议 DISPUTED: 2, // 有争议
}; };
// 积分订单状态 // 积分订单状态
const ORDER_STATUS = { const ORDER_STATUS = {
PENDING: 0, // 待核销 PENDING: 0, // 待核销
COMPLETED: 1, // 已完成 COMPLETED: 1, // 已完成
CANCELLED: 2 // 已取消 CANCELLED: 2, // 已取消
}; };
// 战力值计算参数 // 战力值计算参数
const POWER_CALC = { const POWER_CALC = {
BASE_WIN: 15, // 基础胜场分 BASE_WIN: 15, // 基础胜场分
BASE_LOSE: -5, // 基础负场分 BASE_LOSE: -5, // 基础负场分
UNDERDOG_THRESHOLD: 100, // 以下克上分差阈值 UNDERDOG_THRESHOLD: 100, // 以下克上分差阈值
UNDERDOG_RATE: 0.1, // 以下克上奖励比例 UNDERDOG_RATE: 0.1, // 以下克上奖励比例
MAX_CHANGE: 50, // 单场最大变动 MAX_CHANGE: 50, // 单场最大变动
NEWBIE_PROTECTION: 5, // 新手保护场次 NEWBIE_PROTECTION: 5, // 新手保护场次
NEWBIE_LOSE_RATE: 0.5, // 新手输分减半 NEWBIE_LOSE_RATE: 0.5, // 新手输分减半
MIN_MONTHLY_MATCHES: 3, // 每月最低参赛场次 MIN_MONTHLY_MATCHES: 3, // 每月最低参赛场次
CHALLENGE_COOLDOWN: 30 // 挑战赛同人冷却天数 CHALLENGE_COOLDOWN: 30, // 挑战赛同人冷却天数
}; };
// 比赛权重 // 比赛权重
const MATCH_WEIGHTS = { const MATCH_WEIGHTS = {
DAILY: 1.0, // 日常畅打 DAILY: 1.0, // 日常畅打
CHALLENGE: 1.5, // 挑战赛 CHALLENGE: 1.5, // 挑战赛
MONTHLY: 1.5, // 月度排位赛 MONTHLY: 1.5, // 月度排位赛
SEASONAL: 2.0 // 季度/年度总决赛 SEASONAL: 2.0, // 季度/年度总决赛
}; };
// 积分计算 // 积分计算
const POINTS_CALC = { const POINTS_CALC = {
CONSUME_RATE: 0.1 // 每10元1分 CONSUME_RATE: 0.1, // 每10元1分
}; };
module.exports = { module.exports = {
@ -96,5 +96,5 @@ module.exports = {
ORDER_STATUS, ORDER_STATUS,
POWER_CALC, POWER_CALC,
MATCH_WEIGHTS, MATCH_WEIGHTS,
POINTS_CALC POINTS_CALC,
}; };

View File

@ -8,7 +8,7 @@ class LadderController {
// 获取天梯排名 // 获取天梯排名
async getRanking(req, res) { async getRanking(req, res) {
try { try {
const { store_id, gender, level, page = 1, pageSize = 50 } = req.query; const { store_id, gender, level, page = 1, pageSize = 50, is_display } = req.query;
const { limit, offset } = getPagination(page, pageSize); const { limit, offset } = getPagination(page, pageSize);
if (!store_id) { if (!store_id) {
@ -17,10 +17,14 @@ class LadderController {
const where = { const where = {
store_id, store_id,
status: 1, status: 1
monthly_match_count: { [Op.gte]: POWER_CALC.MIN_MONTHLY_MATCHES }
}; };
// 如果不是大屏显示,则需要满足每月最低参赛场次限制
if (!is_display) {
where.monthly_match_count = { [Op.gte]: POWER_CALC.MIN_MONTHLY_MATCHES };
}
if (gender) { if (gender) {
where.gender = gender; where.gender = gender;
} }