Compare commits

..

5 Commits

Author SHA1 Message Date
Ls
c9aeba0949 保存数据 2026-04-11 17:47:28 +08:00
Ls
f682c0316b 提交数据 2026-04-06 09:31:25 +08:00
Ls
a4bcd5ae1f '提交' 2026-03-31 08:40:11 +08:00
Ls
b8c05d23ae 页面修改 2026-03-28 08:55:48 +08:00
Ls
deaf39e943 最新状态 2026-03-26 08:34:32 +08:00
101 changed files with 33354 additions and 684 deletions

143
AGENTS.md Normal file
View File

@@ -0,0 +1,143 @@
# AGENTS.md - Swimming uni-app Project
## Build & Development Commands
### Development Servers
```bash
npm run dev:h5 # Run H5 development server
npm run dev:mp-weixin # Run WeChat mini-program development
npm run dev:app # Run app development
npm run dev:app-android # Run Android app development
npm run dev:app-ios # Run iOS app development
npm run dev:mp-alipay # Run Alipay mini-program
npm run dev:mp-baidu # Run Baidu mini-program
npm run dev:mp-qq # Run QQ mini-program
npm run dev:mp-toutiao # Run Toutiao mini-program
```
### Build Commands
```bash
npm run build:h5 # Build for H5
npm run build:mp-weixin # Build for WeChat mini-program
npm run build:app # Build for app
npm run build:app-android # Build for Android
npm run build:app-ios # Build for iOS
```
### Type Checking
```bash
npm run type-check # Run TypeScript type checking (vue-tsc --noEmit)
```
### Testing
- **Note**: No test framework configured in this project yet.
## Code Style Guidelines
### Imports & Path Aliases
- Use `@/` alias for imports from `src/` directory (configured in `tsconfig.json`)
- Example: `import { Service } from '@/Service/Service'`
- Import Vue composition API functions from 'vue'
- Import uni-app lifecycle hooks from `@dcloudio/uni-app`
### File Structure
```
src/
├── pages/ # Page components (with sub-packages)
├── components/ # Reusable Vue components
├── Service/ # API services and utilities
├── common/ # Common utilities and helpers
├── static/ # Static assets (images, etc.)
├── types/ # TypeScript type definitions
├── uni_modules/ # Uni-app modules
└── colorui/ # ColorUI CSS framework
```
### Naming Conventions
- **Files**: PascalCase for components (e.g., `ImageCropper.vue`), camelCase for utilities
- **Components**: PascalCase for component names
- **Variables**: camelCase for local variables and functions
- **Constants**: UPPER_SNAKE_CASE for constants
- **Classes**: PascalCase for class names (e.g., `Service`)
- **CSS Classes**: kebab-case (e.g., `home-container`, `timer-card`)
### TypeScript
- Use `lang="ts"` in Vue SFC `<script>` tags
- Use `<script setup>` syntax for Vue 3 composition API
- Type function parameters and return values
- Use `any` sparingly, prefer proper type definitions
- Path alias: `@/*` maps to `./src/*`
### Vue Component Structure
```vue
<template>
<!-- Template with tabs for indentation -->
</template>
<script setup lang="ts">
// Imports first
// Composition API logic
// Functions
// Lifecycle hooks
</script>
<style lang="scss" scoped>
/* SCSS styles with nested structure */
</style>
```
### Formatting
- **Indentation**: Use tabs (not spaces) for indentation
- **Quotes**: Single quotes for strings in TypeScript/JavaScript
- **Semicolons**: Optional but consistent (project uses semicolons)
- **Line endings**: CRLF (Windows)
- **SCSS**: Use nested selectors, kebab-case class names
### Error Handling
- Use `Service.Msg()` for toast messages
- Use `Service.Alert()` for modal dialogs
- API requests through `Service.Request()` handle 401 (token expired) automatically
- Return `ResultData` objects from service methods
- Clean up intervals in `onUnmounted()` lifecycle hook
### UI Components
- Use **uview-plus** as primary UI component library
- Use **ColorUI** for CSS framework and icons
- Component prefix: `u-` for uview-plus components (e.g., `<u-icon>`, `<u-button>`)
### Service Layer Pattern
- Extend `BaseConfig` for service classes
- Use static methods for utility functions
- `Service` class provides:
- API requests: `Service.Request()`
- Navigation: `Service.GoPage()`, `Service.GoPageTab()`, `Service.GoPageBack()`
- Storage: `Service.SetStorageCache()`, `Service.GetStorageCache()`
- Messages: `Service.Msg()`, `Service.Alert()`
- Loading: `Service.LoadIng()`, `Service.LoadClose()`
### uni-app Specifics
- Use `uni.` API for platform-specific operations
- Pages registered in `src/pages.json`
- Tab bar configured in `src/pages.json`
- Use `rpx` units for responsive design
- Global styles in `src/App.vue` and `src/uni.scss`
### Git & Commit
- `.gitignore` includes `node_modules/`, `dist/`, `unpackage/`
- Commit messages should be descriptive (in Chinese or English)
### Key Dependencies
- Vue 3.4.21
- TypeScript 4.9.4
- Vite 5.2.8
- uni-app 3.0.0
- uview-plus 3.3.54
- dayjs (date manipulation)
- echarts (charts)
- vue-i18n (internationalization)
### No Existing Rules
- No `.cursor/rules/` or `.cursorrules` found
- No `.github/copilot-instructions.md` found
- No ESLint or Prettier configuration found
- Follow existing code patterns when making changes

88
CLAUDE.md Normal file
View File

@@ -0,0 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a uni-app Vue 3 + TypeScript swimming timer/training application called "智能秒表" (Smart Stopwatch). It supports multiple platforms including H5, WeChat mini-program, and native apps.
## Development Commands
```bash
# Development
npm run dev:h5 # Run H5 development server
npm run dev:mp-weixin # Run WeChat mini-program development
npm run dev:app # Run app development
# Build
npm run build:h5 # Build for H5
npm run build:mp-weixin # Build for WeChat mini-program
npm run build:app # Build for app
# Type checking
npm run type-check # Run TypeScript type checking
```
## Architecture
### Directory Structure
```
src/
├── pages/ # Main pages (index/, user/)
├── pages/userFunc/ # Sub-package for user features (timing, segmentation, project)
├── components/ # Reusable Vue components
├── Service/ # API service layer
│ ├── Service.ts # Main service class (extends BaseConfig)
│ └── BaseConfig.ts # Base configuration
├── common/
│ ├── Domain/ # TypeScript interfaces (Project, StudentRecord, TimingState)
│ └── Unit/ # Utility classes (HttpRequest, StoreAssist, UploadAssist)
├── static/ # Static assets
├── types/ # Additional TypeScript types
└── colorui/ # ColorUI CSS framework
```
### Service Layer Pattern
- `Service` class extends `BaseConfig` and provides:
- API requests: `Service.Request(url, method, data)` - handles 401 token expiration automatically
- Navigation: `Service.GoPage()`, `Service.GoPageTab()`, `Service.GoPageBack()`, `Service.GoPageDelse()`
- Storage: `Service.SetStorageCache()`, `Service.GetStorageCache()`, `Service.DelStorageCache()`
- Messages: `Service.Msg()`, `Service.Alert()`
- Loading: `Service.LoadIng()`, `Service.LoadClose()`
- File uploads: `Service.UpLoadMedia()`, `Service.uploadH5()`
### Pages Configuration
- Pages registered in `src/pages.json`
- User features in `subPackages` under `pages/userFunc/`
- Tab bar with 2 tabs: "项目" (index) and "我的" (user)
- Navigation style is custom for some pages (e.g., swimim page)
### Key Domain Types
- `Project`: Swimming project data (id, name, createdAt)
- `StudentRecord`: Student timing records
- `TimingState`: Timer state management
- `ResultData`: Standard API response wrapper
## Code Style
- Use tabs for indentation
- Single quotes for strings
- PascalCase for components and classes
- camelCase for variables and functions
- kebab-case for CSS classes
- Use `@/` path alias for imports from src/ directory
- Import Vue composition API from 'vue', uni-app hooks from `@dcloudio/uni-app`
## UI Framework
- **uview-plus** as primary UI component library (configured with easycom)
- **ColorUI** for CSS framework and icons
- Component prefix: `u-` (e.g., `<u-icon>`, `<u-button>`)
- Use `rpx` units for responsive design
## Important Notes
- Automatic 401 token handling: API requests through `Service.Request()` redirect to login on token expiration
- Clean up intervals in `onUnmounted()` lifecycle hook
- No test framework currently configured
- Platform-specific APIs prefixed with `uni.` (uni-app framework)

14
package-lock.json generated
View File

@@ -12682,15 +12682,13 @@
}
},
"node_modules/uview-plus": {
"version": "3.3.54",
"resolved": "https://registry.npmmirror.com/uview-plus/-/uview-plus-3.3.54.tgz",
"integrity": "sha512-c/KcwTkbJed6ZZqxh7mreDFjtkq5ebNkMHsvgFn53xKVEbjWuGJ/zz4jOXWoO+n0r5hxH1LzrDXXDDiBDvqORA==",
"dependencies": {
"clipboard": "^2.0.11",
"dayjs": "^1.11.3"
},
"version": "3.7.13",
"resolved": "https://registry.npmmirror.com/uview-plus/-/uview-plus-3.7.13.tgz",
"integrity": "sha512-vHByf0kxKReYxam6BuU6wn/80giCkMaMUHEblhkf4kAjP852b86V3ctkjfGtV17MEIORFo3Vkve+HFnHNXpwNg==",
"engines": {
"HBuilderX": "^3.1.0"
"HBuilderX": "^3.1.0",
"uni-app": "^4.66",
"uni-app-x": ""
}
},
"node_modules/v8-to-istanbul": {

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
import { NvpMerchService } from '@/Service/Nvp/NvpMerchService'
onLaunch(() => {
uni.hideTabBar()
getUpData()
console.log("App Launch");
});
onShow(() => {
@@ -12,39 +10,16 @@
onHide(() => {
console.log("App Hide");
});
const getUpData=()=>{
// #ifdef APP
plus.runtime.getProperty(plus.runtime.appid, (wgtinfo) => {
NvpMerchService.GetAppVersion().then(res=>{
console.log('wgtinfo.versionCode',wgtinfo.versionCode);
if (res.data.version > wgtinfo.versionCode) {
setTimeout(function() {
uni.navigateTo({
url: "/pages/upData/upData?info=" +
encodeURIComponent(
JSON.stringify(res.data))
})
}, 1000)
}
})
})
// #endif
}
</script>
<style lang="scss">
@import "uview-plus/index.scss";
@import "colorui/main.css";
@import "colorui/icon.css";
page {
--nav-mian: #F84F28; //全局颜色
--nav-vice: #F59D77; //副颜色
--nav-diluted: #F2C0A3; //淡颜色
}
@import "colorui/icon.css";
</style>
page {
--nav-mian: #22a6f2; //全局颜色
--nav-vice: #00a2ff; //副颜色
--nav-diluted: #6ba9cd; //淡颜色
}
</style>

View File

@@ -1,9 +1,11 @@
export class BaseConfig {
// protected static servesUrl: string = "http://192.168.0.190:8806";//线下
protected static servesUrl: string = "http://192.168.0.142:5298";
protected static imgUrl: string = "http://192.168.0.142:5298";
protected static mediaUrl: string = "http://192.168.0.142:5298/";
protected static servesUrl: string = "http://vp.xypays.cn";
protected static imgUrl: string = "http://vp.cloud.xypays.cn";
protected static mediaUrl: string = "http://byc1.xypays.cn/";
// protected static servesUrl: string = "http://vp.xypays.cn";
// protected static imgUrl: string = "http://vp.cloud.xypays.cn";
// protected static mediaUrl: string = "http://byc1.xypays.cn/";
protected static uploadUrl: string = "/TencentCos/GetUpLoadInfo";
protected static payuploadUrl: string = "http://pay.xypays.cn";
protected static payuploadUrl: string = "http://192.168.0.142:5298";
}

View File

@@ -1,36 +0,0 @@
import { Service } from '@/Service/Service';
/*****登录接口*****/
class NvpAddressService {
private static GetAddressListPath: string = '/Address/GetAddressList';
/*****收货地址*****/
static GetAddressList(page: number) {
var result = Service.Request(this.GetAddressListPath, "GET", { page });
return result;
}
private static DelUserAddressPath: string = '/Address/DelUserAddress';
/*****删除地址*****/
static DelUserAddress(uaId: string) {
var result = Service.Request(this.DelUserAddressPath, "POST", { uaId });
return result;
}
private static AddUserAddressPath: string = '/Address/AddUserAddress';
/*****添加地址*****/
static AddUserAddress(uaId: string, name: string, phone: string, province: string, city: string, county: string, address: string, isDefault: number) {
var result = Service.Request(this.AddUserAddressPath, "POST", { uaId, name, phone, province, city, county, address, isDefault });
return result;
}
private static UpdateUserAddressPath: string = '/Address/UpdateUserAddress';
/*****修改默认地址*****/
static UpdateUserAddress(uaId: string) {
var result = Service.Request(this.UpdateUserAddressPath, "POST", { uaId });
return result;
}
}
export {
Service,
NvpAddressService
}

View File

@@ -1,43 +0,0 @@
import { Service } from '@/Service/Service';
/*****代理端接口*****/
class NvpAgentService {
private static LoginPath: string = '/Agent/Login';
/*****登录接口*****/
static Login(name: string, pwd: string) {
var result = Service.Request(this.LoginPath, "POST", { name, pwd });
return result;
}
private static GetAgentAccInfoPath: string = '/Agent/GetAgentAccInfo';
/*****账户接口*****/
static GetAgentAccInfo() {
var result = Service.Request(this.GetAgentAccInfoPath, "GET", "");
return result;
}
private static GetAgentAccLogPath: string = '/Agent/GetAgentAccLog';
/*****账户记录*****/
static GetAgentAccLog(code: string, page: number) {
var result = Service.Request(this.GetAgentAccLogPath, "GET", { code, page });
return result;
}
private static GetAgentMerchPath: string = '/Agent/GetAgentMerch';
/*****获取代理开通商家*****/
static GetAgentMerch(type: number, page: number) {
var result = Service.Request(this.GetAgentMerchPath, "GET", { type, page });
return result;
}
private static GetAgentHomePath: string = '/Agent/GetAgentHome';
/*****代理主页*****/
static GetAgentHome() {
var result = Service.Request(this.GetAgentHomePath, "GET", { });
return result;
}
}
export {
Service,
NvpAgentService
}

View File

@@ -1,156 +0,0 @@
import { Service } from '@/Service/Service';
/*****用户接口*****/
class NvpApplyService {
// private static WithDrawPath: string = '/With/WithDraw';
// /*****佣金提现*****/
// static WithDraw(money: number, name: string, account: string) {
// var result = Service.Request(this.WithDrawPath, "POST", { money, name, account });
// return result;
// }
private static GetSiteMccCodeListPath: string = '/Apply/GetSiteMccCodeList';
/*****获取mcc列表*****/
static GetSiteMccCodeList(mercType:string, mchType: string) {
var result = Service.Request(this.GetSiteMccCodeListPath, "GET", {mercType, mchType });
return result;
}
private static GetBankTypeListPath: string = '/Apply/GetBankTypeList';
/*****获取银行列表*****/
static GetBankTypeList(name:string) {
var result = Service.Request(this.GetBankTypeListPath, "GET", {name});
return result;
}
private static GetAreaListPath: string = '/Apply/GetAreaList';
/*****获取银行地区列表*****/
static GetAreaList(areaCode:string) {
var result = Service.Request(this.GetAreaListPath, "GET", { areaCode});
return result;
}
private static GetBankCodeListPath: string = '/Apply/GetBankCodeList';
/*****获取银行代码列表*****/
static GetBankCodeList(bankType:string,cityCode:string,name:string) {
var result = Service.Request(this.GetBankCodeListPath, "GET", {bankType,cityCode,name});
return result;
}
private static SendApplyMerchPath: string = '/Apply/SendApplyMerch';
/*****进价提交*****/
static SendApplyMerch(para:any) {
var result = Service.Request(this.SendApplyMerchPath, "POST", para);
return result;
}
private static GetAgentMerchLogPath: string = '/Apply/GetAgentMerchLog';
/*****获取待审核*****/
static GetAgentMerchLog() {
var result = Service.Request(this.GetAgentMerchLogPath, "GET", {});
return result;
}
private static AuditApplyPath: string = '/Apply/AuditApply';
/*****确认资料*****/
static AuditApply(outId:string) {
var result = Service.Request(this.AuditApplyPath, "POST", {outId});
return result;
}
private static SetPayFeePath: string = '/Apply/SetPayFee';
/*****确认资料*****/
static SetPayFee(outId:string) {
var result = Service.Request(this.SetPayFeePath, "POST", {outId});
return result;
}
private static GetAssortListPath: string = '/Apply/GetAssortList';
/*****获取v派分类*****/
static GetAssortList(code:string) {
var result = Service.Request(this.GetAssortListPath, "GET", {code,parent:'0'});
return result;
}
private static GetAgentMerchPath: string = '/Agent/GetAgentMerch';
/*****获取已开通商家*****/
static GetAgentMerch(type:number,page:number) {
var result = Service.Request(this.GetAgentMerchPath, "GET", {type,page});
return result;
}
private static GetAppMerchInfoPath: string = '/Apply/GetAppMerchInfo';
/*****获取银盛已填写信息*****/
static GetAppMerchInfo(outId:string) {
var result = Service.Request(this.GetAppMerchInfoPath, "GET", {outId});
return result;
}
private static UpdateMerchPath: string = '/Apply/UpdateMerch';
/*****修改银盛信息*****/
static UpdateMerch(obj:any) {
var result = Service.Request(this.UpdateMerchPath, "POST", obj);
return result;
}
private static UploadImgPath: string = '/Apply/UploadImg';
/*****修改银盛图片*****/
static UploadImg(outId:string,picType:string,img:string) {
var result = Service.Request(this.UploadImgPath, "POST", {outId,picType,img});
return result;
}
private static GetAgentApplyPath: string = '/Apply/GetAgentApply';
/*****获取添加的商家*****/
static GetAgentApply(serch:string,page:number) {
var result = Service.Request(this.GetAgentApplyPath, "GET", {serch,page});
return result;
}
private static GetCategoryPath: string = '/Apply/GetCategory';
/*****获取商家类型*****/
static GetCategory() {
var result = Service.Request(this.GetCategoryPath, "GET", {});
return result;
}
private static AddMerchInfoPath: string = '/Apply/AddMerchInfo';
/*****添加商户*****/
static AddMerchInfo(obj:any) {
var result = Service.Request(this.AddMerchInfoPath, "POST", obj);
return result;
}
private static BandAppIdPath: string = '/Agent/BandAppId';
/*****绑定appid*****/
static BandAppId(merchId:string) {
var result = Service.Request(this.BandAppIdPath, "POST", {merchId});
return result;
}
}
export {
Service,
NvpApplyService
}

View File

@@ -1,50 +0,0 @@
import { Service } from '@/Service/Service';
/*****登录接口*****/
class NvpBankService {
private static GetPageBankListPath: string = '/Bank/GetPageBankList';
/*****用户银行卡列表*****/
static GetPageBankList(page: number) {
var result = Service.Request(this.GetPageBankListPath, "GET", { page });
return result;
}
private static GetBankListPath: string = '/Bank/GetBankList';
/*****用户银行卡列表*****/
static GetBankList() {
var result = Service.Request(this.GetBankListPath, "GET", "");
return result;
}
private static GetUnitBankListPath: string = '/Bank/GetUnitBankList';
/*****银行卡列表*****/
static GetUnitBankList() {
var result = Service.Request(this.GetUnitBankListPath, "GET", "");
return result;
}
private static AddUserBankPath: string = '/Bank/AddUserBank';
/*****添加银行卡*****/
static AddUserBank(account: string, bank: string, name: string) {
var result = Service.Request(this.AddUserBankPath, "POST", { account, bank, name });
return result;
}
private static UpdateBankPath: string = '/Bank/UpdateBank';
/*****修改银行卡*****/
static UpdateBank(ubId: string, bank: string, name: string, account: string) {
var result = Service.Request(this.UpdateBankPath, "POST", { ubId, bank, name, account });
return result;
}
private static DelUserBankPath: string = '/Bank/DelUserBank';
/*****删除银行卡*****/
static DelUserBank(ubId: string) {
var result = Service.Request(this.DelUserBankPath, "POST", { ubId });
return result;
}
}
export {
Service,
NvpBankService
}

View File

@@ -1,36 +0,0 @@
import { Service } from '@/Service/Service';
/*****登录接口*****/
class NvpLoginService {
private static GetWxLoginOpenIdPath: string = '/Login/GetWxLoginOpenId';
/*****小程序根据code获取openId*****/
static GetWxLoginOpenId(code: string, type: number) {
var result = Service.Request(this.GetWxLoginOpenIdPath, "GET", { code, type });
return result;
}
private static AppletLoginPath: string = '/Login/AppletLogin';
/*****普通登陆接口(小程序)*****/
static AppletLogin(openId: string, channel: string) {
var result = Service.Request(this.AppletLoginPath, "POST", { openId, channel });
return result;
}
private static RegistPath: string = '/Login/Regist';
/*****注册接口*****/
static Regist(remNo: string, openId: string, uniopenId: string, channel: string, type: number) {
var result = Service.Request(this.RegistPath, "POST", { remNo, openId, uniopenId, channel, type });
return result;
}
private static GetNumberPhonePath: string = '/Login/GetNumberPhone';
/*****获取手机号*****/
static GetNumberPhone(code: string) {
var result = Service.Request(this.GetNumberPhonePath, "GET", { code });
return result;
}
}
export {
Service,
NvpLoginService
}

View File

@@ -1,32 +0,0 @@
import { Service } from '@/Service/Service';
/*****设备接口*****/
class NvpMachineService {
private static GetMerchListPath: string = '/Machine/GetMerchList';
/*****商户设备*****/
static GetMerchList(merchId: string) {
var result = Service.Request(this.GetMerchListPath, "GET", { merchId });
return result;
}
private static DelMerchMachinePath: string = '/Machine/DelMerchMachine';
/*****删除设备*****/
static DelMerchMachine(merchId: string, machineId: string) {
var result = Service.Request(this.DelMerchMachinePath, "POST", { merchId, machineId });
return result;
}
private static AddMachinePath: string = '/Machine/AddMachine';
/*****添加设备*****/
static AddMachine(merchId: string, payCode: string) {
var result = Service.Request(this.AddMachinePath, "POST", { merchId, payCode });
return result;
}
}
export {
Service,
NvpMachineService
}

View File

@@ -1,106 +0,0 @@
import { Service } from '@/Service/Service';
/*****用户接口*****/
class NvpMerchService {
private static GetMerchInfoPath: string = '/Merch/GetMerchInfo';
/*****获取商家信息*****/
static GetMerchInfo(merchId: string,lon:number,lat:number) {
var result = Service.Request(this.GetMerchInfoPath, "GET", { merchId,lon,lat});
return result;
}
private static GetMerchOrderPath: string = '/Merch/GetMerchOrder';
/*****获取商家营业数据*****/
static GetMerchOrder(merchId: string) {
var result = Service.Request(this.GetMerchOrderPath, "GET", { merchId });
return result;
}
private static UpdateMerchInfoPath: string = '/Merch/UpdateMerchInfo';
/*****修改商家信息*****/
static UpdateMerchInfo(merchId: string, logo: string ,name:string, phone:string,province:string,city:string,county:string,address:string,lon:string,lat:string,sTime:string,eTime:string,showImg:string) {
var result = Service.Request(this.UpdateMerchInfoPath, "POST", { merchId, logo,name ,phone,province,city,county,address,lon,lat,sTime,eTime,showImg});
return result;
}
private static GetMerchAccPath: string = '/Merch/GetMerchAcc';
/*****获取商家佣金数据*****/
static GetMerchAcc(merchId: string) {
var result = Service.Request(this.GetMerchAccPath, "GET", { merchId });
return result;
}
private static GetMerchAccLogPath: string = '/Merch/GetMerchAccLog';
/*****获取商家佣金记录*****/
static GetMerchAccLog(merchId: string,op:string,page:number) {
var result = Service.Request(this.GetMerchAccLogPath, "GET", { merchId,op,page });
return result;
}
private static MerchWithDrawPath: string = '/Merch/MerchWithDraw';
/*****商家佣金提现*****/
static MerchWithDraw(merchId: string, money: number ,name:string, account:string) {
var result = Service.Request(this.MerchWithDrawPath, "POST", { merchId, money,name,account});
return result;
}
private static GetMerchWithLogPath: string = '/Merch/GetMerchWithLog';
/*****商家佣金提现记录*****/
static GetMerchWithLog(merchId: string) {
var result = Service.Request(this.GetMerchWithLogPath, "GET", { merchId });
return result;
}
private static GetMerchTicketListPath: string = '/Merch/GetMerchTicketList';
/*****商家优惠券列表*****/
static GetMerchTicketList(merchId: string) {
var result = Service.Request(this.GetMerchTicketListPath, "GET", { merchId });
return result;
}
private static AddMerchTicketPath: string = '/Merch/AddMerchTicket';
/*****商家优惠券修改添加*****/
static AddMerchTicket(ticketId: string, merchId: string ,code:string, atkAcc:number,redAcc:number,count:number,useTime:Number,state:Number,addTime:string,endTime:string) {
var result = Service.Request(this.AddMerchTicketPath, "POST", { ticketId, merchId,code,atkAcc,redAcc,count,useTime,state,addTime,endTime});
return result;
}
private static DelMerchTicketPath: string = '/Merch/DelMerchTicket';
/*****商家删除优惠券*****/
static DelMerchTicket(ticketId: string) {
var result = Service.Request(this.DelMerchTicketPath, "POST", { ticketId});
return result;
}
private static GetTicketInfoPath: string = '/Merch/GetTicketInfo';
/*****获取优惠券详情*****/
static GetTicketInfo(ticketId: string) {
var result = Service.Request(this.GetTicketInfoPath, "GET", { ticketId });
return result;
}
private static GetAppVersionPath: string = '/Login/GetAppVersion';
/*****更新*****/
static GetAppVersion() {
var result = Service.Request(this.GetAppVersionPath, "GET", {type:2});
return result;
}
}
export {
Service,
NvpMerchService
}

View File

@@ -1,43 +0,0 @@
import { Service } from '@/Service/Service';
/*****公共接口*****/
class NvpPubService {
private static GetIndexPath: string = '/Pub/GetIndex';
/*****主页信息*****/
static GetIndex() {
var result = Service.Request(this.GetIndexPath, "GET", "");
return result;
}
private static GetIndexDataPath: string = '/Pub/GetIndexData';
/*****获取首页数据*****/
static GetIndexData(lon: number, lat: number, city: string, county: string, sort: number, page: number) {
var result = Service.Request(this.GetIndexDataPath, "GET", { lon, lat, city, county, sort, page });
return result;
}
private static GetMenuDataPath: string = '/Pub/GetMenuData';
/*****获取分类*****/
static GetMenuData(type: string, parent: string) {
var result = Service.Request(this.GetMenuDataPath, "GET", { type, parent });
return result;
}
private static GetMerchDataPath: string = '/Pub/GetMerchData';
/*****获取店铺*****/
static GetMerchData(code: string, serch: string, assId: string, lon: number, lat: number, city: string, county: string, sort: number, page: number, limit: number) {
var result = Service.Request(this.GetMerchDataPath, "GET", { code, serch, assId, lon, lat, city, county, sort, page, limit });
return result;
}
private static GetRandomMerchPath: string = '/Pub/GetRandomMerch';
/*****随机获取商家*****/
static GetRandomMerch(count: number, lon: number, lat: number, city: string, county: string) {
var result = Service.Request(this.GetRandomMerchPath, "GET", { count, lon, lat, city, county });
return result;
}
}
export {
Service,
NvpPubService
}

View File

@@ -1,22 +0,0 @@
import { Service } from '@/Service/Service';
/*****腾讯云存储*****/
class NvpTencentCosService {
private static GetAuthorizationPath: string = '/TencentCos/GetAuthorization';
/*****获取云存储配置*****/
static GetAuthorization() {
var result = Service.Request(this.GetAuthorizationPath, "GET", "");
return result;
}
private static GetUpLoadInfoPath: string = '/TencentCos/GetUpLoadInfo';
/*****获取上传地址*****/
static GetUpLoadInfo(code: string, fileName: string, desire: string) {
var result = Service.Request(this.GetUpLoadInfoPath, "GET", { code, fileName, desire });
return result;
}
}
export {
Service,
NvpTencentCosService
}

View File

@@ -1,43 +0,0 @@
import { Service } from '@/Service/Service';
/*****用户接口*****/
class NvpUserService {
private static GetUserInfoPath: string = '/User/GetUserInfo';
/*****用户基础信息*****/
static GetUserInfo() {
var result = Service.Request(this.GetUserInfoPath, "GET", "");
return result;
}
private static UpdateUserInfoPath: string = '/User/UpdateUserInfo';
/*****修改用户信息*****/
static UpdateUserInfo(par: string, smsCode: string, type: number) {
var result = Service.Request(this.UpdateUserInfoPath, "POST", { par, smsCode, type });
return result;
}
private static GetUserAccLogPath: string = '/User/GetUserAccLog';
/*****获取账户记录*****/
static GetUserAccLog(code: string, accType: string, page: number) {
var result = Service.Request(this.GetUserAccLogPath, "GET", { code, accType, page });
return result;
}
private static GetUserCommissionInfoPath: string = '/User/GetUserCommissionInfo';
/*****用户佣金账户*****/
static GetUserCommissionInfo() {
var result = Service.Request(this.GetUserCommissionInfoPath, "GET", "");
return result;
}
private static GetUserCommissionLogPath: string = '/User/GetUserCommissionLog';
/*****用户佣金记录*****/
static GetUserCommissionLog(code: string, accType: string, page: number) {
var result = Service.Request(this.GetUserCommissionLogPath, "GET", { code, accType, page });
return result;
}
}
export {
Service,
NvpUserService
}

View File

@@ -1,22 +0,0 @@
import { Service } from '@/Service/Service';
/*****用户接口*****/
class NvpWithService {
private static WithDrawPath: string = '/With/WithDraw';
/*****佣金提现*****/
static WithDraw(money: number, name: string, account: string) {
var result = Service.Request(this.WithDrawPath, "POST", { money, name, account });
return result;
}
private static GetUserWithLogPath: string = '/With/GetUserWithLog';
/*****用户提现记录*****/
static GetUserWithLog(page: number) {
var result = Service.Request(this.GetUserWithLogPath, "GET", { page });
return result;
}
}
export {
Service,
NvpWithService
}

View File

@@ -15,6 +15,16 @@ export class Service extends BaseConfig {
}
}
//获取静态图片地址
static GetIconImg(path : string) {
return path;
if (path.startsWith('http') || path.startsWith('https')) {
return path;
} else {
return `${this.payuploadUrl}${path}`;
}
}
//获取图片地址
static GetMateUrlByImg(path : string) {
if (path.startsWith('http') || path.startsWith('https')) {
@@ -175,6 +185,21 @@ export class Service extends BaseConfig {
})
}
static Confirm(msg : string, cb ?: any) {
uni.showModal({
title: '确认',
content: msg,
showCancel: true,
cancelText: '取消',
confirmText: '确定',
success: res => {
if (res.confirm) {
cb && cb();
}
}
})
}
static LoadIng(text : any) : void {
uni.showLoading({
title: text,
@@ -240,6 +265,13 @@ export class Service extends BaseConfig {
// 时间戳处理
static formatDate(time : any, type : number) : string {
if(time==0 && type==6 ){
return "00:00"
}
if(time==0 && type==5 ){
return "00"
}
const date = new Date(time);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从0开始所以加1并用0填充
@@ -258,11 +290,14 @@ export class Service extends BaseConfig {
return `${hours}:${minutes}`;
} else if (type == 4) {
return `${year}${month}${day}`;
}else if (type == 5) {
return `${seconds}`;
}
else {
return `${hours}:${minutes}`;
}
}
/*****节流*****/
@@ -301,7 +336,7 @@ export class Service extends BaseConfig {
static uploadH5(path, dic, callback) {
console.log(this.payuploadUrl,'xxx')
uni.uploadFile({
url: this.payuploadUrl+'/Upload/UploadFile',
url: this.payuploadUrl+'/api/Upload/UploadImg',
method: "POST",
header: {
'Authorization': 'Bearer ' + Service.GetUserToken(),

View File

@@ -0,0 +1,49 @@
import { Service } from '@/Service/Service';
/*****项目接口*****/
class PlanService {
private static GetPlanListPath : string = '/api/Plan/GetPlanList';
/*****计划列表接口*****/
static GetPlanList( planType:string, page:string) {
var result = Service.Request(this.GetPlanListPath, "GET", { planType, page});
return result;
}
private static GetPlanInfoPath : string = '/api/Plan/GetPlanInfo';
/*****计划详情接口*****/
static GetPlanInfo(planId:string) {
var result = Service.Request(this.GetPlanInfoPath, "GET", {planId});
return result;
}
private static AddPlanPath : string = '/api/Plan/AddPlan';
/*****添加修改计划接口*****/
static AddPlan(data:any) {
var result = Service.Request(this.AddPlanPath, "POST", data);
return result;
}
private static DeletePlanPath : string = '/api/Plan/DeletePlan';
/*****删除计划接口*****/
static DeletePlan(planId:string) {
var result = Service.Request(this.DeletePlanPath, "POST", { planId});
return result;
}
private static GetNoSelectStudentPath : string = '/api/Plan/GetNoSelectStudent';
/*****查询项目分组未选择的学生接口*****/
static GetNoSelectStudent(planId:string) {
var result = Service.Request(this.GetNoSelectStudentPath, "GET", { planId});
return result;
}
}
export {
Service,
PlanService
}

View File

@@ -0,0 +1,14 @@
import { Service } from '@/Service/Service';
/*****登录接口*****/
class loginService {
private static LoginPath : string = '/api/Login/Login';
/*****登录接口*****/
static Login(code : string) {
var result = Service.Request(this.LoginPath, "POST", { code });
return result;
}
}
export {
Service,
loginService
}

View File

@@ -0,0 +1,47 @@
import { Service } from '@/Service/Service';
/*****登录接口*****/
class studentService {
private static GetStudentListPath : string = '/api/Student/GetStudentList';
/*****学生列表不分页接口*****/
static GetStudentList() {
var result = Service.Request(this.GetStudentListPath, "GET", { });
return result;
}
private static GetStudentInfoPath : string = '/api/Student/GetStudentInfo';
/*****学生详情接口*****/
static GetStudentInfo(studentId : string) {
var result = Service.Request(this.GetStudentInfoPath, "GET", { studentId });
return result;
}
private static AddPath : string = '/api/Student/Add';
/*****添加学生接口*****/
static Add(data:any) {
var result = Service.Request(this.AddPath, "POST", data);
return result;
}
private static DeletePath : string = '/api/Student/Delete';
/*****删除学生接口*****/
static Delete(studentId:string) {
var result = Service.Request(this.DeletePath, "POST", {studentId });
return result;
}
private static GetStudentListPagePath : string = '/api/Student/GetStudentListPage';
/*****学生列表分页接口*****/
static GetStudentListPage(page : string) {
var result = Service.Request(this.GetStudentListPagePath, "GET", { page });
return result;
}
}
export {
Service,
studentService
}

View File

@@ -0,0 +1,14 @@
import { Service } from '@/Service/Service';
/*****上传接口*****/
class loginService {
private static UploadImgPath : string = '/api/Upload/UploadImg';
/*****上传接口*****/
static UploadImg(file : string,path:string) {
var result = Service.Request(this.UploadImgPath, "POST", { file, path});
return result;
}
}
export {
Service,
loginService
}

View File

@@ -0,0 +1,37 @@
import { Service } from '@/Service/Service';
/*****用户接口*****/
class userService {
private static GetUserInfoPath : string = '/api/User/GetUserInfo';
/*****获取用户信息接口*****/
static GetUserInfo() {
var result = Service.Request(this.GetUserInfoPath, "GET", {});
return result;
}
private static IsCheakUserVerifyPath : string = '/api/User/IsCheakUserVerify';
/*****是否可以使用接口*****/
static IsCheakUserVerify() {
var result = Service.Request(this.IsCheakUserVerifyPath, "GET", {});
return result;
}
private static UpdateUserInfoPath : string = '/api/User/UpdateUserInfo';
/*****修改用户信息接口*****/
static UpdateUserInfo(name:string,phone:string,headImg:string) {
var result = Service.Request(this.UpdateUserInfoPath, "POST", {name,phone,headImg});
return result;
}
// private static GetStudentInfoPath : string = '/api/Student/GetStudentInfo';
// /*****根据学生id查详情接口*****/
// static GetStudentInfo(studentId:string) {
// var result = Service.Request(this.GetStudentInfoPath, "GET", {studentId});
// return result;
// }
}
export {
Service,
userService
}

View File

@@ -0,0 +1,19 @@
export interface Project {
id: string;
name: string;
createdAt: number;
}
export interface StudentRecord {
id: string;
projectId: string;
name: string;
time: number;
createdAt: number;
}
export interface TimingState {
isRunning: boolean;
startTime: number;
elapsedTime: number;
}

View File

@@ -0,0 +1,69 @@
import type { Project, StudentRecord } from '../Domain/Project';
const STORAGE_KEYS = {
PROJECTS: 'swimming_projects',
RECORDS: 'swimming_records',
};
export class StorageManager {
static async getProjects(): Promise<Project[]> {
try {
const data = uni.getStorageSync(STORAGE_KEYS.PROJECTS);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
static async saveProjects(projects: Project[]): Promise<void> {
uni.setStorageSync(STORAGE_KEYS.PROJECTS, JSON.stringify(projects));
}
static async addProject(project: Project): Promise<void> {
const projects = await this.getProjects();
projects.unshift(project);
await this.saveProjects(projects);
}
static async deleteProject(projectId: string): Promise<void> {
const projects = await this.getProjects();
const filtered = projects.filter(p => p.id !== projectId);
await this.saveProjects(filtered);
}
static async getRecordsByProject(projectId: string): Promise<StudentRecord[]> {
try {
const data = uni.getStorageSync(STORAGE_KEYS.RECORDS);
const records: StudentRecord[] = data ? JSON.parse(data) : [];
return records.filter(r => r.projectId === projectId);
} catch {
return [];
}
}
static async saveRecords(records: StudentRecord[]): Promise<void> {
uni.setStorageSync(STORAGE_KEYS.RECORDS, JSON.stringify(records));
}
static async addRecord(record: StudentRecord): Promise<void> {
try {
const data = uni.getStorageSync(STORAGE_KEYS.RECORDS);
const records: StudentRecord[] = data ? JSON.parse(data) : [];
records.unshift(record);
await this.saveRecords(records);
} catch {
const records: StudentRecord[] = [record];
await this.saveRecords(records);
}
}
static async deleteRecord(recordId: string): Promise<void> {
try {
const data = uni.getStorageSync(STORAGE_KEYS.RECORDS);
const records: StudentRecord[] = data ? JSON.parse(data) : [];
const filtered = records.filter(r => r.id !== recordId);
await this.saveRecords(filtered);
} catch {
}
}
}

View File

@@ -0,0 +1,22 @@
export function formatTime(ms: number): string {
if (ms < 0) ms = 0;
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 10);
const hh = hours.toString().padStart(2, '0');
const mm = minutes.toString().padStart(2, '0');
const ss = seconds.toString().padStart(2, '0');
const mmm = milliseconds.toString().padStart(2, '0');
if (hours > 0) {
return `${hh}:${mm}:${ss}.${mmm}`;
}
return `${mm}:${ss}.${mmm}`;
}
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}

View File

@@ -1,5 +1,5 @@
{
"name" : "科讯代购",
"name" : "智能秒表",
"appid" : "__UNI__06C2D6A",
"description" : "",
"versionName" : "1.0.8",
@@ -112,7 +112,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"appid" : "wx6a9cfc961962b249",
"setting" : {
"urlCheck" : false
},

View File

@@ -8,46 +8,154 @@
}
},
"pages": [ //pages数组中第一项表示应用启动页参考https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "门店运营",
"navigationBarBackgroundColor": "#36394D",
"navigationStyle": "custom",
"backgroundColor": "#F8F8F8"
"navigationBarTitleText": "智能秒表"
}
},
{
"path" : "pages/index/user",
"style" :
{
"navigationBarTitleText" : "我的"
"path": "pages/index/user",
"style": {
"navigationBarTitleText": "我的"
}
}
],
"subPackages": [{
"root": "pages/userFunc",
"pages": [{
"path": "setCourse",
"style": {
"navigationBarTitleText": "新增项目"
}
},
{
"path": "swiming",
"style": {
"navigationBarTitleText": "游泳项目"
}
},
{
"path": "segmentation",
"style": {
"navigationBarTitleText": "分段"
}
},
{
"path": "student",
"style": {
"navigationBarTitleText": "学员管理"
}
},
{
"path": "analyze",
"style": {
"navigationBarTitleText": "数据分析"
}
},
{
"path": "dataAnalyze",
"style": {
"navigationBarTitleText": "数据分析图"
}
},
{
"path": "set",
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "projectList",
"style": {
"navigationBarTitleText": "项目列表"
}
},
{
"path": "project",
"style": {
"navigationBarTitleText": "包干"
}
},
{
"path": "plan",
"style": {
"navigationBarTitleText": "训练计划"
}
},
{
"path": "hunyang",
"style": {
"navigationBarTitleText": "混氧"
}
},
{
"path": "addHunyang",
"style": {
"navigationBarTitleText": "创建项目"
}
}
]
}, {
"root": "pages/dataAnalyze",
"pages": [
{
"path": "baoganAnalyze",
"style": {
"navigationBarTitleText": "包干数据"
}
},
{
"path": "timingAnalze",
"style": {
"navigationBarTitleText": "计时数据"
}
},
{
"path": "Curve",
"style": {
"navigationBarTitleText": "曲线走势图"
}
},
{
"path": "paragraphAnalyze",
"style": {
"navigationBarTitleText": "分段数据"
}
},
{
"path": "grades",
"style": {
"navigationBarTitleText": "成绩排名"
}
}
]
}],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "v派商家",
"navigationBarBackgroundColor": "#F84F28",
"backgroundColor": "#F8F8F8"
"navigationBarBackgroundColor": "#fff",
"backgroundColor": "#000"
},
"tabBar": {
"color": "#000",
"selectedColor": "#000",
"backgroundColor": "#FFFFFF",
"list": [ {
"pagePath": "pages/index/index",
"iconPath": "static/tab/01.png",
"selectedIconPath": "static/tab/02.png",
"text": "主页"
}, {
"pagePath": "pages/index/user",
"iconPath": "static/tab/01.png",
"selectedIconPath": "static/tab/02.png",
"text": "我的"
}
"list": [{
"pagePath": "pages/index/index",
"iconPath": "static/tab/01.png",
"selectedIconPath": "static/tab/02.png",
"text": "项目"
},
{
"pagePath": "pages/index/user",
"iconPath": "static/tab/03.png",
"selectedIconPath": "static/tab/04.png",
"text": "我的"
}
]
}
}

View File

@@ -0,0 +1,698 @@
<template>
<view class="curve-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">曲线走势图</text>
<text class="subtitle">学员成绩趋势变化分析</text>
</view>
</view>
<!-- ==================== 项目选择区域 ==================== -->
<view class="project-select-section">
<view class="section-header">
<text class="section-title">选择项目</text>
</view>
<!-- 项目选择器 -->
<picker mode="selector" :range="projectOptions" range-key="name" :value="selectedProjectIndex"
@change="handleProjectChange">
<view class="picker-wrapper">
<text class="picker-text">{{ selectedProjectName }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</picker>
</view>
<!-- ==================== 学生选择区域 ==================== -->
<!-- 仅在选择项目后显示 -->
<view class="student-select-section" v-if="selectedProjectId && projectStudents.length > 0">
<view class="section-header">
<text class="section-title">选择学员最多3人</text>
<text class="select-count">已选{{ selectedStudentIds.length }}/3</text>
</view>
<!-- 学生列表 -->
<view class="student-list">
<view v-for="student in projectStudents" :key="student.id"
:class="['student-item', { 'selected': selectedStudentIds.includes(student.id) }]"
@click="toggleStudent(student.id)">
<!-- 学生头像使用首字母作为头像 -->
<view class="student-avatar">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
<!-- 学生姓名 -->
<text class="student-name">{{ student.name }}</text>
<!-- 选择状态图标 -->
<view class="check-icon" v-if="selectedStudentIds.includes(student.id)">
<u-icon name="checkmark" size="16" color="#fff"></u-icon>
</view>
</view>
</view>
</view>
<!-- ==================== 图表展示区域 ==================== -->
<!-- 仅在选择了至少一名学生后显示 -->
<view class="chart-section" v-if="selectedStudentIds.length > 0">
<!-- 数据统计卡片 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">选中项目</text>
<text class="stat-value">{{ selectedProjectName }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">对比学员</text>
<text class="stat-value">{{ selectedStudentIds.length }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">训练次数</text>
<text class="stat-value">{{ maxTrainingCount }}</text>
</view>
</view>
<!-- 折线图卡片 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">成绩趋势对比</text>
<text class="chart-desc">历史训练成绩变化曲线</text>
</view>
<!-- 折线图容器 -->
<scroll-view class="chart-scroll" scroll-x :show-scrollbar="false">
<view class="chart-box" :style="{ width: chartWidth + 'rpx' }">
<qiun-data-charts type="line" :opts="lineOpts" :chartData="lineChartData" :ontouch="true" />
</view>
</scroll-view>
</view>
<!-- 图例说明 -->
<view class="legend-section">
<text class="legend-title">图例说明</text>
<view class="legend-list">
<view v-for="(student, index) in selectedStudents" :key="student.id" class="legend-item">
<view class="legend-color" :style="{ backgroundColor: chartColors[index] }"></view>
<text class="legend-name">{{ student.name }}</text>
<text class="legend-desc">{{ getStudentBestTime(student.id) }}s 最佳</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed, watch } from 'vue'
// ==================== 常量定义 ====================
// 最大选择学生数量限制
const MAX_SELECTED_STUDENTS = 3
// 图表颜色配置最多支持3种颜色
const chartColors = ['#52c41a', '#1890ff', '#faad14']
// ==================== 响应式数据 - 项目相关 ====================
// 项目列表数据
const projectList = ref([
{ id: '1', name: '100米自由泳' },
{ id: '2', name: '50米自由泳' },
{ id: '3', name: '200米由泳' },
{ id: '4', name: '100米蛙泳' }
])
// 项目选择器选项(用于 picker 组件)
const projectOptions = ref(projectList.value)
// 当前选中的项目索引(用于 picker 组件的显示)
const selectedProjectIndex = ref(-1)
// 当前选中的项目 ID
const selectedProjectId = ref('')
// 当前选中的项目名称(用于显示)
const selectedProjectName = computed(() => {
if (selectedProjectIndex.value === -1) {
return '请选择项目'
}
return projectList.value[selectedProjectIndex.value]?.name || '请选择项目'
})
// ==================== TypeScript 接口定义 ====================
/**
* 学生接口
* 定义学生的基本信息
*/
interface Student {
id : string
name : string
}
/**
* 学生训练记录接口
* 定义学生某次训练的详细记录
*/
interface StudentTrainingRecord {
studentId : string
studentName : string
time : number
recordDate : string
recordFullDate : string
round : number
}
// ==================== 响应式数据 - 学生相关 ====================
// 当前项目的学生列表
const projectStudents = ref<Student[]>([])
// 已选中的学生 ID 列表
const selectedStudentIds = ref<string[]>([])
// 已选中的学生详细信息列表((计算属性)
const selectedStudents = computed(() => {
return projectStudents.value.filter(student =>
selectedStudentIds.value.includes(student.id)
)
})
// ==================== 响应式数据 - 图表相关 ====================
// 计算图表宽度(根据数据点数量动态调整)
const chartWidth = computed(() => {
const categoryCount = lineChartData.value.categories.length
if (categoryCount <= 6) return 700
// 每个数据点分配 100rpx最小 700rpx
return Math.max(700, categoryCount * 100)
})
// 折线图配置选项
const lineOpts = ref({
color: chartColors,
padding: [15, 10, 0, 15],
legend: {
show: true,
position: 'bottom',
itemGap: 30
},
xAxis: {
disableGrid: true,
rotateLabel: true,
scrollShow: true,
scrollAlign: 'left'
},
yAxis: {
data: [{
min: 0,
format: (val : number) => val.toFixed(1) + 's'
}]
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow',
linearType: 'none',
onGap: false
}
}
})
// 折线图数据
const lineChartData = ref({
categories: [] as string[],
series: [] as any[]
})
// 最大训练次数(计算属性)
const maxTrainingCount = computed(() => {
const allRecords = mockTrainingRecords[selectedProjectId.value] || []
const studentCounts = new Map<string, number>()
allRecords.forEach(record => {
studentCounts.set(record.studentId, (studentCounts.get(record.studentId) || 0) + 1)
})
return Math.max(0, ...Array.from(studentCounts.values()))
})
// ==================== 模拟数据 ====================
// 模拟的学生数据(按项目分组)
const mockProjectStudents : Record<string, Student[]> = {
'1': [
{ id: 's1', name: '张小明' },
{ id: 's2', name: '李小红' },
{ id: 's3', name: '王小明' },
{ id: 's4', name: '赵小芳' },
{ id: 's5', name: '陈小刚' }
],
'2': [
{ id: 's6', name: '刘小华' },
{ id: 's7', name: '张小丽' },
{ id: 's8', name: '杨小龙' }
],
'3': [
{ id: 's9', name: '黄小东' },
{ id: 's10', name: '吴小西' },
{ id: 's11', name: '周小南' },
{ id: 's12', name: '徐小北' }
],
'4': [
{ id: 's13', name: '马小兵' },
{ id: 's14', name: '朱小红' }
]
}
// 模拟的训练记录数据(按项目分组)
const mockTrainingRecords : Record<string, StudentTrainingRecord[]> = {
'1': [
{ studentId: 's1', studentName: '张小明', time: 28.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's1', studentName: '张小明', time: 27.2, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's1', studentName: '张小明', time: 26.5, recordDate: '03-16', recordFullDate: '2026-03-16', round: 1 },
{ studentId: 's1', studentName: '张小明', time: 25.8, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 },
{ studentId: 's1', studentName: '张小明', time: 25.2, recordDate: '03-24', recordFullDate: '2026-03-24', round: 1 },
{ studentId: 's2', studentName: '李小红', time: 32.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's2', studentName: '李小红', time: 31.2, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's2', studentName: '李小红', time: 30.5, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 },
{ studentId: 's2', studentName: '李小红', time: 29.8, recordDate: '03-24', recordFullDate: '2026-03-24', round: 1 },
{ studentId: 's3', studentName: '王小明', time: 25.8, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's3', studentName: '王小明', time: 24.5, recordDate: '03-16', recordFullDate: '2026-03-16', round: 1 },
{ studentId: 's3', studentName: '王小明', time: 23.8, recordDate: '03-24', recordFullDate: '2026-03-24', round: 1 },
{ studentId: 's4', studentName: '赵小芳', time: 33.5, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's4', studentName: '赵小芳', time: 32.2, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 },
{ studentId: 's5', studentName: '陈小刚', time: 35.5, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 }
],
'2': [
{ studentId: 's6', studentName: '刘小华', time: 24.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's6', studentName: '刘小华', time: 23.8, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's6', studentName: '刘小华', time: 23.2, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 },
{ studentId: 's7', studentName: '张小丽', time: 26.8, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's7', studentName: '张小丽', time: 26.2, recordDate: '03-16', recordFullDate: '2026-03-16', round: 1 },
{ studentId: 's8', studentName: '杨小龙', time: 25.5, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's8', studentName: '杨小龙', time: 24.8, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 }
],
'3': [
{ studentId: 's9', studentName: '黄小东', time: 125.8, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's9', studentName: '黄小东', time: 124.5, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's10', studentName: '吴小西', time: 128.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's10', studentName: '吴小西', time: 127.2, recordDate: '03-16', recordFullDate: '2026-03-16', round: 1 },
{ studentId: 's10', studentName: '吴小西', time: 126.8, recordDate: '03-19', recordFullDate: '2026-03-19', round: 1 }
],
'4': [
{ studentId: 's13', studentName: '马小兵', time: 82.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 },
{ studentId: 's13', studentName: '马小兵', time: 81.8, recordDate: '03-14', recordFullDate: '2026-03-14', round: 1 },
{ studentId: 's14', studentName: '朱小红', time: 88.5, recordDate: '03-12', recordFullDate: '2026-03-12', round: 1 }
]
}
// ==================== 生命周期钩子 ====================
onLoad(() => {
loadProjectData()
})
onShow(() => {
// TODO: 如果需要在页面显示时刷新数据,可以在这里添加逻辑
})
// ==================== 监听器 ====================
// 监听选中学生 ID 列表的变化
// 使用 getter 函数返回数组副本,这样可以确保监听器触发
watch(() => [...selectedStudentIds.value], (newIds, oldIds) => {
console.log('selectedStudentIds changed:', newIds)
console.log('oldIds:', oldIds)
updateChartData()
})
// ==================== 业务逻辑方法 ====================
const loadProjectData = () => {
// TODO: 调用后端 API 获取项目列表
}
const handleProjectChange = (e : any) => {
const index = e.detail.value
selectedProjectIndex.value = index
const selectedProject = projectList.value[index]
if (selectedProject) {
selectedProjectId.value = selectedProject.id
loadProjectStudents(selectedProject.id)
}
selectedStudentIds.value = []
}
const loadProjectStudents = (projectId : string) => {
if (mockProjectStudents[projectId]) {
projectStudents.value = mockProjectStudents[projectId]
} else {
projectStudents.value = []
}
}
const toggleStudent = (studentId : string) => {
const index = selectedStudentIds.value.indexOf(studentId)
if (index !== -1) {
selectedStudentIds.value.splice(index, 1)
} else {
if (selectedStudentIds.value.length >= MAX_SELECTED_STUDENTS) {
Service.Msg(`最多只能选择 ${MAX_SELECTED_STUDENTS} 名学员`)
return
}
selectedStudentIds.value.push(studentId)
}
}
const getStudentBestTime = (studentId : string) : number => {
const records = mockTrainingRecords[selectedProjectId.value] || []
const studentRecords = records.filter(r => r.studentId === studentId)
if (studentRecords.length === 0) return 0
return Math.min(...studentRecords.map(r => r.time))
}
const updateChartData = () => {
if (selectedStudentIds.value.length === 0 || !selectedProjectId.value) {
lineChartData.value.series = []
lineChartData.value.categories = []
return
}
const projectRecords = mockTrainingRecords[selectedProjectId.value] || []
const allDates = new Set<string>()
projectRecords.forEach(record => {
allDates.add(record.recordFullDate)
})
const sortedDates = Array.from(allDates).sort()
lineChartData.value.categories = sortedDates.map(date => {
const parts = date.split('-')
return `${parts[1]}-${parts[2]}`
})
const seriesData : any[] = []
selectedStudentIds.value.forEach((studentId, index) => {
const studentRecords = projectRecords.filter(r => r.studentId === studentId)
studentRecords.sort((a, b) => a.recordFullDate.localeCompare(b.recordFullDate))
const timeMap = new Map<string, number>()
studentRecords.forEach(record => {
timeMap.set(record.recordFullDate, record.time)
})
const timeData = sortedDates.map(date => timeMap.get(date) || null)
const studentName = projectStudents.value.find(s => s.id === studentId)?.name || '未知学员'
seriesData.push({
name: studentName,
data: timeData,
color: chartColors[index]
})
})
console.log();
lineChartData.value.series = seriesData
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.curve-container {
min-height: 100vh;
padding-bottom: 40rpx;
}
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
margin-bottom: 20rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.select-count {
font-size: 24rpx;
color: #52c41a;
}
}
.project-select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #f0f0f0;
border-color: #52c41a;
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
.student-select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.section-header {
margin-bottom: 24rpx;
}
.student-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
.student-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 10rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 2rpx solid transparent;
transition: all 0.3s ease;
position: relative;
&:active {
transform: scale(0.95);
}
&.selected {
background-color: #f0f9eb;
border-color: #52c41a;
}
.student-avatar {
width: 60rpx;
height: 60rpx;
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.3);
.avatar-text {
font-size: 24rpx;
font-weight: 700;
color: #fff;
}
}
.student-name {
font-size: 24rpx;
color: #333;
text-align: center;
}
.check-icon {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 28rpx;
height: 28rpx;
background-color: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
.chart-section {
margin: 0 20rpx;
.stats-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.stat-item {
text-align: center;
.stat-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #52c41a;
}
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
}
}
.chart-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.chart-header {
margin-bottom: 20rpx;
padding-left: 12rpx;
border-left: 6rpx solid #52c41a;
.chart-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 6rpx;
}
.chart-desc {
font-size: 24rpx;
color: #999;
}
}
.chart-scroll {
width: 100%;
height: 500rpx;
background-color: #fafafa;
border-radius: 12rpx;
overflow: hidden;
white-space: nowrap;
.chart-box {
height: 100%;
display: inline-block;
}
}
}
.legend-section {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.legend-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.legend-list {
display: flex;
flex-direction: column;
gap: 16rpx;
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
.legend-color {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
flex-shrink: 0;
}
.legend-name {
font-size: 26rpx;
color: #333;
font-weight: 600;
}
.legend-desc {
font-size: 24rpx;
color: #999;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,802 @@
<template>
<view class="baogan-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">包干数据</text>
<text class="subtitle">包干项目训练记录统计</text>
</view>
</view>
<!-- 项目选择区域 -->
<view class="select-section">
<view class="select-label">
<text class="label-text">选择项目</text>
</view>
<picker mode="selector" :range="projectOptions" range-key="name" :value="selectedProjectIndex"
@change="handleProjectChange">
<view class="picker-wrapper">
<text class="picker-text">{{ selectedProjectName }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</picker>
</view>
<!-- 日历组件 -->
<view class="calendar-wrapper" v-if="selectedProjectId">
<uni-calendar :insert="true" :range='true' :lunar="false" :show-month="true" :selected="selectedDates"
@monthSwitch="handleMonthSwitch" @change="handleDateChange">
</uni-calendar>
</view>
<!-- 学生选择区域 -->
<view class="select-section" v-if="selectedProjectId && selectedDate">
<view class="select-label">
<text class="label-text">选择学生</text>
</view>
<view class="picker-wrapper" @click="showStudentPicker = true">
<text class="picker-text">{{ selectedStudentDisplay }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</view>
<!-- 学生多选选择器 -->
<view class="modal-overlay" v-if="showStudentPicker" @click="closeStudentPicker"></view>
<view class="student-picker-modal" v-if="showStudentPicker">
<view class="picker-header">
<text class="header-title">选择学生</text>
<view class="header-actions">
<text class="action-btn" @click="selectAllStudents">全选</text>
<text class="action-btn primary" @click="confirmStudentSelection">确定</text>
</view>
</view>
<scroll-view class="student-list" scroll-y>
<view v-for="(student, index) in studentList" :key="student.id" class="student-item"
:class="{ 'selected': selectedStudentIndexes.includes(index) }"
@click="toggleStudentSelection(index)">
<view class="item-checkbox">
<view class="checkbox-inner" :class="{ 'checked': selectedStudentIndexes.includes(index) }">
<u-icon v-if="selectedStudentIndexes.includes(index)" name="checkmark" size="14"
color="#fff"></u-icon>
</view>
</view>
<view class="item-avatar">
<view class="avatar-circle"
:class="{ 'male': student.gender === '男', 'female': student.gender === '女' }">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
</view>
<view class="item-info">
<text class="item-name">{{ student.name }}</text>
<view class="item-meta">
<text class="gender-badge"
:class="{ 'male': student.gender === '男', 'female': student.gender === '女' }">
{{ student.gender }}
</text>
<text class="age-text">{{ student.age }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 数据表格 -->
<view class="table-section" v-if="selectedDate && selectedStudentIndexes.length > 0">
<sl-table :columns="columns" :tableData="tableData" @cell-click="handleCellClick">
<template #empty>
<view class="empty-container">
<view class="empty-icon">
<u-icon name="file-text" size="80" color="#ccc"></u-icon>
</view>
<text class="empty-text">该日期暂无训练数据</text>
</view>
</template>
</sl-table>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed } from 'vue'
interface Project {
id : string
name : string
}
interface Student {
id : string
name : string
gender : string
age : number
}
interface TableDataItem {
date : string
name : string
projectName : string
plan : string
completion : number
detail : string
}
interface StudentRecord {
student : Student
date : string
segments : TableDataItem[]
}
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const selectedDate = ref('')
const columns = ref([
{
label: '日期',
prop: 'date',
width: '100px'
},
{
label: '姓名',
prop: 'name',
width: '100px'
},
{
label: '项目名称',
prop: 'projectName',
width: '120px'
},
{
label: '包干计划',
prop: 'plan',
width: '100px'
},
{
label: '完成比例',
prop: 'completion',
width: '100px'
},
{
label: '详细数据',
prop: 'detail',
width: '120px'
}
])
const tableData = ref<TableDataItem[]>([])
const projectList = ref<Project[]>([
{ id: '1', name: '100米自由泳' },
{ id: '2', name: '200米自由泳' },
{ id: '3', name: '400米自由泳' },
{ id: '4', name: '100米蛙泳' }
])
const projectOptions = ref(projectList.value)
const selectedProjectIndex = ref(-1)
const selectedProjectId = ref('')
const selectedProjectName = computed(() => {
if (selectedProjectIndex.value === -1) {
return '请选择项目'
}
return projectList.value[selectedProjectIndex.value]?.name || '请选择项目'
})
const selectedDates = ref([
{ date: '2026-03-28', info: '训练' },
{ date: '2026-03-29', info: '训练' },
{ date: '2026-03-30', info: '训练' }
])
const studentList = ref<Student[]>([])
const selectedStudentIndexes = ref<number[]>([])
const selectedStudentIds = ref<string[]>([])
const showStudentPicker = ref(false)
const selectedStudentDisplay = computed(() => {
if (selectedStudentIndexes.value.length === 0) {
return '请选择学生'
} else {
const names = selectedStudentIndexes.value.map(index => studentList.value[index].name)
return names.length > 2 ? `${names.slice(0, 2).join(', ')}${names.length}` : names.join(', ')
}
})
const averageCompletion = computed(() => {
if (tableData.value.length === 0) return 0
const total = tableData.value.reduce((sum, item) => sum + item.completion, 0)
return Math.round(total / tableData.value.length)
})
const mockData : Record<string, Record<string, TableDataItem[]>> = {
'1': {
'2026-03-28': [
{ name: '张小明', projectName: '100米自由泳', plan: '2000米', completion: 95, detail: '1900/2000' },
{ name: '李小红', projectName: '100米自由泳', plan: '2000米', completion: 92, detail: '1840/2000' },
{ name: '王小明', projectName: '100米自由泳', plan: '2000米', completion: 90, detail: '1800/2000' }
],
'2026-03-29': [
{ name: '张小明', projectName: '100米自由泳', plan: '2000米', completion: 90, detail: '1800/2000' },
{ name: '李小红', projectName: '100米自由泳', plan: '2000米', completion: 95, detail: '1900/2000' },
{ name: '王小明', projectName: '100米自由泳', plan: '2000米', completion: 88, detail: '1760/2000' }
]
},
'2': {
'2026-03-28': [
{ name: '张小明', projectName: '200米自由泳', plan: '1500米', completion: 88, detail: '1320/1500' },
{ name: '李小红', projectName: '200米自由泳', plan: '1500米', completion: 92, detail: '1380/1500' },
{ name: '王小明', projectName: '200米自由泳', plan: '1500米', completion: 85, detail: '1275/1500' }
],
'2026-03-29': [
{ name: '张小明', projectName: '200米自由泳', plan: '1500米', completion: 90, detail: '1350/1500' },
{ name: '李小红', projectName: '200米自由泳', plan: '1500米', completion: 88, detail: '1320/1500' }
]
},
'3': {
'2026-03-28': [
{ name: '张小明', projectName: '400米自由泳', plan: '1200米', completion: 85, detail: '1020/1200' },
{ name: '李小红', projectName: '400米自由泳', plan: '1200米', completion: 90, detail: '1080/1200' }
]
},
'4': {
'2026-03-28': [
{ name: '赵小芳', projectName: '100米蛙泳', plan: '1800米', completion: 88, detail: '1584/1800' },
{ name: '陈小' + '刚', projectName: '100米蛙泳', plan: '1800米', completion: 95, detail: '1710/1800' }
]
}
}
const mockStudents : Record<string, Student[]> = {
'1': [
{ id: 's1', name: '张小明', gender: '男', age: 12 },
{ id: 's2', name: '李小红', gender: '女', age: 11 },
{ id: 's3', name: '王小明', gender: '男', age: 13 }
],
'2': [
{ id: 's1', name: '张小明', gender: '男', age: 12 },
{ id: 's2', name: '李小红', gender: '女', age: 11 },
{ id: 's3', name: '王小明', gender: '男', age: 13 }
],
'3': [
{ id: 's1', name: '张小明', gender: '男', age: 12 },
{ id: 's2', name: '李小红', gender: '女', age: 11 }
],
'4': [
{ id: 's4', name: '赵小芳', gender: '女', age: 10 },
{ id: 's5', name: '陈小' + '刚', gender: '男', age: 14 }
]
}
onLoad(() => {
loadData()
})
onShow(() => {
})
const loadData = () => {
}
const handleCellClick = (event : any) => {
console.log('单元格点击事件:', event)
}
const handleProjectChange = (e : any) => {
const index = e.detail.value
selectedProjectIndex.value = index
const selectedProject = projectList.value[index]
if (selectedProject) {
selectedProjectId.value = selectedProject.id
selectedDate.value = ''
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
loadProjectStudents(selectedProject.id)
}
}
const handleMonthSwitch = (e : any) => {
currentYear.value = e.year
currentMonth.value = e.month
loadData()
selectedDate.value = ''
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
}
const handleDateChange = (e : any) => {
console.log(e);
const date = e.fulldate
selectedDate.value = date
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
}
const loadProjectStudents = (projectId : string) => {
const students = mockStudents[projectId] || []
studentList.value = students
console.log(`项目 ${projectId} 的学生列表加载完成,共 ${students.length} 位学生`)
}
const selectAllStudents = () => {
selectedStudentIndexes.value = studentList.value.map((_, index) => index)
}
const handleStudentChange = (e : any) => {
console.log('学生选择变化:', e)
}
const toggleStudentSelection = (index : number) => {
const idx = selectedStudentIndexes.value.indexOf(index)
if (idx > -1) {
selectedStudentIndexes.value.splice(idx, 1)
} else {
selectedStudentIndexes.value.push(index)
}
}
const confirmStudentSelection = () => {
selectedStudentIds.value = selectedStudentIndexes.value.map(index => studentList.value[index].id)
showStudentPicker.value = false
filterAndLoadData()
}
const closeStudentPicker = () => {
showStudentPicker.value = false
}
const filterAndLoadData = () => {
if (!selectedDate.value || selectedStudentIndexes.value.length === 0) {
tableData.value = []
return
}
const projectData = mockData[selectedProjectId.value]
if (!projectData) {
tableData.value = []
return
}
const dateData = projectData[selectedDate.value]
if (!dateData) {
tableData.value = []
return
}
const selectedStudentNames = selectedStudentIndexes.value.map(index => studentList.value[index].name)
tableData.value = dateData.filter(item => selectedStudentNames.includes(item.name))
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.baogan-container {
min-height: 100vh;
padding-bottom: 40rpx;
}
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
margin-bottom: 20rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
.select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
.select-label {
margin-bottom: 16rpx;
.label-text {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
}
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #f0f0f0;
border-color: #faad14;
transform: scale(0.98);
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
.calendar-wrapper {
background-color: #fff;
margin: 0 20rpx 20rpx;
padding: 20rpx 0;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.student-picker-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 70vh;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-radius: 32rpx 32rpx 0 0;
z-index: 999;
animation: slideUp 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 36rpx 32rpx 28rpx;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
.header-title {
font-size: 36rpx;
font-weight: 700;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
.action-btn {
font-size: 26rpx;
color: #666;
padding: 14rpx 28rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #f5f5f5 0%, #f0f0f0 100%);
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
&.primary {
color: #fff;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 50%, #d48806 100%);
box-shadow: 0 4rpx 16rpx rgba(250, 173, 20, 0.3);
}
&:active {
transform: scale(0.92);
}
}
}
}
.student-list {
flex: 1;
overflow-y: auto;
padding: 0 20rpx 40rpx;
.student-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: linear-gradient(135deg, #fff 0%, #fafbfc 100%);
border-radius: 20rpx;
margin-top: 16rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06), 0 0 0 1rpx rgba(0, 0, 0, 0.04) inset;
position: relative;
overflow: hidden;
&:active {
transform: scale(0.98);
}
.item-checkbox {
flex-shrink: 0;
position: relative;
z-index: 1;
.checkbox-inner {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
border: 2rpx solid #d9d9d9;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.checked {
border-color: #faad14;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
box-shadow: 0 2rpx 8rpx rgba(250, 173, 20, 0.3);
}
}
}
.item-avatar {
flex-shrink: 0;
position: relative;
z-index: 1;
.avatar-circle {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&.male {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
}
&.female {
background: linear-gradient(135deg, #fa8c16 0%, #ffa940 50%, #d46b08 100%);
}
&::before {
content: '';
position: absolute;
top: 8rpx;
right: 8rpx;
width: 12rpx;
height: 12rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.avatar-text {
font-size: 34rpx;
color: #fff;
font-weight: 700;
letter-spacing: 2rpx;
}
}
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
position: relative;
z-index: 1;
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
letter-spacing: 1rpx;
}
.item-meta {
display: flex;
align-items: center;
gap: 12rpx;
.gender-badge {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 10rpx;
font-weight: 500;
&.male {
color: #1890ff;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(64, 169, 255, 0.05) 100%);
}
&.female {
color: #fa8c16;
background: linear-gradient(135deg, rgba(250, 140, 22, 0.1) 0%, rgba(255, 169, 64, 0.05) 100%);
}
}
.age-text {
font-size: 22rpx;
color: #999;
}
}
}
}
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
backdrop-filter: blur(10rpx);
z-index: 998;
animation: fadeIn 0.3s ease;
}
.date-summary {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.summary-item {
text-align: center;
.summary-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.summary-value {
font-size: 32rpx;
font-weight: 700;
color: #faad14;
}
}
.summary-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
}
}
.table-section {
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
.empty-icon {
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
}
.hint-state {
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 80rpx 40rpx;
text-align: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.hint-icon {
margin-bottom: 24rpx;
}
.hint-text {
font-size: 28rpx;
color: #999;
}
}
::v-deep .sl-table {
width: 100%;
}
::v-deep .sl-table__header {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%) !important;
}
::v-deep .sl-table__header__cell {
color: #fff !important;
font-weight: 700;
font-size: 28rpx;
}
::v-deep .sl-table__body__cell {
font-size: 26rpx;
color: #333;
padding: 24rpx 16rpx;
}
::v-deep .sl-table__body__row:nth-child(even) {
background-color: #fafafa !important;
}
::v-deep .sl-table__body__row:nth-child(odd) {
background-color: #fff !important;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,529 @@
<template>
<view class="grades-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">成绩排名</text>
<text class="subtitle">学生最佳成绩排名</text>
</view>
</view>
<!-- ==================== 项目选择区域 ==================== -->
<view class="project-select-section">
<view class="section-header">
<text class="section-title">选择项目</text>
</view>
<!-- 项目选择器 -->
<picker mode="selector" :range="projectOptions" range-key="name" :value="selectedProjectIndex"
@change="handleProjectChange">
<view class="picker-wrapper">
<text class="picker-text">{{ selectedProjectName }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</picker>
</view>
<!-- ==================== 排名列表区域 ==================== -->
<!-- 仅在选择了项目且有数据时显示 -->
<view class="ranking-section" v-if="selectedProjectId && gradeList.length > 0">
<!-- 排名统计卡片 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">对比项目</text>
<text class="stat-value">{{ selectedProjectName }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">参与人数</text>
<text class="stat-value">{{ gradeList.length }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">最高速度</text>
<text class="stat-value">{{ maxSpeed }}m/s</text>
</view>
</view>
<!-- 排名列表 -->
<view class="ranking-list">
<view v-for="(item, index) in gradeList" :key="item.id"
:class="['ranking-item', { 'top-three': index < 3 }]" @click="handleRankingClick(item)">
<!-- 排名徽章 -->
<view class="rank-badge" :class="`rank-${index + 1}`">
<text class="rank-number">{{ index + 1 }}</text>
</view>
<!-- 学生信息 -->
<view class="student-info">
<text class="student-name">{{ item.name }}</text>
<text class="student-speed">最快速度{{ item.bestSpeed }}m/s</text>
</view>
<!-- 成绩详情 -->
<view class="speed-detail">
<text class="detail-text">用时{{ item.bestTime }}s</text>
<text class="detail-text">日期{{ item.recordDate }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed } from 'vue'
// ==================== 响应式数据 - 项目相关 ====================
// 项目列表数据
const projectList = ref([
{ id: '1', name: '100米自由泳' },
{ id: '2', name: '50米自由泳' },
{ id: '3', name: '200米自由泳' },
{ id: '4', name: '100米蛙泳' }
])
// 项目选择器选项(用于 picker 组件)
const projectOptions = ref(projectList.value)
// 当前选中的项目索引(用于 picker 组件的显示)
const selectedProjectIndex = ref(-1)
// 当前选中的项目 ID
const selectedProjectId = ref('')
// 当前选中的项目名称(用于显示)
const selectedProjectName = computed(() => {
if (selectedProjectIndex.value === -1) {
return '请选择项目'
}
return projectList.value[selectedProjectIndex.value]?.name || '请选择项目'
})
// ==================== TypeScript 接口定义 ====================
/**
* 学生成绩接口
* 定义学生的成绩和排名信息
*/
interface StudentGrade {
id : string // 学生 ID
name : string // 学生姓名
bestSpeed : number // 最快速度m/s
bestTime : number // 最快用时(秒)
recordDate : string // 记录日期
studentId : string // 学生 ID用于数据关联
}
// ==================== 响应式数据 - 排名相关 ====================
// 排名列表数据
const gradeList = ref<StudentGrade[]>([])
// 最高速度(计算属性)
const maxSpeed = computed(() => {
if (gradeList.value.length === 0) return 0
return Math.max(...gradeList.value.map(item => item.bestSpeed)).toFixed(2)
})
// ==================== 模拟数据 ====================
/**
* 模拟的学生成绩数据(按项目分组)
* 键为项目 ID值为该项目的学生成绩列表
*/
const mockStudentGrades : Record<string, StudentGrade[]> = {
'1': [
{ id: 'g1', name: '王小明', bestSpeed: 3.89, bestTime: 25.72, recordDate: '03-24', studentId: 's3' },
{ id: 'g2', name: '张小明', bestSpeed: 3.97, bestTime: 25.18, recordDate: '03-24', studentId: 's1' },
{ id: 'g3', name: '赵小芳', bestSpeed: 3.03, bestTime: 32.96, recordDate: '03-14', studentId: 's4' },
{ id: 'g4', name: '李小红', bestSpeed: 3.38, bestTime: 29.58, recordDate: '03-24', studentId: 's2' },
{ id: 'g5', name: '陈小刚', bestSpeed: 2.82, bestTime: 35.46, recordDate: '03-19', studentId: 's5' }
],
'2': [
{ id: 'g6', name: '杨小龙', bestSpeed: 2.05, bestTime: 24.39, recordDate: '03-19', studentId: 's8' },
{ id: 'g7', name: '刘小华', bestSpeed: 2.11, bestTime: 23.70, recordDate: '03-19', studentId: 's6' },
{ id: 'g8', name: '张小丽', bestSpeed: 1.91, bestTime: 26.18, recordDate: '03-16', studentId: 's7' }
],
'3': [
{ id: 'g9', name: '吴小西', bestSpeed: 1.59, bestTime: 125.79, recordDate: '03-16', studentId: 's10' },
{ id: 'g10', name: '黄小东', bestSpeed: 1.61, bestTime: 124.23, recordDate: '03-14', studentId: 's9' },
{ id: 'g11', name: '周小南', bestSpeed: 0.00, bestTime: 0, recordDate: '-', studentId: 's11' },
{ id: 'g12', name: '徐小北', bestSpeed: 0.00, bestTime: 0, recordDate: '-', studentId: 's12' }
],
'4': [
{ id: 'g13', name: '马小兵', bestSpeed: 1.22, bestTime: 81.97, recordDate: '03-14', studentId: 's13' },
{ id: 'g14', name: '朱小红', bestSpeed: 1.13, bestTime: 88.47, recordDate: '03-12', studentId: 's14' }
]
}
// ==================== 生命周期钩子 ====================
/**
* 页面加载时触发
* 在页面初始化时执行,只执行一次
*/
onLoad(() => {
// 初始化数据
loadProjectData()
})
/**
* 页面显示时触发
* 每次页面从后台切换到前台时执行
*/
onShow(() => {
// TODO: 如果需要在页面显示时刷新数据,可以在这里添加逻辑
})
// ==================== 业务逻辑方法 - 项目相关 ====================
/**
* 加载项目数据
* 从后端 API 获取项目列表
*/
const loadProjectData = () => {
// TODO: 调用后端 API 获取项目列表
// 示例代码:
// Service.Request('/api/projects', 'GET').then(res => {
// projectList.value = res.data
// projectOptions.value = res.data
// })
}
/**
* 处理项目选择变化事件
* 当用户选择项目时触发
* @param e picker 选择事件对象,包含 detail.value 属性表示选中的索引
*/
const handleProjectChange = (e : any) => {
// 获取选中的项目索引
const index = e.detail.value
// 更新选中项目的索引
selectedProjectIndex.value = index
// 获取选中的项目
const selectedProject = projectList.value[index]
if (selectedProject) {
// 更新选中项目的 ID
selectedProjectId.value = selectedProject.id
// 加载该项目的排名数据
loadProjectGrades(selectedProject.id)
}
// TODO: 实际项目中应该调用 API 获取该项目的排名数据
}
/**
* 加载项目排名数据
* 根据项目 ID 获取该项目的学生成绩排名列表
* @param projectId 项目 ID
*/
const loadProjectGrades = (projectId : string) => {
// 从模拟数据中获取排名列表
if (mockStudentGrades[projectId]) {
// 按最快速度从高到低排序
gradeList.value = [...mockStudentGrades[projectId]].sort((a, b) => b.bestSpeed - a.bestSpeed)
} else {
gradeList.value = []
}
}
// ==================== 业务逻辑方法 - 排名相关 ====================
/**
* 处理排名项点击事件
* 点击某排名项查看详情
* @param item 排名项数据
*/
const handleRankingClick = (item : StudentGrade) => {
console.log('点击了排名项:', item)
// TODO: 跳转到学生详情页面
// Service.GoPage('/pages/student/detail', { studentId: item.studentId })
}
</script>
<style lang="scss" scoped>
// 页面背景色设置
page {
background-color: #f5f5f5;
}
// 页面容器
.grades-container {
min-height: 100vh;
padding-bottom: 40rpx;
}
/* ==================== 页面标题区域 ==================== */
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
margin-bottom: 20rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* ==================== 项目选择区域 ==================== */
.project-select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
// background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx 0 0 20rpx;
}
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #fff0f5;
border-color: #1890ff;
transform: scale(0.98);
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
/* ==================== 排名列表区域 ==================== */
.ranking-section {
margin: 0 20rpx;
// 统计卡片
.stats-card {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
// background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx 0 0 20rpx;
}
.stat-item {
text-align: center;
.stat-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #1890ff;
}
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
}
}
// 排名列表容器
.ranking-list {
// 排名项
.ranking-item {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
// background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 20rpx 0 0 20rpx;
}
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(235, 47, 150, 0.15);
&::before {
opacity: 1;
}
}
// 排名徽章
.rank-badge {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: #f0f0f0;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.rank-number {
font-size: 24rpx;
font-weight: 700;
color: #999;
}
// 前三名特殊颜色
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffec3d 100%);
box-shadow: 0 2rpx 8.6rpx rgba(255, 215, 0, 0.4);
.rank-number {
color: #fff;
font-size: 28rpx;
}
}
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #d9d9d9 100%);
box-shadow: 0 2rpx 8rpx rgba(192, 192, 192, 0.4);
.rank-number {
color: #fff;
}
}
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #e6963d 100%);
box-shadow: 0 2rpx 8rpx rgba(205, 127, 50, 0.4);
.rank-number {
color: #fff;
}
}
}
// 学生头像
.student-avatar {
width: 70rpx;
height: 70rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(235, 47, 150, 0.3);
.avatar-text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
}
// 学生信息
.student-info {
flex: 1;
min-width: 0;
.student-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.student-speed {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
}
// 成绩详情
.speed-detail {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
.detail-text {
font-size: 24rpx;
color: #999;
background-color: #f5f5f5;
padding: 6rpx 12rpx;
border-radius: 6rpx;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,754 @@
<template>
<view class="paragraph-container">
<view class="header-section">
<view class="header-title">
<text class="title">分段数据</text>
<text class="subtitle">学生分段训练成绩分析</text>
</view>
</view>
<view class="select-section">
<view class="select-label">
<text class="label-text">选择项目</text>
</view>
<picker mode="selector" :range="projectOptions" range-key="name" :value="selectedProjectIndex"
@change="handleProjectChange">
<view class="picker-wrapper">
<text class="picker-text">{{ selectedProjectName }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</picker>
</view>
<view class="calendar-wrapper" v-if="selectedProjectId">
<uni-calendar
:insert="true"
:lunar="false"
:show-month="true"
:selected="selectedDates"
@monthSwitch="handleMonthSwitch"
@change="handleDateChange">
</uni-calendar>
</view>
<view class="select-section" v-if="selectedProjectId && selectedDate">
<view class="select-label">
<text class="label-text">选择学生</text>
</view>
<view class="picker-wrapper" @click="showStudentSelect = true">
<text class="picker-text">{{ selectedStudentDisplay }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</view>
<view class="modal-overlay" v-if="showStudentSelect" @click="closeStudentSelect"></view>
<view class="student-picker-modal" v-if="showStudentSelect">
<view class="picker-header">
<text class="header-title">选择学生</text>
<view class="header-actions">
<text class="action-btn" @click="selectAllStudents">全选</text>
<text class="action-btn primary" @click="confirmStudentSelect">确定</text>
</view>
</view>
<scroll-view class="student-list" scroll-y>
<view v-for="(student, index) in studentList" :key="student.id" class="student-item"
:class="{ 'selected': selectedStudentIndexes.includes(index) }"
@click="toggleStudentSelect(index)">
<view class="item-checkbox">
<view class="checkbox-inner" :class="{ 'checked': selectedStudentIndexes.includes(index) }">
<u-icon v-if="selectedStudentIndexes.includes(index)" name="checkmark" size="14"
color="#fff"></u-icon>
</view>
</view>
<view class="item-avatar">
<view class="avatar-circle"
:class="{ 'male': student.gender === '男', 'female': student.gender === '女' }">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
</view>
<view class="item-info">
<text class="item-name">{{ student.name }}</text>
<view class="item-meta">
<text class="gender-badge"
:class="{ 'male': student.gender === '男', 'female': student.gender === '女' }">
{{ student.gender }}
</text>
<text class="age-text">{{ student.age }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="table-section" v-if="selectedDate && selectedStudentIndexes.length > 0 && tableData.length > 0">
<view class="table-wrapper-scroll">
<view class="table-header">
<view class="header-row">
<view class="header-cell" style="width: 100px;">日期</view>
<view class="header-cell" style="width: 100px;">姓名</view>
<view class="header-cell" style="width: 120px;">项目</view>
<view class="header-cell" style="width: 100px;">20</view>
<view class="header-cell" style="width: 100px;">70</view>
<view class="header-cell" style="width: 100px;">120</view>
<view class="header-cell" style="width: 100px;">170</view>
</view>
</view>
<view class="table-body">
<view v-for="(row, rowIndex) in tableData" :key="rowIndex" class="body-row" :class="{ 'even': rowIndex % 2 === 0, 'odd': rowIndex % 2 === 1 }">
<view class="body-cell" style="width: 100px;">{{ row.date }}</view>
<view class="body-cell" style="width: 100px;">{{ row.name }}</view>
<view class="body-cell" style="width: 120px;">{{ row.projectName }}</view>
<view class="body-cell" style="width: 100px;">{{ row.segment20 }}</view>
<view class="body-cell" style="width: 100px;">{{ row.segment70 }}</view>
<view class="body-cell" style="width: 100px;">{{ row.segment120 }}</view>
<view class="body-cell" style="width: 100px;">{{ row.segment170 }}</view>
</view>
</view>
</view>
</view>
<view class="empty-container" v-if="selectedDate && selectedStudentIndexes.length > 0 && tableData.length === 0">
<view class="empty-icon">
<u-icon name="file-text" size="80" color="#ccc"></u-icon>
</view>
<text class="empty-text">该日期暂无分段数据</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed } from 'vue'
interface Project {
id: string
name: string
}
interface Student {
id: string
name: string
gender: string
age: number
}
interface TableDataItem {
date: string
name: string
projectName: string
segment20: string
segment70: string
segment120: string
segment170: string
}
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const selectedDate = ref('')
const projectList = ref<Project[]>([
{ id: '1', name: '100米自由泳' },
{ id: '2', name: '200米自由泳' },
{ id: '3', name: '50米蛙泳' },
{ id: '4', name: '100米蝶泳' }
])
const projectOptions = ref(projectList.value)
const selectedProjectIndex = ref(-1)
const selectedProjectId = ref('')
const selectedProjectName = computed(() => {
if (selectedProjectIndex.value === -1) {
return '请选择项目'
}
return projectList.value[selectedProjectIndex.value]?.name || '请选择项目'
})
const selectedDates = ref([
{ date: '2026-03-12', info: '分段' },
{ date: '2026-03-14', info: '分段' },
{ date: '2026-03-16', info: '分段' },
{ date: '2026-03-19', info: '分段' },
{ date: '2026-03-24', info: '分段' }
])
const studentList = ref<Student[]>([])
const selectedStudentIndexes = ref<number[]>([])
const selectedStudentIds = ref<string[]>([])
const showStudentSelect = ref(false)
const selectedStudentDisplay = computed(() => {
if (selectedStudentIndexes.value.length === 0) {
return '请选择学生'
} else {
const names = selectedStudentIndexes.value.map(index => studentList.value[index].name)
return names.length > 2 ? `${names.slice(0, 2).join(', ')}${names.length}` : names.join(', ')
}
})
const tableData = ref<TableDataItem[]>([])
const mockData: Record<string, Record<string, any[]>> = {
'1': {
'2026-03-12': [
{ name: '王小明', projectName: '100米自由泳', segment20: 0.037, segment70: 0.128, segment120: 0.219, segment170: 0.311 },
{ name: '张小红', projectName: '100米自由泳', segment20: 0.042, segment70: 0.145, segment120: 0.248, segment170: 0.352 },
{ name: '李小龙', projectName: '100米自由泳', segment20: 0.032, segment70: 0.112, segment120: 0.192, segment170: 0.272 }
],
'2026-03-14': [
{ name: '王小明', projectName: '100米自由泳', segment20: 0.035, segment70: 0.122, segment120: 0.209, segment170: 0.296 },
{ name: '张小红', projectName: '100米自由泳', segment20: 0.040, segment70: 0.138, segment120: 0.236, segment170: 0.334 }
]
},
'2': {
'2026-03-16': [
{ name: '赵小芳', projectName: '200米自由泳', segment20: 0.038, segment70: 0.133, segment120: 0.228, segment170: 0.323 }
]
},
'3': {
'2026-03-19': [
{ name: '陈小刚', projectName: '50米蛙泳', segment20: 0.044, segment70: 0.154, segment120: 0.264, segment170: 0.374 }
]
},
'4': {
'2026-03-24': [
{ name: '周小丽', projectName: '100米蝶泳', segment20: 0.041, segment70: 0.143, segment120: 0.245, segment170: 0.347 }
]
}
}
const mockStudents: Record<string, Student[]> = {
'1': [
{ id: 's1', name: '王小明', gender: '男', age: 12 },
{ id: 's2', name: '张小红', gender: '女', age: 11 },
{ id: 's3', name: '李小龙', gender: '男', age: 13 }
],
'2': [
{ id: 's4', name: '赵小芳', gender: '女', age: 10 }
],
'3': [
{ id: 's5', name: '陈小刚', gender: '男', age: 14 }
],
'4': [
{ id: 's6', name: '周小丽', gender: '女', age: 12 }
]
}
onLoad(() => {
loadData()
})
onShow(() => {
})
const loadData = () => {
}
const handleProjectChange = (e: any) => {
const index = e.detail.value
selectedProjectIndex.value = index
const selectedProject = projectList.value[index]
if (selectedProject) {
selectedProjectId.value = selectedProject.id
selectedDate.value = ''
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
loadProjectStudents(selectedProject.id)
}
}
const handleMonthSwitch = (e: any) => {
currentYear.value = e.year
currentMonth.value = e.month
loadData()
selectedDate.value = ''
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
}
const handleDateChange = (e: any) => {
const date = e.fulldate
selectedDate.value = date
selectedStudentIndexes.value = []
selectedStudentIds.value = []
tableData.value = []
}
const loadProjectStudents = (projectId: string) => {
const students = mockStudents[projectId] || []
studentList.value = students
console.log(`项目 ${projectId} 的学生列表加载完成,共 ${students.length} 位学生`)
}
const selectAllStudents = () => {
selectedStudentIndexes.value = studentList.value.map((_, index) => index)
}
const toggleStudentSelect = (index: number) => {
const idx = selectedStudentIndexes.value.indexOf(index)
if (idx > -1) {
selectedStudentIndexes.value.splice(idx, 1)
} else {
selectedStudentIndexes.value.push(index)
}
}
const confirmStudentSelect = () => {
selectedStudentIds.value = selectedStudentIndexes.value.map(index => studentList.value[index].id)
showStudentSelect.value = false
filterAndLoadData()
}
const closeStudentSelect = () => {
showStudentSelect.value = false
}
const filterAndLoadData = () => {
if (!selectedDate.value || selectedStudentIndexes.value.length === 0) {
tableData.value = []
return
}
const projectData = mockData[selectedProjectId.value]
if (!projectData) {
tableData.value = []
return
}
const dateData = projectData[selectedDate.value]
if (!dateData) {
tableData.value = []
return
}
const selectedStudentNames = selectedStudentIndexes.value.map(index => studentList.value[index].name)
const filteredData = dateData.filter(item => selectedStudentNames.includes(item.name))
tableData.value = filteredData.map(item => ({
date: selectedDate.value,
name: item.name,
projectName: item.projectName,
segment20: item.segment20.toFixed(3),
segment70: item.segment70.toFixed(3),
segment120: item.segment120.toFixed(3),
segment170: item.segment170.toFixed(3)
}))
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.paragraph-container {
min-height: 100vh;
padding-bottom: 40rpx;
}
/* ==================== 页面标题区域) ==================== */
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
margin-bottom: 20rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* ==================== 选择区域通用样式 ==================== */
.select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
.select-label {
margin-bottom: 16rpx;
.label-text {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
}
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #f0f0f0;
border-color: #1890ff;
transform: scale(0.98);
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
/* ==================== 日历组件包装 ==================== */
.calendar-wrapper {
background-color: #fff;
margin: 0 20rpx 20rpx;
padding: 20rpx 0;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ==================== 学生选择器弹窗 ==================== */
.student-picker-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 70vh;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-radius: 32rpx 32rpx 0 0;
z-index: 999;
animation: slideUp 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 36rpx 32rpx 28rpx;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
.header-title {
font-size: 36rpx;
font-weight: 700;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
.action-btn {
font-size: 26rpx;
color: #666;
padding: 14rpx 28rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #f5f5f5 0%, #f0f0f0 100%);
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
&.primary {
color: #fff;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
box-shadow: 0 4rpx 16rpx rgba(24, 144, 255, 0.3);
}
&:active {
transform: scale(0.92);
}
}
}
}
.student-list {
flex: 1;
overflow-y: auto;
padding: 0 20rpx 40rpx;
.student-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: linear-gradient(135deg, #fff 0%, #fafbfc 100%);
border-radius: 20rpx;
margin-top: 16rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
&:active {
transform: scale(0.98);
}
.item-checkbox {
flex-shrink: 0;
position: relative;
z-index: 1;
.checkbox-inner {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
border: 2rpx solid #d9d9d9;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.checked {
border-color: #1890ff;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
box-shadow: 0 2rpx 8rpx rgba(24, 144, 255, 0.3);
}
}
}
.item-avatar {
flex-shrink: 0;
position: relative;
z-index: 1;
.avatar-circle {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&.male {
background: linear-gradient(135deg, #1890ffkt 0%, #40a9ff 50%, #096dd9 100%);
}
&.female {
background: linear-gradient(135deg, #fa8c16 0%, #ffa940 50%, #d46b08 100%);
}
&::before {
content: '';
position: absolute;
top: 8rpx;
right: 8rpx;
width: 12rpx;
height: 12rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.avatar-text {
font-size: 34rpx;
color: #fff;
font-weight: 700;
letter-spacing: 2rpx;
}
}
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
position: relative;
z-index: 1;
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
letter-spacing: 1rpx;
}
.item-meta {
display: flex;
align-items: center;
gap: 12rpx;
.gender-badge {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 10rpx;
font-weight: 500;
&.male {
color: #1890ff;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(64, 169, 255, 0.05) 100%);
}
&.female {
color: #fa8c16;
background: linear-gradient(135deg, rgba(250, 140, 22, 0.1) 0%, rgba(255, 169, 64, 0.05) 100%);
}
}
.age-text {
font-size: 22rpx;
color: #999;
}
}
}
}
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
backdrop-filter: blur(10rpx);
z-index: 998;
animation: fadeIn 0.3s ease;
}
/* ==================== 表格区域 ==================== */
.table-section {
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.table-wrapper-scroll {
width: 100%;
overflow-x: auto;
}
.table-header {
// background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
.header-row {
display: flex;
.header-cell {
min-width: 100px;
padding: 24rpx 16rpx;
font-size: 28rpx;
font-weight: 700;
// color: #fff;
text-align: center;
border-right: 1rpx solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
&:last-child {
border-right: none;
}
}
}
}
.table-body {
.body-row {
display: flex;
transition: all 0.3s ease;
&.even {
background-color: #fafafa;
}
&.odd {
background-color: #fff;
}
&:active {
background-color: #e6f7ff;
}
.body-cell {
min-width: 100px;
padding: 24rpx 16rpx;
font-size: 26rpx;
color: #333;
text-align: center;
border-right: 1rpx solid #f0f0f0;
flex-shrink: 0;
&:last-child {
border-right: none;
}
}
}
}
}
/* ==================== 空状态容器 ==================== */
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 80rpx 40rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.empty-icon {
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,463 @@
<template>
<view class="timing-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">计时数据</text>
<text class="subtitle">计时项目训练记录统计</text>
</view>
</view>
<!-- 日历组件 -->
<view class="calendar-wrapper">
<uni-calendar
:insert="true"
:lunar="false"
:show-month="true"
:selected="selectedDates"
@monthSwitch="handleMonthSwitch"
@change="handleDateChange">
</uni-calendar>
</view>
<!-- 选中日期数据统计 -->
<!-- 仅在选择了日期时显示统计信息 -->
<view class="date-summary" v-if="selectedDate">
<view class="summary-item">
<text class="summary-label">选中日期</text>
<text class="summary-value">{{ selectedDate }}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">训练人数</text>
<text class="summary-value">{{ tableData.length }}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">平均速度</text>
<text class="summary-value">{{ averageSpeed }}m/s</text>
</view>
</view>
<!-- 数据表格 -->
<!-- 仅在选择了日期时显示表格 -->
<view class="table-section" v-if="selectedDate">
<sl-table
:columns="columns"
:tableData="tableData"
@cell-click="handleCellClick">
<!-- 空数据插槽 -->
<template #empty>
<view class="empty-container">
<view class="empty-icon">
<u-icon name="file-text" size="80" color="#ccc"></u-icon>
</view>
<text class="empty-text">该日期暂无训练数据</text>
</view>
</template>
</sl-table>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed } from 'vue'
// ==================== 响应式数据定义 ====================
// 当前年份,用于月份切换时记录当前年份
const currentYear = ref(new Date().getFullYear())
// 当前月份1-12用于月份切换时记录当前月份
const currentMonth = ref(new Date().getMonth() + 1)
// 当前选中的日期,格式为 'YYYY-MM-DD'
// 空字符串表示未选择日期
const selectedDate = ref('')
// ==================== 表格配置 ====================
// 表格列定义
// label: 列标题
// prop: 数据项中对应的字段名
// width: 列宽度
const columns = ref([
{
label: '姓名',
prop: 'name',
width: '100px'
},
{
label: '项目名称',
prop: 'projectName',
width: '150px'
},
{
label: '最大速度',
prop: 'maxSpeed',
width: '120px'
},
{
label: '计时数据',
prop: 'timingData',
width: '150px'
}
])
// ==================== TypeScript 接口定义 ====================
// 表格数据项接口定义
// 描述一条计时记录的数据结构
interface TableDataItem {
name: string // 学员姓名
projectName: string // 项目名称100米自由泳
maxSpeed: number // 最大速度单位m/s
timingData: string // 计时数据1.23, 1.25, 1.24
}
// ==================== 响应式数据 - 表格数据 ====================
// 表格数据列表
// 存储当前选中日期的所有计时记录
// 初始状态为空数组,等待用户选择日期后加载数据
const tableData = ref<TableDataItem[]>([])
// ==================== 响应式数据 - 日历标记 ====================
// 日历打点数据
// 用于在日历上标记有训练记录的日期
// date: 日期字符串,格式为 'YYYY-MM-DD'
// info: 显示在日期上的标记信息
const selectedDates = ref([
{ date: '2026-03-12', info: '训练' },
{ date: '2026-03-14', info: '训练' },
{ date: '2026-03-16', info: '训练' },
{ date: '2026-03-19', info: '训练' },
{ date: '2026-03-24', info: '训练' }
])
// ==================== 计算属性 ====================
// 平均速度计算属性
// 计算当前选中日期所有学员的平均最大速度
// 如果没有数据则返回 0
const averageSpeed = computed(() => {
// 如果没有数据,返回 0
if (tableData.value.length === 0) return 0
// 计算所有学员最大速度的总和
const total = tableData.value.reduce((sum, item) => sum + item.maxSpeed, 0)
// 计算平均值,保留两位小数
return (total / tableData.value.length).toFixed(2)
})
// ======================= 模拟数据 ====================
// 模拟的训练数据
// 键为日期字符串,值为该日期的计时记录列表
// 在实际项目中,这里应该从后端 API 获取数据
const mockData: Record<string, TableDataItem[]> = {
'2026-03-12': [
{ name: '张小明', projectName: '50米自由泳', maxSpeed: 2.15, timingData: '25.35s, 25.42s, 25.28s' },
{ name: '李小红', projectName: '50米自由泳', maxSpeed: 1.98, timingData: '27.15s, 27.32s, 27.08s' },
{ name: '王小明', projectName: '50米自由泳', maxSpeed: 2.22, timingData: '24.68s, 24.75s, 24.62s' }
],
'2026-03-14': [
{ name: '张小明', projectName: '50米自由泳', maxSpeed: 2.18, timingData: '25.12s, 25.18s, 25.08s' },
{ name: '李小红', projectName: '50米自由泳', maxSpeed: 2.02, timingData: '26.85s, 26.92s, 26.78s' },
{ name: '赵小芳', projectName: '50米自由泳', maxSpeed: 1.92, timingData: '27.95s, 28.12s, 27.88s' },
{ name: '王小明', projectName: '50米自由泳', maxSpeed: 2.25, timingData: '24.52s, 24.58s, 24.48s' }
],
'2026-03-16': [
{ name: '张小明', projectName: '50米自由泳', maxSpeed: 2.12, timingData: '25.55s, 25.62s, 25.48s' },
{ name: '李小红', projectName: '50米自由泳', maxSpeed: 2.05, timingData: '26.72s, 26.85s, 26.68s' }
],
'2026-03-19': [
{ name: '张小明', projectName: '50米自由泳', maxSpeed: 2.20, timingData: '24.98s, 25.05s, 24.92s' },
{ name: '李小红', projectName: '50米自由泳', maxSpeed: 2.08, timingData: '26.45s, 26.52s, 26.38s' },
{ name: '赵小芳', projectName: '50米自由泳', maxSpeed: 1.95, timingData: '27.75s, 27.88s, 27.65s' },
{ name: '王小明', projectName: '50米自由泳', maxSpeed: 2.28, timingData: '24.35s, 24.42s, 24.28s' },
{ name: '陈小刚', projectName: '50米自由泳', maxSpeed: 1.88, timingData: '28.25s, 28.38s, 28.15s' }
],
'2026-03-24': [
{ name: '张小明', projectName: '50米自由泳', maxSpeed: 2.15, timingData: '25.30s, 25.38s, 25.25s' },
{ name: '李小红', projectName: '50米自由泳', maxSpeed: 2.10, timingData: '26.25s, 26.32s, 26.18s' },
{ name: '赵小芳', projectName: '50米自由泳', maxSpeed: 1.98, timingData: '27.65s, 27.78s, 27.55s' }
]
}
// ==================== 生命周期钩子 ====================
// 页面加载时触发
// 在页面初始化时执行,只执行一次
onLoad(() => {
// 加载当前月份的数据
loadData()
})
// 页面显示时触发
// 每次页面从后台切换到前台时执行
// 可用于刷新页面显示的数据
onShow(() => {
// TODO: 如果需要在页面显示时刷新数据,可以在这里添加逻辑
// 例如:调用接口获取最新数据
})
// ==================== 业务逻辑方法 ====================
/**
* 加载数据
* 调用后端 API 获取数据
* 当前为模拟数据,实际开发中应替换为真实 API 调用
*/
const loadData = () => {
// TODO: 调用后端 API 获取数据
// 示例代码:
// Service.Request('/api/timing/data', 'GET', {
// year: currentYear.value,
// month: currentMonth.value
// }).then(res => {
// // 处理返回的数据
// })
}
/**
* 处理表格单元格点击事件
* @param event 点击事件对象,包含行索引、列索引、单元格数据等信息
*/
const handleCellClick = (event: any) => {
// 在控制台输出点击事件信息,用于调试
console.log('表格单元格点击事件:', event)
// TODO: 根据业务需求处理单元格点击
// 例如:点击某行可以查看详细信息,点击某列可以进行排序等
}
/**
* 处理日历月份切换事件
* 当用户切换日历的月份时触发
* @param e 切换事件对象,包含 year 和 month 属性
*/
const handleMonthSwitch = (e: any) => {
// 更新当前年份
currentYear.value = e.year
// 更新当前月份
currentMonth.value = e.month
// 重新加载数据
// 获取切换后月份的训练记录和日历标记
loadData()
// 清空选中的日期
// 因为切换了月份,之前选择的日期可能不再显示
selectedDate.value = ''
// 清空表格数据
tableData.value = []
}
/**
* 处理日历日期选择事件
* 当用户点击日历上的某个日期时触发
* @param e 选择事件对象fulldate 属性包含完整的日期字符串
*/
const handleDateChange = (e: any) => {
// 获取选择的完整日期,格式为 'YYYY-MM-DD'
const date = e.fulldate
// 更新选中的日期
selectedDate.value = date
// 根据选择的日期加载对应的训练数据
// 检查模拟数据中是否存在该日期的数据
if (mockData[date]) {
// 如果存在,加载数据
tableData.value = mockData[date]
} else {
// 如果不存在,清空数据(表格将显示空状态)
tableData.value = []
}
// TODO: 实际项目中应该调用 API 获取该日期的计时数据
// 示例代码:
// Service.Request('/api/timing/detail', 'GET', {
// date: date
// }).then(res => {
// // 处理返回的数据并更新表格
// tableData.value = res.data
// })
}
</script>
<style lang="scss" scoped>
// 页面背景色设置
page {
background-color: #f5f5f5;
}
// 页面容器
.timing-container {
min-height: 100vh;
padding-bottom: 40rpx;
}
/* ==================== 页面标题区域 ==================== */
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
margin-bottom: 20rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* ==================== 日历组件包装 ==================== */
.calendar-wrapper {
background-color: #fff;
margin-bottom: 20rpx;
padding: 20rpx 0;
}
/* ==================== 日期数据统计卡片 ==================== */
.date-summary {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
// 统计项
.summary-item {
text-align: center;
// 标签文字
.summary-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
// 数值文字
.summary-value {
font-size: 32rpx;
font-weight: 700;
color: #1890ff;
}
}
// 分隔线
.summary-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
}
}
/* ==================== 表格区域 ==================== */
.table-section {
margin: 0 20rpx;
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ==================== 空状态容器 ==================== */
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
// 空状态图标
.empty-icon {
margin-bottom: 24rpx;
}
// 空状态文字
.empty-text {
font-size: 28rpx;
color: #999;
}
}
/* ==================== 提示状态容器 ==================== */
// 用于在用户未选择日期时显示提示
.hint-state {
margin: 0 20rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 80rpx 40rpx;
text-align: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
// 提示图标
.hint-icon {
margin-bottom: 24rpx;
}
// 提示文字
.hint-text {
font-size: 28rpx;
color: #999;
}
}
/* ==================== sl-table 样式覆盖 ==================== */
// 表格容器
::v-deep .sl-table {
width: 100%;
}
// 表头背景色 - 使用蓝色渐变(计时功能的主色调)
::v-deep .sl-table__header {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%) !important;
}
// 表头单元格样式
::v-deep .sl-table__header__cell {
color: #fff !important;
font-weight: 700;
font-size: 28rpx;
}
// 表格单元格样式
::v-deep .sl-table__body__cell {
font-size: 26rpx;
color: #333;
padding: 24rpx 16rpx;
}
// 偶数行背景色
::v-deep .sl-table__body__row:nth-child(even) {
background-color: #fafafa !important;
}
// 奇数行背景色
::v-deep .sl-table__body__row:nth-child(odd) {
background-color: #fff !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,558 @@
<template>
<view>
</view>
<view class="user-container">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-avatar">
<view class="avatar-circle" v-if="!userInfo.headImg">
<u-icon name="account" size="52" color="#fff"></u-icon>
</view>
<image v-else class="avatar-image" :src="Service.GetMateUrlByImg(userInfo.headImg)" mode="aspectFill"></image>
</view>
<view class="user-info">
<text class="user-nickname">{{ userInfo.name }}</text>
<view class="user-phone-wrapper">
<text class="user-phone">{{ userInfo.phone || '暂无手机号' }}</text>
</view>
</view>
<view @click="Service.GoPage('/pages/userFunc/set')" class="user-edit">
<u-icon name="edit-pen" size="20" color="rgba(255,255,255,0.8)"></u-icon>
</view>
</view>
<!-- 统计数据区域 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<view class="stat-info">
<text class="stat-value">{{ userInfo.projectCount || 0 }}</text>
<text class="stat-label">我的项目</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-info">
<text class="stat-value">{{ userInfo.studentCount || 0 }}</text>
<text class="stat-label">学员数</text>
</view>
</view>
</view>
</view>
<!-- 功能菜单区域 -->
<view class="menu-section">
<view class="menu-card">
<!-- 项目管理菜单项 -->
<view class="menu-item" @click="Service.GoPage('/pages/userFunc/projectList')">
<view class="menu-icon-bg project-icon-bg">
<u-icon name="list" size="28" color="#fff"></u-icon>
</view>
<view class="menu-content">
<view class="menu-header">
<text class="menu-title">项目管理</text>
<view class="menu-tag project-tag">
<text class="tag-text">常用</text>
</view>
</view>
<text class="menu-desc">管理训练项目和设置</text>
</view>
<view class="menu-arrow">
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<view class="menu-divider"></view>
<!-- 学员管理菜单项 -->
<view class="menu-item" @click="Service.GoPage('/pages/userFunc/student')">
<view class="menu-icon-bg academy-icon-bg">
<u-icon name="grid" size="28" color="#fff"></u-icon>
</view>
<view class="menu-content">
<view class="menu-header">
<text class="menu-title">学员管理</text>
<view class="menu-tag academy-tag">
<text class="tag-text">核心</text>
</view>
</view>
<text class="menu-desc">管理学员信息和课程</text>
</view>
<view class="menu-arrow">
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<view class="menu-divider"></view>
<!-- 数据分析菜单项 -->
<view class="menu-item" @click="Service.GoPage('/pages/userFunc/analyze')">
<view class="menu-icon-bg analysis-icon-bg">
<u-icon name="order" size="28" color="#fff"></u-icon>
</view>
<view class="menu-content">
<view class="menu-header">
<text class="menu-title">数据分析</text>
<view class="menu-tag analysis-tag">
<text class="tag-text">智能</text>
</view>
</view>
<text class="menu-desc">查看训练数据和统计</text>
</view>
<view class="menu-arrow">
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow,onLoad } from "@dcloudio/uni-app";
onLoad(() => {
import { onShow, onLoad } from "@dcloudio/uni-app"
import { reactive, ref } from "vue"
import { Service } from '@/Service/Service'
import { userService } from '@/Service/swimming/userService'
});
onShow(() => {
let userInfo = ref<any>({})
});
onLoad(() => {
loadUserInfo()
})
onShow(() => {
})
const loadUserInfo = () => {
userService.GetUserInfo().then((content) => {
if (content.code == 0) {
userInfo.value=content.data.userInfo
} else {
Service.Msg(content.msg)
}
})
}
/**
* 跳转到训练计划页面
* 点击"训练计划"菜单项时触发
* 功能说明:
* - 跳转到训练计划列表页面
* - 用户可以查看、添加、编辑训练计划
*/
const goToTrainingPlans = () => {
Service.GoPage('/pages/userFunc/plan')
}
// 跳转到设置
const goToSettings = () => {
Service.Msg('系统设置功能开发中')
}
// 跳转到关于
const goToAbout = () => {
Service.Msg('关于我们功能开发中')
}
</script>
<style lang="scss">
</style>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.user-container {
padding: 20rpx;
padding-top: 30rpx;
padding-bottom: 40rpx;
}
/* 用户信息卡片 */
.user-card {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 28rpx;
padding: 40rpx 30rpx;
margin-bottom: 24rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.25);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -40%;
right: -30%;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -20%;
width: 250rpx;
height: 250rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
}
.user-avatar {
position: relative;
z-index: 1;
.avatar-circle {
width: 130rpx;
height: 130rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 5rpx solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(20rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.avatar-image {
width: 130rpx;
height: 130rpx;
border-radius: 50%;
border: 5rpx solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(20rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.avatar-badge {
position: absolute;
bottom: 0;
right: 0;
width: 44rpx;
height: 44rpx;
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(82, 196, 26, 0.4);
}
}
.user-info {
flex: 1;
position: relative;
z-index: 1;
.user-nickname {
font-size: 38rpx;
font-weight: 700;
color: #fff;
margin-bottom: 14rpx;
display: block;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.user-phone-wrapper {
display: flex;
align-items: center;
gap: 8rpx;
.user-phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
}
}
.user-edit {
width: 60rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10rpx);
position: relative;
z-index: 1;
transition: all 0.3s ease;
&:active {
background: rgba(255, 255, 255, 0.35);
transform: scale(0.95);
}
}
/* 统计数据区域 */
.stats-section {
margin-bottom: 24rpx;
.stats-card {
background-color: #fff;
border-radius: 28rpx;
padding: 36rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.stat-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
cursor: pointer;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
}
.stat-icon-bg {
width: 68rpx;
height: 68rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.12);
&.project-stat {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
}
&.record-stat {
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
}
&.student-stat {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
}
}
.stat-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
}
.stat-value {
font-size: 42rpx;
font-weight: 700;
background: linear-gradient(135deg, #333 0%, #666 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.stat-label {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
.stat-divider {
width: 1rpx;
height: 80rpx;
background: linear-gradient(180deg, transparent 0%, #e8e8e8 50%, transparent 100%);
}
}
/* 功能菜单区域 */
.menu-section,
.settings-section {
margin-bottom: 24rpx;
.menu-card {
background-color: #fff;
border-radius: 28rpx;
padding: 8rpx 0;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 36rpx 32rpx;
transition: all 0.3s ease;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, rgba(24, 144, 255, 0.06) 0%, transparent 50%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
&:active {
&::before {
opacity: 1;
}
}
}
.menu-icon-bg {
width: 84rpx;
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.12);
transition: all 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
top: 6rpx;
right: 6rpx;
width: 18rpx;
height: 18rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
/* 项目管理图标背景色 - 蓝色渐变 */
&.project-icon-bg {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
}
/* 训练计划图标背景色 - 紫色渐变 */
&.plan-icon-bg {
background: linear-gradient(135deg, #722ed1 0%, #9254de 50%, #531dab 100%);
}
/* 学员管理图标背景色 - 绿色渐变 */
&.academy-icon-bg {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 50%, #389e0d 100%);
}
/* 数据分析图标背景色 - 橙色渐变 */
&.analysis-icon-bg {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 50%, #d48806 100%);
}
&.settings-icon-bg {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
}
&.about-icon-bg {
background: linear-gradient(135deg, #722ed1 0%, #9254de 50%, #531dab 100%);
}
}
.menu-content {
flex: 1;
}
.menu-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.menu-title {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.menu-tag {
padding: 4rpx 12rpx;
border-radius: 16rpx;
font-size: 20rpx;
font-weight: 500;
/* 项目管理标签样式 - 蓝色 */
&.project-tag {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.12) 0%, rgba(24, 144, 255, 0.08) 100%);
color: #1890ff;
}
/* 训练计划标签样式 - 紫色 */
&.plan-tag {
background: linear-gradient(135deg, rgba(114, 46, 209, 0.12) 0%, rgba(114, 46, 209, 0.08) 100%);
color: #722ed1;
}
/* 学员管理标签样式 - 绿色 */
&.academy-tag {
background: linear-gradient(135deg, rgba(82, 196, 26, 0.12) 0%, rgba(82, 196, 26, 0.08) 100%);
color: #52c41a;
}
/* 数据分析标签样式 - 橙色 */
&.analysis-tag {
background: linear-gradient(135deg, rgba(250, 173, 20, 0.12) 0%, rgba(250, 173, 20, 0.08) 100%);
color: #faad14;
}
.tag-text {
font-size: 20rpx;
font-weight: 500;
}
}
.menu-desc {
font-size: 24rpx;
color: #999;
line-height: 1.4;
}
.menu-arrow {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.menu-item:active {
.menu-icon-bg {
transform: scale(0.92) translateY(2rpx);
}
.menu-arrow {
background: rgba(24, 144, 255, 0.1);
transform: scale(1.1);
}
}
.menu-divider {
height: 1rpx;
background: linear-gradient(90deg, transparent 0%, #f0f0f0 15%, #f0f0f0 85%, transparent 100%);
margin: 0 32rpx;
}
}
</style>

View File

@@ -0,0 +1,577 @@
<template>
<view class="add-hunyang-container">
<!-- 表单区域 -->
<view class="form-section">
<!-- 项目名称 -->
<view class="form-card">
<view class="form-title">项目信息</view>
<view class="form-group">
<text class="form-label">项目名称</text>
<input class="form-input" v-model="projectName" placeholder="请输入项目名称"
placeholder-class="input-placeholder" />
</view>
<!-- 计划总时长 -->
<view class="total-time-section">
<view class="total-time-label">计划总时长</view>
<view class="total-time-value">{{ formatTotalTime(totalDuration) }}</view>
</view>
</view>
<!-- 计划列表 -->
<view class="form-card">
<view class="form-title">计划列表</view>
<!-- 计划项列表 -->
<view v-for="(item, index) in planList" :key="index" class="plan-item">
<view class="plan-item-header">
<text class="plan-item-title">计划 {{ index + 1 }}</text>
<view class="delete-plan-btn" @click="deletePlanItem(index)">
<u-icon name="trash" size="16" color="#fff"></u-icon>
</view>
</view>
<view class="plan-item-content">
<!-- 目标时间 - 分秒选择 -->
<view class="input-group">
<text class="input-label">目标时间</text>
<view class="time-selector">
<view class="time-input-wrapper">
<input class="time-input" v-model="item.targetSeconds" type="number"
placeholder="" />
<text class="time-unit"></text>
</view>
</view>
</view>
<!-- 休息时长 - 秒选择 -->
<view class="input-group">
<text class="input-label">休息时长</text>
<view class="rest-selector">
<input class="rest-input" v-model="item.restTime" type="number" placeholder="请输入休息时长" />
<text class="time-unit"></text>
</view>
</view>
<!-- 圈数 -->
<view class="input-group">
<text class="input-label">圈数</text>
<view class="lap-selector">
<input class="lap-input" v-model="item.lapCount" type="number" placeholder="请输入圈数" />
<text class="time-unit"></text>
</view>
</view>
</view>
</view>
<!-- 添加计划按钮 -->
<view class="add-plan-btn" @click="addPlanItem">
<u-icon name="plus-circle" size="20" color="#1890ff"></u-icon>
<text class="add-plan-text">添加计划</text>
</view>
</view>
</view>
<!-- 底部按钮区域 -->
<view class="bottom-actions">
<view class="action-buttons">
<button class="cancel-btn" @click="handleCancel">取消</button>
<button class="confirm-btn" @click="handleSave">保存</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Service } from '@/Service/Service'
import { studentService } from '@/Service/swimming/studentService'
import { PlanService } from '@/Service/swimming/PlanService'
import { onLoad, onShow } from '@dcloudio/uni-app'
// 计划项接口
interface PlanItem {
targetMinutes : string
targetSeconds : string
restTime : string
lapCount : string
}
// 项目名称
const projectName = ref('')
// 计划列表
const planList = ref<PlanItem[]>([])
let planId = ref('')
onLoad((data : any) => {
planId.value = data.id
if(planId.value ){
getPlanInfo()
}
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
projectName.value=res.data.plan.name
JSON.parse(res.data.plan.project).map((item:any)=>{
planList.value.push({
targetMinutes:'',
targetSeconds : item.target,
restTime : item.rest,
lapCount : item.circle
})
})
} else {
// 显示错误信息
Service.Msg(res.msg)
}
})
}
// 计算计划总时长(秒)
const totalDuration = computed(() => {
let total = 0
planList.value.forEach(item => {
const targetMinutes = parseInt(item.targetMinutes) || 0
const targetSeconds = parseInt(item.targetSeconds) || 0
const targetTime = targetMinutes * 60 + targetSeconds
const restTime = parseInt(item.restTime) || 0
const lapCount = parseInt(item.lapCount) || 0
// 计算公式:(目标时间 + 休息时间) * 圈数
total += (targetTime + restTime) * lapCount
})
return total
})
// 格式化总时长显示(时:分:秒)
const formatTotalTime = (seconds : number) : string => {
if (seconds <= 0) return '00:00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 生成唯一ID
const generateId = () : string => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}
// 添加计划项
const addPlanItem = () => {
const newItem : PlanItem = {
id: generateId(),
targetMinutes: '',
targetSeconds: '',
restTime: '',
lapCount: ''
}
planList.value.push(newItem)
}
// 删除计划项
const deletePlanItem = (index : number) => {
uni.showModal({
title: '确认删除',
content: '确定要删除该计划吗?',
success: (res) => {
if (res.confirm) {
planList.value.splice(index, 1)
Service.Msg('删除成功')
}
}
})
}
// 验证表单
const validateForm = () : boolean => {
if (!projectName.value.trim()) {
Service.Msg('请输入项目名称')
return false
}
if (planList.value.length === 0) {
Service.Msg('请至少添加一个计划')
return false
}
for (let i = 0; i < planList.value.length; i++) {
const item = planList.value[i]
if (!item.targetSeconds) {
Service.Msg(`计划 ${i + 1} 请输入目标时间`)
return false
}
const seconds = parseInt(item.targetSeconds) || 0
if (seconds === 0) {
Service.Msg(`计划 ${i + 1} 目标时间不能为0`)
return false
}
if (!item.restTime || parseInt(item.restTime) < 0) {
Service.Msg(`计划 ${i + 1} 请输入有效的休息时长`)
return false
}
if (!item.lapCount || parseInt(item.lapCount) <= 0) {
Service.Msg(`计划 ${i + 1} 请输入有效的圈数`)
return false
}
}
return true
}
// 保存
const handleSave = () => {
if (!validateForm()) {
return
}
let plan = planList.value.map(item => ({
target: (parseInt(item.targetMinutes) || 0) * 60 + (parseInt(item.targetSeconds) || 0),
rest: parseInt(item.restTime),
circle: parseInt(item.lapCount)
}))
// 整理数据
const data = {
planId: planId.value,
name: projectName.value,
planType: '混氧项目',
departType: '',
interval: '',
groupInt: '',
subsectionDistance: '',
subsectionInt: '',
users: '',
project: JSON.stringify(plan),
group: '',
}
console.log('保存的数据:', data)
PlanService.AddPlan(data).then(res => {
if (res.code == 0) {
Service.Msg('添加成功!')
setTimeout(() => {
Service.GoPageBack()
}, 1000)
} else {
Service.Msg(res.msg)
}
})
}
// 取消
const handleCancel = () => {
uni.showModal({
title: '确认取消',
content: '确定要取消吗?已输入的内容将不会保存。',
success: (res) => {
if (res.confirm) {
Service.GoPageBack()
}
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.add-hunyang-container {
min-height: 100vh;
padding-bottom: 140rpx;
}
/* 表单区域 */
.form-section {
padding: 20rpx;
}
.form-card {
background-color: #fff;
border-radius: 24rpx;
padding: 32rpx 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.form-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
margin-bottom: 28rpx;
display: block;
}
}
/* 表单输入 */
.form-group {
margin-bottom: 0;
.form-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 14rpx;
}
.form-input {
width: 100%;
height: 88rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
transition: all 0.2s ease;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 4rpx rgba(24, 144, 255, 0.15);
}
}
}
/* 计划总时长 */
.total-time-section {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 2rpx solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.total-time-label {
font-size: 28rpx;
font-weight: 600;
color: #666;
}
.total-time-value {
font-size: 36rpx;
font-weight: bold;
color: #1890ff;
font-family: 'DIN Alternate', 'Helvetica Neue', monospace;
letter-spacing: 2rpx;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
padding: 12rpx 24rpx;
border-radius: 12rpx;
border: 2rpx solid #91d5ff;
}
}
/* 计划项 */
.plan-item {
background-color: #f9f9f9;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
border: 2rpx solid #f0f0f0;
.plan-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.plan-item-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.delete-plan-btn {
width: 44rpx;
height: 44rpx;
background: linear-gradient(135deg, #ff4d4f 0%, #d9363e 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(255, 77, 79, 0.3);
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
}
.plan-item-content {
display: flex;
flex-direction: column;
gap: 20rpx;
}
}
/* 输入组 */
.input-group {
display: flex;
align-items: center;
justify-content: space-between;
.input-label {
font-size: 26rpx;
font-weight: 500;
color: #666;
min-width: 120rpx;
}
}
/* 时间选择器 */
.time-selector {
display: flex;
align-items: center;
gap: 8rpx;
flex: 1;
.time-input-wrapper {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 0 16rpx;
height: 72rpx;
flex: 1;
.time-input {
flex: 1;
font-size: 28rpx;
color: #333;
text-align: right;
}
.time-unit {
font-size: 24rpx;
color: #999;
margin-left: 8rpx;
}
}
.time-separator {
font-size: 32rpx;
font-weight: bold;
color: #333;
padding: 0 4rpx;
}
}
/* 休息选择器 */
.rest-selector,
.lap-selector {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 0 16rpx;
height: 72rpx;
flex: 1;
.rest-input,
.lap-input {
flex: 1;
font-size: 28rpx;
color: #333;
text-align: right;
}
.time-unit {
font-size: 24rpx;
color: #999;
margin-left: 12rpx;
}
}
/* 添加计划按钮 */
.add-plan-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 24rpx 32rpx;
margin-top: 20rpx;
background: linear-gradient(135deg, #f0f9ff 0%, #e6f7ff 100%);
border-radius: 16rpx;
border: 2rpx dashed #91d5ff;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
background: linear-gradient(135deg, #e6f7ff 0%, #d6f4ff 100%);
}
.add-plan-text {
font-size: 28rpx;
color: #1890ff;
font-weight: 500;
}
}
/* 底部操作按钮 */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 99;
.action-buttons {
display: flex;
gap: 16rpx;
}
}
.cancel-btn,
.confirm-btn {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
border: none;
font-size: 30rpx;
font-weight: 600;
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666;
&:active {
background-color: #e8e8e8;
transform: scale(0.96);
}
}
.confirm-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #fff;
box-shadow: 0 6rpx 16rpx rgba(24, 144, 255, 0.35);
&:active {
transform: scale(0.96);
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.25);
}
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<view class="analyze-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">数据分析</text>
<text class="subtitle">训练数据统计与分析</text>
</view>
</view>
<!-- 功能入口卡片列表 -->
<view class="function-list">
<!-- 包干数据 -->
<view class="function-card package-card" @click="Service.GoPage('/pages/dataAnalyze/baoganAnalyze')">
<view class="card-icon package-icon">
<u-icon name="grid" size="36" color="#fff"></u-icon>
</view>
<view class="card-info">
<text class="card-title">包干数据</text>
<text class="card-desc">查看包干项目训练记录</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#ccc"></u-icon>
</view>
</view>
<!-- 计时数据 -->
<view class="function-card timing-card" @click="Service.GoPage('/pages/dataAnalyze/timingAnalze')">
<view class="card-icon timing-icon">
<u-icon name="clock" size="36" color="#fff"></u-icon>
</view>
<view class="card-info">
<text class="card-title">计时数据</text>
<text class="card-desc">查看计时项目训练记录</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#ccc"></u-icon>
</view>
</view>
<!-- 曲线走势图 -->
<view class="function-card chart-card" @click="Service.GoPage('/pages/dataAnalyze/Curve')">
<view class="card-icon chart-icon">
<u-icon name="calendar" size="36" color="#fff"></u-icon>
</view>
<view class="card-info">
<text class="card-title">曲线走势图</text>
<text class="card-desc">查看学员成绩趋势变化</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#ccc"></u-icon>
</view>
</view>
<!-- 分段数据 -->
<view class="function-card segment-card" @click="Service.GoPage('/pages/dataAnalyze/paragraphAnalyze')">
<view class="card-icon segment-icon">
<u-icon name="list" size="36" color="#fff"></u-icon>
</view>
<view class="card-info">
<text class="card-title">分段数据</text>
<text class="card-desc">查看分段训练记录</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#ccc"></u-icon>
</view>
</view>
<!-- 成绩排名 -->
<view class="function-card ranking-card" @click="Service.GoPage('/pages/dataAnalyze/grades')">
<view class="card-icon ranking-icon">
<u-icon name="integral" size="36" color="#fff"></u-icon>
</view>
<view class="card-info">
<text class="card-title">成绩排名</text>
<text class="card-desc">查看学员成绩排名情况</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#ccc"></u-icon>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref } from 'vue'
// 功能入口数据统计
const packageCount = ref(18)
const timingCount = ref(36)
const chartCount = ref(12)
const segmentCount = ref(24)
const rankingCount = ref(15)
onLoad(() => {
})
onShow(() => {
})
// 跳转到包干数据
const goToPackageData = () => {
Service.GoPage('/pages/userFunc/project')
}
// 跳转到计时数据
const goToTimingData = () => {
Service.Msg('计时数据功能开发中')
}
// 跳转到曲线走势图
const goToChart = () => {
Service.GoPage('/pages/userFunc/dataAnalyze')
}
// 跳转到分段数据
const goToSegment = () => {
Service.GoPage('/pages/userFunc/segmentation')
}
// 跳转到成绩排名
const goToRanking = () => {
Service.Msg('成绩排名功能开发中')
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.analyze-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
/* 页面标题区域 */
.header-section {
margin-bottom: 24rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* 功能入口卡片列表 */
.function-list {
.function-card {
background-color: #fff;
border-radius: 20rpx;
padding: 32rpx 28rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
opacity: 0;
transition: opacity 0.3s ease;
}
&:active {
transform: scale(0.98);
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.04);
&::before {
opacity: 1;
}
}
&.package-card::before {
background: linear-gradient(180deg, #faad14 0%, #ffc53d 100%);
}
&.timing-card::before {
background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
}
&.chart-card::before {
background: linear-gradient(180deg, #52c41a 0%, #73d13d 100%);
}
&.segment-card::before {
background: linear-gradient(180deg, #722ed1 0%, #9254de 100%);
}
&.ranking-card::before {
background: linear-gradient(180deg, #eb2f96 0%, #f759ab 100%);
}
.card-icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
&.package-icon {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
box-shadow: 0 4rpx 12rpx rgba(250, 173, 20, 0.3);
}
&.timing-icon {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
&.chart-icon {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
box-shadow: 0 4rpx 12rpx rgba(82, 196, 26, 0.3);
}
&.segment-icon {
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
box-shadow: 0 4rpx 12rpx rgba(114, 46, 209, 0.3);
}
&.ranking-icon {
background: linear-gradient(135deg, #eb2f96 0%, #f759ab 100%);
box-shadow: 0 4rpx 12rpx rgba(235, 47, 150, 0.3);
}
}
.card-info {
flex: 1;
margin-right: 20rpx;
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.card-desc {
font-size: 24rpx;
color: #999;
display: block;
}
}
.card-arrow {
flex-shrink: 0;
transition: transform 0.3s ease;
}
&:active .card-arrow {
transform: translateX(6rpx);
}
}
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<view class="data-analyze-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">数据图表</text>
<text class="subtitle">训练数据可视化分析</text>
</view>
</view>
<!-- 多人折线图 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">多人成绩趋势</text>
<text class="chart-desc">近5次训练成绩对比</text>
</view>
<view class="chart-box">
<qiun-data-charts
type="line"
:opts="lineOpts"
:chartData="lineChartData"
:ontouch="true"
/>
</view>
</view>
<!-- 完成率饼状图 - 每个学生 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">学员完成率</text>
<text class="chart-desc">各学员训练完成情况统计</text>
</view>
<view class="chart-box">
<qiun-data-charts
type="pie"
:opts="completionOpts"
:chartData="completionChartData"
:ontouch="true"
/>
</view>
</view>
<!-- 排名统计图 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">学员排名</text>
<text class="chart-desc">按最佳成绩排名</text>
</view>
<view class="chart-box">
<qiun-data-charts
type="column"
:opts="columnOpts"
:chartData="columnChartData"
:ontouch="true"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { ref, computed, } from 'vue'
// 折线图配置
const lineOpts = ref({
color: ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1'],
padding: [15, 10, 0, 15],
legend: {},
xAxis: {
disableGrid: true,
itemCount: 4
},
yAxis: {
data: [{ min: 0 }]
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow'
}
}
})
const lineChartData = ref({
categories: ['第1次', '第2次', '第3次', '第4次', '第5次'],
series: [
{
name: '张三',
data: [45, 42, 38, 40, 36]
},
{
name: '李四',
data: [50, 48, 45, 44, 42]
},
{
name: '王五',
data: [55, 52, 50, 48, 45]
}
]
})
// 完成率饼状图配置
const completionOpts = ref({
color: ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'],
padding: [5, 5, 5, 5],
legend: {},
extra: {
pie: {
activeOpacity: 0.5,
activeRadius: 10,
offsetAngle: 0,
labelWidth: 15,
ringWidth: 40,
title: {
name: '总完成率',
fontSize: 15,
color: '#666666'
},
subtitle: {
name: '87%',
fontSize: 20,
color: '#1890ff'
}
}
}
})
const completionChartData = ref({
series: [
{
name: '完成率',
data: [
{ value: 17, name: '张三' },
{
value: 17,
name: '李四',
color: '#52c41a'
},
{
value: 17,
name: '王五',
color: '#faad14'
},
{
value: 14,
name: '赵六',
color: '#f5222d'
},
{
value: 14,
name: '钱七',
color: '#722ed1'
},
{
value: 11,
name: '孙八',
color: '#13c2c2'
}
]
}
]
})
// 柱状图配置
const columnOpts = ref({
color: ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'],
padding: [15, 10, 0, 15],
legend: {},
xAxis: {
disableGrid: true
},
yAxis: {
data: [{ min: 0, max: 60 }]
},
extra: {
column: {
type: 'group',
width: 30,
activeBgColor: '#000000',
activeBgOpacity: 0.08,
linearType: 'custom',
seriesGap: 2,
categoryGap: 0.5,
barBorderCircle: true
}
}
})
const columnChartData = ref({
categories: ['张三', '李四', '王五', '赵六', '钱七', '孙八'],
series: [
{
name: '最佳成绩(秒)',
data: [36, 42, 45, 50, 56, 60]
}
]
})
onLoad(() => {
})
onShow(() => {
})
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.data-analyze-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
/* 页面标题区域 */
.header-section {
margin-bottom: 24rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* 图表卡片 */
.chart-card {
background-color: #fff;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:active {
transform: scale(0.995);
}
.chart-header {
margin-bottom: 20rpx;
padding-left: 8rpx;
border-left: 6rpx solid #1890ff;
.chart-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 6rpx;
}
.chart-desc {
font-size: 24rpx;
color: #999;
}
}
.chart-box {
width: 100%;
height: 500rpx;
background-color: #fafafa;
border-radius: 16rpx;
overflow: hidden;
}
}
</style>

View File

@@ -0,0 +1,693 @@
<template>
<view class="hunyang-container">
<!--
混氧训练页面主容器
包含头部信息倒计时装置底部控制按钮
-->
<!-- 项目信息头部 -->
<view class="card-section header-section" style="position: relative;">
<view class="header-content">
<!-- 训练项目名称 -->
<view class="project-name">{{ name }}</view>
<!-- 总训练时长显示 -->
<view class="total-duration">
<text class="duration-label">计划总时长:</text>
<!-- 格式化显示总时长 -->
<text class="duration-value">{{ formatTotalDuration(totalDuration) }}</text>
</view>
</view>
<view class="" style="position: absolute; top: 20rpx; right: 30rpx; ">
<up-icon @click="Service.GoPage('/pages/userFunc/addHunyang?id='+planId)" name="setting"
size="22"></up-icon>
</view>
</view>
<!-- 多个倒计时装置区域 -->
<view class="card-section timer-section">
<!-- 区域标题 -->
<view class="section-title">倒计时装置</view>
<view class="multi-timers-wrapper">
<!--
使用v-for循环渲染每个训练计划
:active类用于高亮当前正在执行的计划
-->
<view v-for="(plan, index) in plans" :key="plan.id" class="timer-item"
:class="{ active: currentPlanIndex === index }">
<view class="timer-horizontal">
<!--
训练状态显示区域
根据不同状态显示休息中/已完成/计划X
-->
<view class="timer-status" :class="{ resting: timerStates[index].isResting }">
<text v-if="timerStates[index].isResting" class="status-label">休息中</text>
<text v-else-if="timerStates[index].isCompleted" class="status-label">已完成</text>
<text v-else class="status-label">计划{{ index + 1 }}</text>
</view>
<!--
倒计时显示区域
根据状态显示训练时间或休息时间
-->
<view class="timer-display" :class="{ resting: timerStates[index].isResting }">
<text v-if="timerStates[index].isResting"
class="display-time">{{ formatRestCountdown(timerStates[index].restCountdown) }}</text>
<text v-else class="display-time">{{ formatCountdown(timerStates[index].countdown) }}</text>
</view>
<!--
完成圈数显示
显示已完成的圈数/总圈数
-->
<view class="timer-laps">
<text class="laps-text">{{ timerStates[index].completedLaps }}/{{ plan.circle }}</text>
</view>
</view>
<!-- 计划详细信息 -->
<view class="plan-info-bar">
<view class="info-item">
<text class="info-label">目标:</text>
<text class="info-value">{{ plan.target }}</text>
</view>
<view class="info-item">
<text class="info-label">休息:</text>
<text class="info-value">{{ plan.rest }}</text>
</view>
</view>
</view>
<!-- 空状态提示 -->
<view v-if="plans.length === 0" class="empty-timer">
<text class="empty-text">暂无训练计划请添加</text>
</view>
</view>
</view>
<!-- 底部控制按钮区域 -->
<view class="bottom-controls">
<view class="controls-inner">
<!--
开始按钮 - 仅在未运行时显示
点击开始所有训练计划
-->
<view v-if="!isRunning" class="control-btn start-btn" @click="startAll">
<u-icon name="play-circle" size="32" color="#fff"></u-icon>
<text class="btn-text">开始</text>
</view>
<!--
暂停按钮 - 仅在运行时显示
点击暂停所有训练
-->
<view v-else class="control-btn pause-btn" @click="pauseAll">
<u-icon name="pause-circle" size="32" color="#fff"></u-icon>
<text class="btn-text">暂停</text>
</view>
<!--
重置按钮
点击重置所有训练状态
-->
<view class="control-btn reset-btn" @click="resetAll">
<u-icon name="reload" size="32" color="#fff"></u-icon>
<text class="btn-text">重置</text>
</view>
</view>
</view>
<!-- 保存悬浮按钮 -->
<view class="save-float-btn" @click="saveData">
<text class="btn-text">保存</text>
</view>
</view>
</template>
<script setup lang="ts">
// 导入必要的Vue组合式API和项目服务
import { ref, computed, onUnmounted } from 'vue'
import { Service } from '@/Service/Service'
import { PlanService } from '@/Service/swimming/PlanService'
import { onLoad, onShow } from '@dcloudio/uni-app'
// 定义训练计划的数据结构接口
interface PlanItem {
id : string // 计划唯一标识
targetTime : number // 目标训练时间(秒)
restTime : number // 休息时间(秒)
lapCount : number // 训练圈数
}
// 定义计时器状态的数据结构接口
interface TimerState {
isRunning : boolean // 是否正在运行
isResting : boolean // 是否处于休息状态
isCompleted : boolean // 是否已完成
countdown : number // 倒计时(秒)
restCountdown : number // 休息倒计时(秒)
completedLaps : number // 已完成圈数
}
// 训练计划列表使用ref实现响应式
const plans = ref<Array<any>>([
])
// 每个计划的计时器状态与plans一一对应
const timerStates = ref<TimerState[]>([])
// 当前正在执行的训练计划索引
const currentPlanIndex = ref(0)
// 训练是否正在运行的状态
const isRunning = ref(false)
// 主计时器间隔ID用于控制全局计时
const mainInterval = ref<number | null>(null)
// 训练计划ID从页面参数获取
let planId = ref('')
let name = ref('')
// 页面加载时触发,获取训练计划信息
onLoad((data : any) => {
planId.value = data.id
})
onShow(() => {
getPlanInfo()
})
// 获取计划详情
// 通过PlanService获取指定ID的训练计划并初始化计时器状态
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
// 解析计划数据并初始化计时器状态
name.value = res.data.plan.name
plans.value = JSON.parse(res.data.plan.project)
initTimerStates()
} else {
// 显示错误信息
Service.Msg(res.msg)
}
})
}
// 初始化计时器状态
// 为每个计划创建对应的计时器状态对象,重置所有状态
const initTimerStates = () => {
timerStates.value = plans.value.map(() => ({
isRunning: false,
isResting: false,
isCompleted: false,
countdown: 0,
restCountdown: 0,
completedLaps: 0
}))
}
// 开始所有训练计划
// 检查是否有计划,如果没有则提示用户
// 设置运行状态并启动第一个计划
const startAll = () => {
if (plans.value.length === 0) {
Service.Msg('请先添加训练计划')
return
}
isRunning.value = true
// 启动指定索引的计划
const startPlan = (index : number) => {
// 如果索引超出范围,停止所有训练并显示完成消息
if (index >= plans.value.length) {
stopAll()
Service.Msg('所有计划训练完成!', 'success')
return
}
// 设置当前计划索引并获取对应的状态
currentPlanIndex.value = index
const plan = plans.value[index]
const state = timerStates.value[index]
// 设置计划为运行状态,并初始化倒计时
state.isRunning = true
if (state.countdown === 0 && !state.isResting) {
state.countdown = plan.target
}
}
// 启动当前计划
startPlan(currentPlanIndex.value)
// 设置主计时器每10毫秒更新一次
mainInterval.value = setInterval(() => {
const index = currentPlanIndex.value
// 如果索引超出范围,停止所有训练
if (index >= plans.value.length) {
stopAll()
return
}
const plan = plans.value[index]
const state = timerStates.value[index]
// 如果处于休息状态,更新休息倒计时
if (state.isResting) {
state.restCountdown -= 0.01
if (state.restCountdown <= 0) {
// 休息结束,重置休息状态并开始训练
state.isResting = false
state.restCountdown = 0
state.countdown = plan.target
}
} else {
// 训练状态,更新训练倒计时
state.countdown -= 0.01
if (state.countdown <= 0) {
// 训练完成一圈,增加完成圈数
state.completedLaps += 1
// 如果达到总圈数,标记为已完成并启动下一个计划
if (state.completedLaps >= plan.circle) {
state.isRunning = false
state.isCompleted = true
currentPlanIndex.value += 1
startPlan(currentPlanIndex.value)
} else {
// 否则进入休息状态
state.isResting = true
state.restCountdown = plan.rest
}
}
}
}, 10)
}
// 暂停所有训练
// 调用stopAll方法暂停训练
const pauseAll = () => {
stopAll()
}
// 停止所有训练
// 重置运行状态,清除主计时器
const stopAll = () => {
isRunning.value = false
// 重置所有计划的运行状态
timerStates.value.forEach(state => {
state.isRunning = false
})
// 清除主计时器
if (mainInterval.value) {
clearInterval(mainInterval.value)
mainInterval.value = null
}
}
// 重置所有训练状态
// 停止训练,重置当前计划索引,重置所有计时器状态
const resetAll = () => {
stopAll()
currentPlanIndex.value = 0
// 重置所有计时器状态
timerStates.value.forEach(state => {
state.isResting = false
state.isCompleted = false
state.countdown = 0
state.restCountdown = 0
state.completedLaps = 0
})
}
// 格式化倒计时显示
// 将秒数格式化为"分:秒"格式
const formatCountdown = (seconds : number) : string => {
const totalSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(totalSeconds / 60)
const secs = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化休息倒计时显示
// 将秒数格式化为"分:秒"格式
const formatRestCountdown = (seconds : number) : string => {
const totalSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(totalSeconds / 60)
const secs = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化总时长显示
// 将秒数格式化为"分:秒"格式
const formatTotalDuration = (seconds : number) : string => {
if (seconds === 0) return '0分0秒'
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${minutes}${secs}`
}
// 计算总训练时长
// 根据所有计划的训练时间和休息时间计算总时长
const totalDuration = computed(() => {
return plans.value.reduce((total, plan) => {
const planTime = plan.target * plan.circle
const restTime = plan.rest * (plan.circle - 1)
return total + planTime + restTime
}, 0)
})
// 保存数据
const saveData = () => {
if (plans.value.length === 0) {
Service.Msg('没有可保存的训练计划')
return
}
// 构建要保存的数据
const saveData = {
name: name.value,
project: JSON.stringify(plans.value),
totalDuration: totalDuration.value
}
}
// 页面卸载时触发
// 确保停止所有训练,避免内存泄漏
onUnmounted(() => {
stopAll()
})
// 格式化总时长显示
// 将秒数格式化为"小时:分:秒"或"分:秒"或"秒"格式
// 页面卸载时触发
// 确保停止所有训练,避免内存泄漏
onUnmounted(() => {
stopAll()
})
</script>
<style lang="scss" scoped>
.hunyang-container {
min-height: 100vh;
background-color: #f8f9fa;
padding: 20rpx;
padding-bottom: 240rpx;
}
.card-section {
background-color: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.header-section {
background: #ffffff;
color: #333333;
}
.header-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.project-name {
font-size: 36rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.total-duration {
display: flex;
align-items: center;
gap: 8rpx;
background: #f0f0f0;
padding: 12rpx 20rpx;
border-radius: 20rpx;
}
.duration-label {
font-size: 24rpx;
color: #666666;
}
.duration-value {
font-size: 28rpx;
font-weight: 500;
}
.section-title {
font-size: 28rpx;
font-weight: 500;
color: #333333;
margin-bottom: 20rpx;
position: relative;
padding-left: 16rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4rpx;
height: 20rpx;
background: #4a90e2;
border-radius: 2rpx;
}
}
.empty-timer {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 20rpx;
.empty-text {
font-size: 24rpx;
color: #999999;
}
}
.timer-section {
background: #ffffff;
}
.multi-timers-wrapper {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.timer-item {
background: #ffffff;
border-radius: 12rpx;
padding: 20rpx;
border: 1rpx solid #e0e0e0;
transition: all 0.2s ease;
&.active {
border-color: #4a90e2;
box-shadow: 0 0 0 2rpx rgba(74, 144, 226, 0.1);
}
&:active {
transform: scale(0.99);
}
}
.timer-horizontal {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 12rpx;
gap: 12rpx;
}
.timer-status {
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx 16rpx;
border-radius: 8rpx;
background: #e6f0ff;
border: 1rpx solid #d0e3ff;
min-width: 100rpx;
&.resting {
background: #fff8e6;
border-color: #ffe0b2;
}
.status-label {
font-size: 22rpx;
font-weight: 500;
color: #4a90e2;
}
&.resting .status-label {
color: #ff9800;
}
}
.timer-display {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
.display-time {
font-size: 56rpx;
font-weight: bold;
font-family: 'Helvetica Neue', monospace;
color: #4a90e2;
letter-spacing: 1rpx;
}
&.resting .display-time {
color: #ff9800;
animation: countdownFlash 0.5s ease-in-out infinite;
}
}
.timer-laps {
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx 16rpx;
background: #f5f5f5;
border-radius: 8rpx;
min-width: 100rpx;
.laps-text {
font-size: 22rpx;
color: #666666;
font-weight: 400;
}
}
.bottom-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 16rpx 24rpx 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
z-index: 100;
border-top: 1rpx solid #e0e0e0;
}
.controls-inner {
display: flex;
justify-content: center;
align-items: center;
gap: 20rpx;
max-width: 750rpx;
margin: 0 auto;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 16rpx;
border-radius: 8rpx;
transition: all 0.2s ease;
flex: 1;
}
.control-btn:active {
transform: scale(0.98);
}
.btn-text {
font-size: 22rpx;
font-weight: 500;
color: #ffffff;
}
.start-btn {
background-color: #4caf50;
}
.pause-btn {
background-color: #ff9800;
}
.reset-btn {
background-color: #2196f3;
}
.plan-info-bar {
display: flex;
justify-content: center;
gap: 30rpx;
padding-top: 10rpx;
border-top: 1rpx dashed #e0e0e0;
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.info-label {
font-size: 20rpx;
color: #666666;
}
.info-value {
font-size: 20rpx;
color: #333333;
font-weight: 500;
}
}
@keyframes countdownFlash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 保存悬浮按钮样式 */
.save-float-btn {
position: fixed;
bottom: 240rpx;
right: 30rpx;
background-color: #2196f3;
padding: 40rpx;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(33, 150, 243, 0.3);
z-index: 101;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.save-float-btn:active {
transform: scale(0.95);
}
.save-float-btn .btn-text {
font-size: 20rpx;
}
</style>

394
src/pages/userFunc/plan.vue Normal file
View File

@@ -0,0 +1,394 @@
<template>
<view class="plan-container">
<!-- 训练计划列表 -->
<view class="plan-list-section">
<view class="section-header">
<text class="section-title">我的训练计划</text>
<text class="plan-count">{{ trainingPlans.length }}个计划</text>
</view>
<!-- 计划列表 -->
<view class="plan-list">
<view v-for="(plan, index) in trainingPlans" :key="plan.id" class="plan-card">
<!-- 计划信息 -->
<view class="plan-info">
<view class="plan-icon">
<view class="icon-circle">
<u-icon name="calendar" size="24" color="#fff"></u-icon>
</view>
</view>
<view class="plan-detail">
<text class="plan-name">{{ plan.name }}</text>
<view class="plan-meta">
<view class="meta-tag">
<text class="tag-text">{{ plan.laps }}</text>
</view>
<view class="meta-tag">
<text class="tag-text">混氧</text>
</view>
</view>
</view>
</view>
<!-- 删除按钮 -->
<view class="delete-btn">
<u-icon name="trash" size="20" color="#ff4d4f"></u-icon>
</view>
</view>
<!-- 空状态 -->
<view v-if="trainingPlans.length === 0" class="empty-state">
<view class="empty-icon">
<u-icon name="calendar" size="80" color="#d9d9d9"></u-icon>
</view>
<text class="empty-text">暂无训练计划</text>
<text class="empty-desc">点击下方按钮添加第一个训练计划</text>
</view>
</view>
</view>
<!-- 底部添加计划按钮 -->
<view class="bottom-section">
<view class="add-plan-btn" @click="Service.GoPage('/pages/userFunc/addplan')">
<u-icon name="plus" size="24" color="#fff"></u-icon>
<text class="add-btn-text">添加训练计划</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Service } from '@/Service/Service'
interface TrainingPlan {
id : string
name : string
laps : string
}
const trainingPlans = ref<TrainingPlan[]>([
{
id: '1',
name: '自由泳500米基础训练',
laps: '10'
},
{
id: '2',
name: '蛙泳200米进阶训练',
laps: '4'
},
{
id: '3',
name: '混合泳1000米挑战',
laps: '20'
},
{
id: '4',
name: '蝶泳400米专项训练',
laps: '8'
}
])
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.plan-container {
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: 180rpx;
}
/* 计划列表区域 */
.plan-list-section {
padding: 20rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 8rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
position: relative;
padding-left: 16rpx;
/* 标题左侧装饰条 */
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 32rpx;
background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
border-radius: 3rpx;
}
}
.plan-count {
font-size: 26rpx;
color: #1890ff;
font-weight: 600;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(24, 144, 255, 0.05) 100%);
padding: 8rpx 20rpx;
border-radius: 20rpx;
}
/* 计划列表 */
.plan-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 单个计划卡片 - 美化版 */
.plan-card {
background: linear-gradient(135deg, #fff 0%, #fafbfc 100%);
border-radius: 28rpx;
padding: 28rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08), 0 0 0 1rpx rgba(0, 0, 0, 0.04) inset;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
/* 卡片背景装饰 */
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 120rpx;
height: 120rpx;
background: radial-gradient(circle, rgba(24, 144, 255, 0.08) 0%, transparent 70%);
pointer-events: none;
}
&:active {
transform: scale(0.98) translateY(2rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
}
/* 左侧序号 */
.plan-index {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
.index-text {
font-size: 26rpx;
font-weight: 700;
color: #fff;
}
/* 计划信息区域 */
.plan-info {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
min-width: 0;
}
.plan-icon {
flex-shrink: 0;
}
.icon-circle {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(24, 144, 255, 0.25);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 8rpx;
right: 8rpx;
width: 16rpx;
height: 16rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
}
.plan-detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.plan-name {
font-size: 32rpx;
font-weight: 700;
color: #333;
line-height: 1.3;
background: linear-gradient(135deg, #333 0%, #666 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.plan-meta {
display: flex;
align-items: center;
gap: 12rpx;
}
.meta-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(24, 144, 255, 0.05) 100%);
border-radius: 16rpx;
border: 1rpx solid rgba(24, 144, 255, 0.2);
}
.tag-text {
font-size: 24rpx;
color: #1890ff;
font-weight: 600;
}
/* 删除按钮 - 美化版 */
.delete-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 77, 79, 0.1) 0%, rgba(255, 77, 79, 0.05) 100%);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
border: 2rpx solid rgba(255, 77, 79, 0.2);
&:active {
background: linear-gradient(135deg, #ff4d4f 0%, #d9363e 100%);
transform: scale(0.9);
box-shadow: 0 4rpx 12rpx rgba(255, 77, 79, 0.4);
border-color: transparent;
u-icon {
color: #fff;
}
}
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 140rpx 40rpx;
gap: 24rpx;
}
.empty-icon {
margin-bottom: 20rpx;
opacity: 0.6;
}
.empty-text {
font-size: 32rpx;
color: #8c8c8c;
font-weight: 600;
}
.empty-desc {
font-size: 26rpx;
color: #bfbfbf;
}
/* 底部添加计划按钮 */
.bottom-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(180deg, transparent 0%, rgba(245, 245, 245, 0.95) 20%, #f5f5f5 100%);
padding: 24rpx 20rpx 40rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
z-index: 100;
}
.add-plan-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
width: 100%;
height: 100rpx;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #36cfc9 100%);
background-size: 200% 200%;
border-radius: 24rpx;
box-shadow: 0 12rpx 32rpx rgba(24, 144, 255, 0.35), 0 0 0 1rpx rgba(255, 255, 255, 0.5) inset;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
/* 光泽效果 */
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shimmer 3s infinite;
}
&:active {
transform: scale(0.96);
box-shadow: 0 6rpx 20rpx rgba(24, 144, 255, 0.25);
}
}
.add-btn-text {
font-size: 34rpx;
color: #fff;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 1;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,423 @@
<template>
<view class="project-list-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title" style="display: flex; align-items: center; justify-content: space-between;">
<text class="title">项目列表</text>
<text class="subtitle">{{ projects.length }}个项目</text>
</view>
</view>
<!-- 项目列表 -->
<view class="projects-section">
<view v-if="projects.length === 0" class="empty-state">
<view class="empty-icon">
<u-icon name="list" size="64" color="#ddd"></u-icon>
</view>
<text class="empty-text">暂无项目</text>
<text class="empty-desc">点击下方按钮创建第一个项目</text>
</view>
<view v-else class="projects-list">
<view v-for="(project, index) in projects" :key="project.planId" class="project-card"
@click="viewProjectDetail(project)">
<view class="project-delete" @click.stop="deleteProject(project, index)">
<u-icon name="trash" size="18" color="#ff4d4f"></u-icon>
</view>
<view class="project-info">
<view class="project-title-row">
<text class="project-name">{{ project.name }}</text>
<view class="project-mode" :class="getModeClass(project.mode)">
<text class="mode-text">{{ project.mode }}</text>
</view>
</view>
<view class="project-stats">
<view class="stat-badge">
<u-icon name="account" size="14" color="#1890ff"></u-icon>
<text class="badge-text">{{ project.users.length }}位学员</text>
</view>
</view>
</view>
<view class="project-footer">
<view class="">
</view>
<view class="project-time">
<u-icon name="clock" size="14" color="#999"></u-icon>
<text class="time-text">{{ Service.formatDate(project.addTime,1) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad, onReachBottom } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { PlanService } from '@/Service/swimming/PlanService'
import { ref } from "vue"
// 分页相关
let page = ref(1)
let status = ref('loadmore')
// 项目列表数据
const projects = ref<Array<any>>([])
onLoad(() => {
getData()
})
onReachBottom(() => {
getList()
})
// 获取模式对应的样式类
const getModeClass = (mode : string) : string => {
const classMap : Record<string, string> = {
'计时': 'mode-timing',
'包干': 'mode-package',
'分段': 'mode-segment'
}
return classMap[mode] || ''
}
// 查看项目详情
const viewProjectDetail = (project : any) => {
Service.Msg(`查看「${project.name}」详情`)
}
// 删除项目
const deleteProject = (project : any, index : number) => {
Service.Confirm(`确定要删除「${project.name}」吗?`, () => {
PlanService.DeletePlan(project.planId).then(res => {
if (res.code == 0) {
getData()
Service.Msg('删除成功')
} else {
Service.Msg(res.msg)
}
})
})
}
// 获取项目列表数据
const getData = () => {
projects.value = []
page.value = 1
status.value = 'loadmore'
getList()
}
// 获取项目列表
const getList = () => {
if (status.value == 'loading' || status.value == 'nomore') {
return
}
status.value = 'loading'
PlanService.GetPlanList( '', page.value.toString()).then(res => {
if (res.code == 0) {
projects.value = [...projects.value, ...res.data]
status.value = res.data.length == 10 ? 'loadmore' : 'nomore'
page.value++
} else {
Service.Msg(res.msg)
}
})
}
// 添加项目
const addProject = () => {
Service.GoPage('/pages/userFunc/setCourse')
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.project-list-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 180rpx;
}
/* 页面标题区域 */
.header-section {
margin-bottom: 24rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* 项目列表区域 */
.projects-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.section-count {
font-size: 24rpx;
color: #999;
background-color: #f5f5f5;
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
}
.empty-state {
background-color: #fff;
border-radius: 24rpx;
padding: 100rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.empty-icon {
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
font-size: 30rpx;
font-weight: 600;
color: #666;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #999;
}
}
}
.projects-list {
.project-card {
background-color: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
&:active {
transform: scale(0.98);
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.04);
&::before {
opacity: 1;
}
}
.project-delete {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff1f0 0%, #ffccc7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 77, 79.3);
transition: all 0.3s ease;
z-index: 2;
&:active {
transform: scale(0.9);
background: linear-gradient(135deg, #ffccc7 0%, #ffa39e 100%);
}
}
.project-info {
padding-right: 80rpx;
.project-title-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.project-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.project-mode {
padding: 6rpx 14rpx;
border-radius: 12rpx;
font-size: 22rpx;
font-weight: 600;
flex-shrink: 0;
&.mode-timing {
background-color: #e6f7ff;
color: #1890ff;
}
&.mode-package {
background-color: #fff7e6;
color: #faad14;
}
&.mode-segment {
background-color: #f6ffed;
color: #52c41a;
}
.mode-text {
font-size: 22rpx;
font-weight: 600;
}
}
.project-stats {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
.stat-badge {
display: flex;
align-items: center;
gap: 6rpx;
padding: 8rpx 14rpx;
background-color: #f5f5f5;
border-radius: 12rpx;
&.record-badge {
background-color: #f6ffed;
}
.badge-text {
font-size: 22rpx;
color: #666;
font-weight: 500;
}
}
}
}
.project-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
.project-time {
display: flex;
align-items: center;
gap: 6rpx;
.time-text {
font-size: 22rpx;
color: #999;
}
}
.project-arrow {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
}
&:active .project-arrow {
transform: translateX(6rpx);
background-color: rgba(24, 144, 255, 0.1);
}
}
}
/* 底部添加按钮 */
.bottom-add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
.add-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
.btn-text {
font-size: 32rpx;
color: #fff;
font-weight: 700;
}
}
}
</style>

View File

@@ -0,0 +1,835 @@
<template>
<view class="segmentation-container">
<!-- 配置区域 -->
<view class="config-section">
<view class="config-header">
<text class="config-title">分段设置</text>
<u-icon @click="Service.GoPage('/pages/userFunc/setCourse?id='+planId+'&type=2')" name="setting" size="24"
color="#1890ff"></u-icon>
</view>
<view class="config-info">
<text class="info-text">总距离: {{ totalDistance }} ({{ segmentCount }} × {{ segmentDistance }})</text>
</view>
</view>
<!-- 总计时器区域 -->
<view class="total-time-section">
<view class="timer-bar">
<view class="timer-time">{{ formatTime(currentTime) }}</view>
</view>
</view>
<!-- 学生列表 -->
<view class="students-section">
<view class="section-header">
<text class="section-title">学生列表</text>
<text class="student-count">{{ students.length }}位学生</text>
</view>
<view class="students-list">
<view v-for="(student, index) in students" :key="student.id" class="student-item"
:class="{ 'not-started': !student.hasStarted }">
<view class="student-header">
<view class="student-number" :class="{ 'started': student.hasStarted }">{{ student.number }}
</view>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<!-- <view class="start-status" v-if="startMode === 1 && !student.hasStarted">
<text class="status-text">等待</text>
</view> -->
<text class="total-time-text"
v-if="student.segments.length > 0">{{ formatSimpleTime(getLastSegmentTime(student)) }}</text>
<view class="record-badge"
:class="{ 'completed': student.segments.length >= Number(segmentCount) }">
<text class="badge-text">{{ student.segments.length }}/{{ segmentCount }}</text>
</view>
</view>
<view class="student-buttons">
<button class="record-btn" :disabled="!student.hasStarted"
@click="recordStudentSegment(student)">
<text class="btn-text">记录</text>
</button>
<button class="view-btn" @click="showStudentRecords(student)">
<text class="btn-text">详情</text>
</button>
<button class="reset-btn" @click="resetStudent(student)">
<text class="btn-text">重置</text>
</button>
</view>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-btn reset-action" @click="resetAll">
<u-icon name="reload" size="24" color="#fff"></u-icon>
<text class="action-text">重置</text>
</view>
<view class="action-btn timer-action" :class="{ 'running': isRunning }" @click="toggleTimer">
<u-icon :name="isRunning ? 'pause-circle' : 'play-circle'" size="28" color="#fff"></u-icon>
<text class="action-text">{{ isRunning ? '暂停' : '开始' }}</text>
</view>
<view class="action-btn save-action" @click="saveData">
<u-icon name="checkmark" size="24" color="#fff"></u-icon>
<text class="action-text">保存</text>
</view>
</view>
<!-- 记录弹窗 -->
<u-popup :show="showRecordPopup" mode="bottom" :round="20" @close="closeRecordPopup">
<view class="record-popup">
<view class="popup-header">
<text class="popup-title">{{ currentStudent?.name }} - 分段记录</text>
<view class="close-btn" @click="closeRecordPopup">
<u-icon name="close" size="24" color="#999"></u-icon>
</view>
</view>
<view class="popup-summary">
<view class="summary-item">
<text class="summary-label">总距离</text>
<text class="summary-value">{{ totalDistance }}</text>
</view>
<view class="summary-item">
<text class="summary-label">已记录</text>
<text
class="summary-value">{{ currentStudent?.segments?.length || 0 }}/{{ segmentCount }}</text>
</view>
</view>
<view class="record-list"
v-if="currentStudent && currentStudent.segments && currentStudent.segments.length > 0">
<view class="record-header">
<text class="record-cell header-cell">分段</text>
<text class="record-cell header-cell">距离</text>
<text class="record-cell header-cell">累计时间</text>
<text class="record-cell header-cell">分段用时</text>
</view>
<view v-for="(segment, index) in currentStudent.segments" :key="index" class="record-item">
<text class="record-cell">{{ index + 1 }}</text>
<text class="record-cell">{{ segmentDistance }}</text>
<text class="record-cell time-cell">{{ formatTime(segment.time) }}</text>
<text class="record-cell time-cell">{{ formatTimeDiff(index, segment.time) }}</text>
</view>
</view>
<view class="empty-record" v-else>
<u-icon name="info-circle" size="48" color="#ccc"></u-icon>
<text class="empty-text">暂无记录数据</text>
</view>
<view class="popup-footer">
<button class="popup-btn close-popup" @click="closeRecordPopup">关闭</button>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, onUnmounted, computed } from 'vue'
import { Service } from '@/Service/Service'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { PlanService } from '@/Service/swimming/PlanService'
// 分段配置
const segmentDistance = ref(50)
const segmentCount = ref(4)
// 出发模式: 0-一起出发, 1-间隔出发
const startMode = ref(0)
// 间隔时间(秒)
const intervalTime = ref(10)
// 计算总距离
const totalDistance = computed(() => {
return Number(segmentDistance.value) * Number(segmentCount.value)
})
// 学生列表
const students = ref<Array<any>>([])
// 弹窗状态
const showRecordPopup = ref(false)
const currentStudent = ref<any>(null)
// 计时器状态
const isRunning = ref(false)
const currentTime = ref(0)
let timerInterval : number | null = null
let startTime : number = 0
let intervalStartTimer : number | null = null
let planId = ref('')
onLoad((data : any) => {
planId.value = data.id
})
onShow(()=>{
getPlanInfo()
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
// planName.value = res.data.plan.name
segmentDistance.value=res.data.plan.subsectionDistance/res.data.plan.subsectionInt
segmentCount.value=res.data.plan.subsectionInt
// 将计划数据转换为选手数据
// athletes.value = res.data.plan.users.
students.value=res.data.plan.users.map((item:any,index:any)=>{
return {
id: item.studentId,
number: index+1,
name: item.name,
segments: [],
hasStarted: res.data.plan.departType == '间隔出发'?false:true,
startTime: 0
}
})
startMode.value = res.data.plan.departType == '间隔出发' ? 1 : 0
intervalTime.value = res.data.plan.interval
} else {
Service.Msg(res.msg)
}
})
}
// 格式化时间显示
const formatTime = (seconds : number) : string => {
const hours = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化持续时间
const formatDuration = (seconds : number) : string => {
const hours = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
let result = ''
if (hours > 0) {
result += `${hours}小时`
}
if (mins > 0) {
result += `${mins}分钟`
}
if (hours === 0 && mins === 0) {
result = '0分钟'
}
return result
}
// 格式化时间差
const formatTimeDiff = (index : number, currentTime : number) : string => {
if (index === 0) {
return '00:00:00'
}
if (!currentStudent.value || !currentStudent.value.segments[index - 1]) {
return '00:00:00'
}
const prevTime = currentStudent.value.segments[index - 1].time
const diff = currentTime - prevTime
return formatTime(diff)
}
// 获取学生最后一次记录的累计时间
const getLastSegmentTime = (student : any) : number => {
if (!student.segments || student.segments.length === 0) {
return 0
}
return student.segments[student.segments.length - 1].time
}
// 简化时间格式化(分:秒)
const formatSimpleTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 开始计时
const startTimer = () => {
if (isRunning.value) return
isRunning.value = true
startTime = Date.now() - currentTime.value * 1000
timerInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000
currentTime.value = elapsed
}, 10)
// 间隔出发模式
if (startMode.value === 1) {
// 第一个学生立即出发
if (students.value.length > 0) {
students.value[0].hasStarted = true
students.value[0].startTime = 0
}
// 设置间隔出发定时器
const interval = intervalTime.value * 1000
let startedCount = 1
intervalStartTimer = setInterval(() => {
if (startedCount < students.value.length) {
const student = students.value[startedCount]
student.hasStarted = true
student.startTime = (Date.now() - startTime) / 1000
startedCount++
Service.Msg(`${student.name} 已出发`)
} else {
clearInterval(intervalStartTimer as number)
intervalStartTimer = null
}
}, interval)
}
}
// 停止计时
const stopTimer = () => {
if (!isRunning.value) return
isRunning.value = false
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
if (intervalStartTimer) {
clearInterval(intervalStartTimer)
intervalStartTimer = null
}
}
// 切换计时器状态
const toggleTimer = () => {
if (isRunning.value) {
stopTimer()
} else {
startTimer()
}
}
// 记录学生分段
const recordStudentSegment = (student : any) => {
console.log(22222);
if (!isRunning.value) {
Service.Msg('请先开始计时')
return
}
if (!student.hasStarted) {
Service.Msg('该学生尚未出发')
return
}
const maxSeg = Number(segmentCount.value)
if (student.segments.length >= maxSeg) {
Service.Msg(`已达到最大分段数(${maxSeg}段)`)
return
}
let recordTime = currentTime.value
// 间隔出发模式下,使用学生自己的开始时间
if (startMode.value === 1) {
recordTime = currentTime.value - student.startTime
}
student.segments.push({
time: recordTime
})
Service.Msg(`${student.name}${student.segments.length}段已记录`)
}
// 重置学生
const resetStudent = (student : any) => {
student.segments = []
student.hasStarted = false
student.startTime = 0
Service.Msg(`${student.name} 已重置`)
}
// 重置全部
const resetAll = () => {
stopTimer()
currentTime.value = 0
students.value.forEach(student => {
student.segments = []
student.hasStarted = startMode.value == 1?false:true,
student.startTime = 0
})
Service.Msg('已全部重置')
}
// 保存数据
const saveData = () => {
// 检查是否有记录的数据
const hasData = students.value.some(s => s.segments.length > 0)
if (!hasData) {
Service.Msg('暂无数据可保存')
return
}
// 这里可以添加保存到后端或本地存储的逻辑
Service.Msg('保存成功', 'success')
// 保存后可以选择返回上一页
setTimeout(() => {
Service.GoPageBack()
}, 1000)
}
// 显示学生记录弹窗
const showStudentRecords = (student : any) => {
currentStudent.value = student
showRecordPopup.value = true
}
// 关闭记录弹窗
const closeRecordPopup = () => {
showRecordPopup.value = false
currentStudent.value = null
}
// 页面卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
}
if (intervalStartTimer) {
clearInterval(intervalStartTimer)
}
})
</script>
<style lang="scss" scoped>
.segmentation-container {
min-height: 100vh;
background-color: #f6f6f6;
padding: 20rpx 20rpx;
padding-bottom: 180rpx;
}
/* 配置区域 */
.config-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.config-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
.config-info {
background-color: #e6f7ff;
border-radius: 8rpx;
padding: 16rpx 20rpx;
.info-text {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
}
}
/* 总计时器区域 */
.total-time-section {
margin-top: 20rpx;
margin-bottom: 30rpx;
}
.timer-bar {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
.timer-time {
font-size: 80rpx;
font-weight: bold;
color: #ff4d4f;
font-family: 'DIN Alternate', monospace;
}
}
/* 学生列表区域 */
.students-section {
margin-bottom: 20rpx;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.student-count {
font-size: 24rpx;
color: #999;
background-color: #f5f5f5;
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
}
.students-list {
.student-item {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
&.not-started {
opacity: 0.6;
}
.student-header {
display: flex;
align-items: center;
.student-number {
width: 56rpx;
height: 56rpx;
background-color: #e6f7ff;
color: #1890ff;
font-size: 28rpx;
font-weight: 600;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
&.started {
background-color: #52c41a;
color: #fff;
}
}
.student-info {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
.student-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.start-status {
background-color: #faad14;
border-radius: 8rpx;
padding: 4rpx 12rpx;
.status-text {
font-size: 22rpx;
color: #fff;
font-weight: 500;
}
}
.total-time-text {
font-size: 24rpx;
color: #999;
font-family: 'DIN Alternate', monospace;
font-weight: 500;
}
.record-badge {
background-color: #f0f0f0;
border-radius: 12rpx;
padding: 4rpx 12rpx;
.badge-text {
font-size: 22rpx;
color: #999;
font-weight: 500;
}
&.completed {
background-color: #52c41a;
.badge-text {
color: #fff;
}
}
}
}
.student-buttons {
display: flex;
gap: 12rpx;
.record-btn,
.view-btn,
.reset-btn {
height: 56rpx;
padding: 0 20rpx;
border-radius: 8rpx;
border: none;
font-size: 24rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
.btn-text {
font-size: 24rpx;
color: #fff;
}
}
.record-btn {
background-color: #52c41a;
&:disabled {
background-color: #d9d9d9;
opacity: 0.6;
}
}
.view-btn {
background-color: #1890ff;
}
.reset-btn {
background-color: #faad14;
}
}
}
}
}
}
/* 记录弹窗 */
.record-popup {
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.close-btn {
padding: 10rpx;
cursor: pointer;
}
}
.popup-summary {
display: flex;
gap: 20rpx;
padding: 20rpx 30rpx;
background-color: #f6f6f6;
.summary-item {
flex: 1;
background-color: #fff;
border-radius: 12rpx;
padding: 20rpx;
text-align: center;
.summary-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.summary-value {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
}
.record-list {
flex: 1;
overflow-y: auto;
padding: 0 30rpx 20rpx;
.record-header {
display: flex;
background-color: #f5f5f5;
border-radius: 8rpx;
margin-bottom: 16rpx;
margin-top: 20rpx;
.record-cell {
flex: 1;
text-align: center;
font-size: 26rpx;
padding: 16rpx 8rpx;
color: #666;
}
.header-cell {
font-weight: 600;
color: #333;
}
}
.record-item {
display: flex;
background-color: #fff;
border-radius: 8rpx;
margin-bottom: 12rpx;
border: 1rpx solid #f0f0f0;
.record-cell {
flex: 1;
text-align: center;
font-size: 26rpx;
padding: 20rpx 8rpx;
color: #333;
&.time-cell {
font-family: 'DIN Alternate', monospace;
font-weight: 600;
color: #1890ff;
}
}
}
}
.empty-record {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
}
.popup-footer {
padding: 20rpx 30rpx 40rpx;
border-top: 1rpx solid #f0f0f0;
.popup-btn {
height: 80rpx;
border-radius: 12rpx;
border: none;
font-size: 30rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.close-popup {
background-color: #f5f5f5;
color: #666;
}
}
}
/* 底部操作栏 */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx 40rpx;
display: flex;
gap: 20rpx;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
cursor: pointer;
transition: all 0.2s ease;
.action-text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
&:active {
transform: scale(0.98);
}
}
.reset-action {
background-color: #faad14;
}
.timer-action {
background-color: #52c41a;
flex: 1.5;
&.running {
background-color: #ff4d4f;
}
}
.save-action {
background-color: #1890ff;
}
}
</style>

326
src/pages/userFunc/set.vue Normal file
View File

@@ -0,0 +1,326 @@
<template>
<view class="settings-container">
<!-- 头像设置区域 -->
<view class="settings-card">
<view class="card-header">
<text class="card-title">头像设置</text>
</view>
<view class="avatar-section">
<view class="avatar-wrapper">
<view class="avatar-circle">
<img :src="Service.GetMateUrlByImg(userInfo.headImg)" alt="" style="width: 100%; height: 100%;" />
</view>
<view class="avatar-edit" @click="changeAvatar">
<u-icon name="camera" size="20" color="#fff"></u-icon>
</view>
</view>
<text class="avatar-tip">点击更换头像</text>
</view>
</view>
<!-- 基本信息设置 -->
<view class="settings-card">
<view class="card-header">
<text class="card-title">基本信息</text>
</view>
<view class="form-section">
<view class="form-group">
<text class="form-label">用户姓名</text>
<view class="input-wrapper">
<input
class="form-input"
v-model="userInfo.name"
placeholder="请输入用户姓名"
type="text"
maxlength="20"
/>
</view>
</view>
<view class="form-divider"></view>
<view class="form-group">
<text class="form-label">手机号码</text>
<view class="input-wrapper">
<input
class="form-input"
v-model="userInfo.phone"
placeholder="请输入手机号码"
type="number"
maxlength="11"
/>
</view>
</view>
</view>
</view>
<!-- 保存按钮 -->
<view class="save-section">
<button class="save-btn" @click="saveUserInfo">
<text class="btn-text">保存修改</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref } from 'vue'
import { userService } from '@/Service/swimming/userService'
const userInfo = ref<any>({
headImg:'',
name: '',
phone: ''
})
onLoad(() => {
loadUserInfo()
})
onShow(() => {
})
const loadUserInfo = () => {
userService.GetUserInfo().then((content) => {
if (content.code == 0) {
userInfo.value=content.data.userInfo
} else {
Service.Msg(content.msg)
}
})
}
// 更换头像
const changeAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
let path = res.tempFiles[0].path
Service.uploadH5(path, 'Avatar', data => {
userInfo.value.headImg = data
})
}
})
}
// 保存用户信息
const saveUserInfo = () => {
if (!userInfo.value.name.trim()) {
Service.Msg('请输入用户姓名')
return
}
if (!userInfo.value.phone.trim()) {
Service.Msg('请输入手机号码')
return
}
// 简单的手机号验证
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(userInfo.value.phone)) {
Service.Msg('请输入正确的手机号码')
return
}
// 调用UpdateUserInfo接口
userService.UpdateUserInfo(userInfo.value.name, userInfo.value.phone, userInfo.value.headImg).then((content) => {
if (content.code == 0) {
Service.Msg('保存成功', 'success')
setTimeout(() => {
Service.GoPageBack()
}, 1000)
} else {
Service.Msg(content.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.settings-container {
padding: 20rpx;
padding-bottom: 180rpx;
}
/* 页面标题区域 */
.header-section {
margin-bottom: 24rpx;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* 设置卡片 */
.settings-card {
background-color: #fff;
border-radius: 24rpx;
padding: 0;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
overflow: hidden;
.card-header {
padding: 24rpx 24rpx 16rpx;
border-bottom: 1rpx solid #f0f0f0;
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
}
}
/* 头像设置区域 */
.avatar-section {
padding: 40rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
.avatar-wrapper {
position: relative;
margin-bottom: 20rpx;
.avatar-circle {
width: 160rpx;
height: 160rpx;
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 50%, #096dd9 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.3);
overflow: hidden;
}
.avatar-edit {
position: absolute;
bottom: 0;
right: 0;
width: 56rpx;
height: 56rpx;
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(82, 196, 26, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.9);
}
}
}
.avatar-tip {
font-size: 24rpx;
color: #999;
}
}
/* 表单区域 */
.form-section {
padding: 20rpx 24rpx;
.form-group {
padding: 20rpx 0;
.form-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.input-wrapper {
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
transition: all 0.2s ease;
&:focus-within {
background-color: #fff;
box-shadow: 0 0 0 4rpx rgba(24, 144, 255, 0.15);
}
.form-input {
width: 100%;
height: 88rpx;
font-size: 28rpx;
color: #333;
}
}
}
.form-divider {
height: 1rpx;
background-color: #f0f0f0;
margin: 0;
}
}
/* 保存按钮区域 */
.save-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
.save-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
.btn-text {
font-size: 32rpx;
color: #fff;
font-weight: 700;
}
}
}
</style>

View File

@@ -0,0 +1,927 @@
<template>
<view class="course-container">
<!-- 表单区域 -->
<view class="form-section">
<!-- 项目名称 -->
<view class="form-card">
<view class="form-title">项目信息</view>
<view class="form-group">
<text class="form-label">项目名称</text>
<input class="form-input" v-model="courseData.projectName" placeholder="请输入项目名称"
placeholder-class="input-placeholder" />
</view>
</view>
<!-- 出发方式 -->
<view class="form-card">
<view class="form-title">出发方式</view>
<view class="radio-group">
<view class="radio-item" :class="{ active: courseData.startType === 'together' }"
@click="courseData.startType = 'together'">
<view class="radio-icon">
<view v-if="courseData.startType === 'together'" class="radio-inner"></view>
</view>
<text>一起出发</text>
</view>
<view class="radio-item" :class="{ active: courseData.startType === 'interval' }"
@click="courseData.startType = 'interval'">
<view class="radio-icon">
<view v-if="courseData.startType === 'interval'" class="radio-inner"></view>
</view>
<text>间隔出发</text>
</view>
</view>
<view v-if="courseData.startType === 'interval'" class="interval-input-wrapper">
<text class="interval-label">间隔时间</text>
<view class="interval-input">
<input class="number-input" v-model="courseData.intervalSeconds" type="digit"
placeholder="请输入秒数" />
<text class="unit-text"></text>
</view>
</view>
</view>
<!-- 组别设置 -->
<view v-if="type==1" class="form-card">
<view class="form-title">组别设置</view>
<view class="group-options">
<view class="person-input-wrapper">
<text class="person-label">每组人数</text>
<view class="person-input">
<input class="number-input" v-model="courseData.groupPersonCount" type="number"
placeholder="请输入人数" />
<text class="unit-text"></text>
</view>
</view>
</view>
</view>
<!-- 分段设置 -->
<view v-if="type==2" class="form-card">
<view class="form-title">分段设置</view>
<view class="segment-options">
<view class="segment-input-wrapper">
<text class="segment-label">分段距离</text>
<view class="segment-input">
<input class="number-input" v-model="courseData.segmentDistance" type="number"
placeholder="请输入距离" />
<text class="unit-text"></text>
</view>
</view>
<view class="segment-count-wrapper">
<text class="segment-count-label">分段数</text>
<view class="segment-count-input">
<input class="number-input" v-model="courseData.segmentCount" type="number"
placeholder="请输入段数" />
<text class="unit-text"></text>
</view>
</view>
</view>
</view>
<!-- 学生列表 -->
<view class="form-card">
<view class="student-header">
<view class="header-left">
<text class="form-title" style="margin-bottom: 0;">选择学生</text>
<text class="student-count">已选({{ selectedStudentIds.length }}/{{ allStudents.length }})</text>
</view>
<view class="header-actions">
<view class="select-all-btn" @click="toggleSelectAll">
<view class="checkbox-icon" :class="{ checked: allSelected }">
<u-icon v-if="allSelected" name="checkmark" size="14" color="#fff"></u-icon>
</view>
<text class="select-all-text">{{ allSelected ? '取消全选' : '全选' }}</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="allStudents.length === 0" class="empty-student-state">
<view class="empty-icon">
<u-icon name="account" size="48" color="#ddd"></u-icon>
</view>
<text class="empty-text">暂无学生</text>
<text class="empty-desc">请先在学员管理中添加学生</text>
</view>
<view v-else class="student-list">
<view v-for="student in allStudents" :key="student.studentId" class="student-item"
:class="{ checked: selectedStudentIds.includes(student.studentId) }"
@click="toggleStudentSelect(student.studentId)">
<view class="student-checkbox"
:class="{ checked: selectedStudentIds.includes(student.studentId) }">
<u-icon v-if="selectedStudentIds.includes(student.studentId)" name="checkmark" size="14"
color="#fff"></u-icon>
</view>
<view class="student-avatar">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
<view class="student-info">
<view class="student-name">{{ student.name }}</view>
</view>
</view>
</view>
<!-- 已选学生预览 -->
<view v-if="selectedStudents.length > 0" class="selected-preview">
<view class="preview-header">
<text class="preview-title">已选学生</text>
</view>
<view class="preview-list">
<view v-for="(student, index) in selectedStudents" :key="student.studentId" class="preview-item">
<text class="preview-index">{{ index + 1 }}</text>
<text class="preview-name">{{ student.name }}</text>
<view class="preview-remove" @click.stop="removeSelectedStudent(student.studentId)">
<u-icon name="close" size="14" color="#ff4d4f"></u-icon>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="" style="width: 100%; height: 80rpx;">
</view>
<!-- 底部按钮区域 -->
<view class="bottom-actions">
<view class="action-buttons">
<button class="confirm-btn" @click="confirmCreate()">保存项目</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Service } from '@/Service/Service'
import { onLoad } from '@dcloudio/uni-app'
import { PlanService } from '@/Service/swimming/PlanService'
import { studentService } from '@/Service/swimming/studentService'
// 课程数据
const courseData = ref({
projectName: '',
startType: 'together', // together | interval
intervalSeconds: '',
groupPersonCount: '',
segmentDistance: '',
segmentCount: ''
})
// 所有学生列表
const allStudents = ref<Array<any>>([])
// 已选学生ID列表
const selectedStudentIds = ref<string[]>([])
// 加载状态
const loading = ref(false)
let type = ref('')
let planId=ref('')
onLoad((data : any) => {
type.value = data.type
if(data.id){
planId.value=data.id
getPlanInfo()
}
getStudentList()
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
selectedStudentIds.value=res.data.plan.users.map((item:any)=>{
return item.studentId
})
courseData.value.groupPersonCount=res.data.plan.groupInt
courseData.value.segmentDistance=res.data.plan.subsectionDistance
courseData.value.segmentCount=res.data.plan.subsectionInt
courseData.value.projectName=res.data.plan.name
courseData.value.startType = res.data.plan.departType == '间隔出发' ? 'interval' : 'together'
courseData.value.intervalSeconds = res.data.plan.interval
} else {
Service.Msg(res.msg)
}
})
}
// 是否全选
const allSelected = computed(() => {
return allStudents.value.length > 0 && allStudents.value.every(s => selectedStudentIds.value.includes(s.studentId))
})
// 已选学生列表(按选择顺序排序)
const selectedStudents = computed(() => {
return selectedStudentIds.value
.map(id => allStudents.value.find(s => s.studentId === id))
.filter((s) : s is Student => s !== undefined)
})
// 获取学生列表(模拟接口)
const getStudentList = async () => {
loading.value = true
studentService.GetStudentList().then(res => {
if (res.code == 0) {
allStudents.value = res.data
loading.value = false
} else {
Service.Msg(res.msg)
}
})
}
// 切换学生选中状态
const toggleStudentSelect = (id : string) => {
const index = selectedStudentIds.value.indexOf(id)
if (index > -1) {
selectedStudentIds.value.splice(index, 1)
} else {
selectedStudentIds.value.push(id)
}
}
// 全选/取消全选
const toggleSelectAll = () => {
if (allSelected.value) {
// 取消全选
selectedStudentIds.value = []
} else {
// 全选
selectedStudentIds.value = allStudents.value.map(s => s.studentId)
}
}
// 移除已选学生
const removeSelectedStudent = (id : string) => {
const index = selectedStudentIds.value.indexOf(id)
if (index > -1) {
selectedStudentIds.value.splice(index, 1)
}
}
// 保存项目
const confirmCreate = () => {
if (!courseData.value.projectName) {
Service.Msg('请输入项目名称!')
return
}
if (courseData.value.startType == 'interval' && !courseData.value.intervalSeconds) {
Service.Msg('请输入正确时间间隔!')
return
}
if (!courseData.value.groupPersonCount && type.value=='1') {
Service.Msg('请输入每组人数!')
return
}
if (!courseData.value.segmentDistance && type.value=='2') {
Service.Msg('请输入分段距离')
return
}
if (!courseData.value.segmentCount && type.value=='2') {
Service.Msg('请输入分段数')
return
}
let users=[]
selectedStudents.value.map((item:any)=>{
users.push({
studentId:item.studentId,
name:item.name
})
})
const data = {
planId: planId.value,
name: courseData.value.projectName,
planType:type.value=='1'?'计时项目':'分段项目',
departType: courseData.value.startType=='together'?'一起出发':'间隔出发',
interval: courseData.value.startType=='together'?0:courseData.value.intervalSeconds,
groupInt: courseData.value.groupPersonCount?courseData.value.groupPersonCount:'',
subsectionDistance: courseData.value.segmentDistance?courseData.value.segmentDistance:'',
subsectionInt: courseData.value.segmentCount?courseData.value.segmentCount:'',
users: JSON.stringify(users),
project: '',
group: '',
}
console.log(data);
PlanService.AddPlan(data).then(res=>{
if(res.code==0){
Service.Msg('添加成功!')
setTimeout(()=>{
Service.GoPageBack()
},1000)
}else{
Service.Msg(res.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.course-container {
min-height: 100vh;
padding-bottom: 140rpx;
}
/* 表单区域 */
.form-section {
padding: 20rpx;
}
.form-card {
background-color: #fff;
border-radius: 24rpx;
padding: 32rpx 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.form-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
margin-bottom: 28rpx;
display: block;
}
}
/* 表单输入 */
.form-group {
margin-bottom: 32rpx;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 14rpx;
}
.form-input {
width: 100%;
height: 88rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
transition: all 0.2s ease;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 4rpx rgba(24, 144, 255, 0.15);
}
}
}
/* 单选按钮组 */
.radio-group {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
.radio-item {
flex: 1;
height: 88rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: 28rpx;
color: #666;
transition: all 0.25s ease;
border: 2rpx solid transparent;
&:active {
transform: scale(0.96);
}
&.active {
font-weight: 600;
background-color: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
}
.radio-icon {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 3rpx solid #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
&.active .radio-icon {
border-color: #1890ff;
background-color: #1890ff;
}
.radio-inner {
width: 12rpx;
height: 12rpx;
background-color: #fff;
border-radius: 50%;
}
}
}
/* 间隔时间输入 */
.interval-input-wrapper {
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.interval-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.interval-input {
flex: 1;
margin-left: 24rpx;
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 0 20rpx;
height: 72rpx;
.number-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.unit-text {
font-size: 24rpx;
color: #999;
margin-left: 12rpx;
}
}
}
/* 组别选项 */
.group-options {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.group-input-wrapper,
.person-input-wrapper {
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.group-label,
.person-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
}
.group-input,
.person-input {
flex: 1;
margin-left: 24rpx;
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 0 20rpx;
height: 72rpx;
.number-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.unit-text {
font-size: 24rpx;
color: #999;
margin-left: 12rpx;
}
}
/* 分段设置选项 */
.segment-options {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.segment-input-wrapper,
.segment-count-wrapper {
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.segment-label,
.segment-count-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
}
.segment-input,
.segment-count-input {
flex: 1;
margin-left: 24rpx;
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 0 20rpx;
height: 72rpx;
.number-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.unit-text {
font-size: 24rpx;
color: #999;
margin-left: 12rpx;
}
}
/* 学生列表头部 */
.student-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.header-left {
display: flex;
align-items: center;
gap: 8rpx;
.student-count {
font-size: 26rpx;
color: #999;
font-weight: 500;
}
}
.header-actions {
.select-all-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
transition: all 0.2s ease;
&:active {
transform: scale(0.96);
}
.checkbox-icon {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
&.checked {
color: #1890ff;
}
}
.select-all-text {
font-size: 24rpx;
color: #666;
}
}
}
}
/* 加载状态 */
.loading-state {
padding: 80rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #f0f0f0;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20rpx;
}
.loading-text {
font-size: 26rpx;
color: #999;
}
}
/* 空状态 */
.empty-student-state {
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
.empty-icon {
margin-bottom: 20rpx;
opacity: 0.6;
}
.empty-text {
font-size: 28rpx;
font-weight: 600;
color: #666;
margin-bottom: 10rpx;
}
.empty-desc {
font-size: 24rpx;
color: #999;
}
}
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
gap: 12rpx;
.student-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
border: 2rpx solid transparent;
transition: all 0.25s ease;
&:active {
transform: scale(0.98);
}
&.checked {
background-color: #e6f7ff;
border-color: #1890ff;
}
.student-checkbox {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
&.checked {
border-color: #1890ff;
background-color: #1890ff;
}
}
.student-avatar {
width: 64rpx;
height: 64rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.avatar-text {
color: #fff;
font-size: 28rpx;
font-weight: 700;
}
}
.student-info {
flex: 1;
min-width: 0;
.student-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 6rpx;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.student-meta {
display: flex;
align-items: center;
gap: 12rpx;
.gender-badge {
font-size: 22rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
font-weight: 500;
&.male {
color: #1890ff;
background-color: #e6f7ff;
}
&.female {
color: #fa8c16;
background-color: #fff7e6;
}
}
.age-text {
font-size: 22rpx;
color: #999;
}
}
}
}
}
/* 已选学生预览 */
.selected-preview {
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
.preview-header {
margin-bottom: 16rpx;
.preview-title {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
}
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.preview-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
background-color: #e6f7ff;
border-radius: 12rpx;
.preview-index {
width: 24rpx;
height: 24rpx;
background-color: #1890ff;
color: #fff;
font-size: 18rpx;
font-weight: 600;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.preview-name {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
.preview-remove {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:active {
transform: scale(0.9);
}
}
}
}
/* 底部操作按钮 */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 99;
.action-buttons {
display: flex;
gap: 16rpx;
}
}
.cancel-btn,
.confirm-btn {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
border: none;
font-size: 30rpx;
font-weight: 600;
transition: all 0.25s ease;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666;
&:active {
background-color: #e8e8e8;
transform: scale(0.96);
}
}
.confirm-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #fff;
box-shadow: 0 6rpx 16rpx rgba(24, 144, 255, 0.35);
&:active {
transform: scale(0.96);
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.25);
}
}
/* 动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,853 @@
<template>
<view class="student-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">学员管理</text>
<text class="subtitle">{{ students.length }}位学员</text>
</view>
</view>
<!-- 学员列表 -->
<view class="students-section">
<view v-if="students.length === 0" class="empty-state">
<view class="empty-icon">
<u-icon name="account" size="64" color="#ddd"></u-icon>
</view>
<text class="empty-text">暂无学员</text>
<text class="empty-desc">点击下方按钮添加第一位学员</text>
</view>
<view v-else class="students-list">
<view v-for="(student, index) in students" :key="student.id" class="student-card">
<view class="student-info">
<view class="student-avatar">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
<view class="student-details">
<view class="detail-row">
<text class="student-name">{{ student.name }}</text>
<view class="student-gender" :class="student.sex === '男' ? 'male' : 'female'">
<u-icon :name="student.sex === '男' ? 'man' : 'woman'" size="14"></u-icon>
{{ student.sex }}
</view>
</view>
<view class="detail-row">
<text class="detail-label">出生日期</text>
<text class="detail-value">{{ student.birthday }}</text>
</view>
<view class="detail-row">
<text class="detail-label">年龄</text>
<text class="detail-value">{{ calculateAge(student.birthday) }}</text>
</view>
<view v-if="student.school" class="detail-row">
<text class="detail-label">学校</text>
<text class="detail-value">{{ student.school }}</text>
</view>
<view v-if="student.address" class="detail-row">
<text class="detail-label">住址</text>
<text class="detail-value address-text">{{ student.address }}</text>
</view>
</view>
</view>
<view class="student-actions">
<view class="action-btn edit-btn" @click="openEditModal(student)">
<u-icon name="edit-pen" size="18" color="#1890ff"></u-icon>
</view>
</view>
</view>
</view>
</view>
<view class="" style="width: 100%; height: 100rpx;" >
</view>
<!-- 底部添加按钮 -->
<view class="bottom-add-btn">
<button class="add-btn" @click="showAddModal = true">
<u-icon name="plus" size="24" color="#fff"></u-icon>
<text class="btn-text">添加学员</text>
</button>
</view>
<!-- 添加/编辑学员弹窗 -->
<view v-if="showAddModal || showEditModal" class="modal-overlay" @click="closeModal"></view>
<view v-if="showAddModal || showEditModal" class="student-modal">
<view class="modal-header">
<text class="modal-title">{{ showAddModal ? '添加学员' : '编辑学员' }}</text>
<view class="modal-close" @click="closeModal">
<u-icon name="close" size="20" color="#999"></u-icon>
</view>
</view>
<view class="modal-content">
<view class="form-group">
<text class="form-label">学员姓名</text>
<input class="form-input" v-model="formData.name" placeholder="请输入学员姓名" type="text" />
</view>
<view class="form-group">
<text class="form-label">性别</text>
<view class="gender-selector">
<view class="gender-option" :class="{ active: formData.gender === '男' }" @click="formData.gender = '男'">
<view class="gender-icon-box">
<u-icon name="man" size="20" :color="formData.gender === '男' ? '#fff' : '#1890ff'"></u-icon>
</view>
<text></text>
</view>
<view class="gender-option" :class="{ active: formData.gender === '女' }" @click="formData.gender = '女'">
<view class="gender-icon-box">
<u-icon name="woman" size="20" :color="formData.gender === '女' ? '#fff' : '#fa8c16'"></u-icon>
</view>
<text></text>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">出生日期</text>
<view class="date-picker-wrapper">
<picker mode="date" :value="formData.birthDate" @change="onBirthDateChange">
<view class="form-input date-picker" :class="{ placeholder: !formData.birthDate }">
<u-icon name="calendar" size="18" color="#1890ff"></u-icon>
<text>{{ formData.birthDate || '请选择出生日期' }}</text>
<u-icon name="arrow-down" size="14" color="#999"></u-icon>
</view>
</picker>
</view>
<view v-if="formData.birthDate" class="age-display">
<view class="age-icon">
<u-icon name="account" size="24" color="#fff"></u-icon>
</view>
<view class="age-info">
<text class="age-label">年龄</text>
<text class="age-value">{{ calculateAge(formData.birthDate) }}</text>
<text class="age-unit"></text>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">学校选填</text>
<input class="form-input" v-model="formData.school" placeholder="请输入学校名称" type="text" />
</view>
<view class="form-group">
<text class="form-label">家庭住址选填</text>
<input class="form-input" v-model="formData.address" placeholder="请输入家庭住址" type="text" />
</view>
</view>
<view class="modal-footer">
<button class="modal-cancel-btn" @click="closeModal">取消</button>
<button class="modal-confirm-btn" @click="saveStudent">
{{ showAddModal ? '添加' : '保存' }}
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad, onReachBottom } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { studentService } from '@/Service/swimming/studentService'
import { ref } from "vue"
// 分页相关
let page = ref(1)
let status = ref('loadmore')
// 学员列表
const students = ref<Array<any>>([])
// 弹窗状态
const showAddModal = ref(false)
const showEditModal = ref(false)
// 表单数据
const formData = ref<any>({})
// 当前编辑的学员
const editingStudent = ref<any>({})
onLoad(() => {
getData()
})
onReachBottom(() => {
getList()
})
// 计算年龄
const calculateAge = (birthDate : string) : number => {
if (!birthDate) return 0
const birth = new Date(birthDate)
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
// 出生日期变化时自动计算年龄
const onBirthDateChange = (e : any) => {
formData.value.birthDate = e.detail.value
formData.value.age = calculateAge(e.detail.value).toString()
}
// 打开编辑弹窗
const openEditModal = (student :any) => {
editingStudent.value = student
formData.value = {
id: student.studentId,
name: student.name,
gender: student.sex?student.sex: '男',
birthDate: student.birthday,
school: student.school,
address: student.address
}
showEditModal.value = true
}
// 关闭弹窗
const closeModal = () => {
showAddModal.value = false
showEditModal.value = false
editingStudent.value = null
formData.value = {
id: '',
name: '',
gender: '男',
age: '',
birthDate: '',
school: '',
address: ''
}
}
// 获取学员列表数据
const getData = () => {
students.value = []
page.value = 1
status.value = 'loadmore'
getList()
}
// 获取学员列表
const getList = () => {
if (status.value == 'loading' || status.value == 'nomore') {
return
}
status.value = 'loading'
studentService.GetStudentListPage(page.value.toString()).then(res => {
if (res.code == 0) {
students.value = [...students.value, ...res.data]
status.value = res.data.length == 10 ? 'loadmore' : 'nomore'
page.value++
} else {
Service.Msg(res.msg)
}
})
}
// 保存学员
const saveStudent = () => {
if (!formData.value.name) {
Service.Msg('请输入学员姓名')
return
}
if (!formData.value.birthDate) {
Service.Msg('请选择出生日期')
return
}
// 构造接口请求数据
const requestData = {
studentId:formData.value.id,
name: formData.value.name,
sex: formData.value.gender,
birthday: formData.value.birthDate,
school: formData.value.school ,
address: formData.value.address
}
console.log(requestData);
// 调用添加学员接口
studentService.Add(requestData).then((content) => {
if (content.code == 0) {
Service.Msg('添加成功')
getData()
} else {
Service.Msg(content.msg)
}
})
closeModal()
}
// 确认删除
const confirmDelete = (student : Student) => {
uni.showModal({
title: '确认删除',
content: `确定要删除学员「${student.name}」吗?`,
confirmColor: '#ff4d4f',
success: (res) => {
if (res.confirm) {
deleteStudent(student.id)
}
}
})
}
// 删除学员
const deleteStudent = (id : string) => {
studentService.Delete(id).then(res => {
if (res.code == 0) {
students.value = students.value.filter(s => s.id !== id)
Service.Msg('删除成功', 'success')
} else {
Service.Msg(res.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.student-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 180rpx;
}
/* 页面标题区域 */
.header-section {
margin-bottom: 24rpx;
.header-title {
display: flex;
align-items: center;
justify-content: space-between;
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 24rpx;
color: #999;
}
}
}
/* 学员列表区域 */
.students-section {
.empty-state {
background-color: #fff;
border-radius: 24rpx;
padding: 80rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.empty-icon {
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
font-size: 30rpx;
font-weight: 600;
color: #666;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #999;
}
}
}
.students-list {
.student-card {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
display: flex;
align-items: flex-start;
justify-content: space-between;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.04);
}
.student-info {
display: flex;
align-items: flex-start;
gap: 20rpx;
flex: 1;
.student-avatar {
width: 72rpx;
height: 72rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.25);
flex-shrink: 0;
.avatar-text {
color: #fff;
font-size: 32rpx;
font-weight: 700;
}
}
.student-details {
flex: 1;
.detail-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 10rpx;
&:last-child {
margin-bottom: 0;
}
}
.student-name {
font-size: 32rpx;
font-weight: 700;
color: #333;
}
.student-gender {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 24rpx;
font-weight: 500;
padding: 4rpx 12rpx;
border-radius: 12rpx;
&.male {
color: #1890ff;
background-color: #e6f7ff;
}
&.female {
color: #fa8c16;
background-color: #fff7e6;
}
}
.detail-label {
font-size: 24rpx;
color: #999;
flex-shrink: 0;
}
.detail-value {
font-size: 26rpx;
color: #666;
&.address-text {
font-size: 24rpx;
color: #888;
}
}
}
}
.student-actions {
display: flex;
gap: 12rpx;
flex-shrink: 0;
margin-top: 4rpx;
.action-btn {
width: 64rpx;
height: 64rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
&.edit-btn {
background-color: #e6f7ff;
&:active {
background-color: #bae7ff;
transform: scale(0.92);
}
}
&.delete-btn {
background-color: #fff1f0;
&:active {
background-color: #ffccc7;
transform: scale(0.92);
}
}
}
}
}
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
animation: fadeIn 0.2s ease;
}
.student-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 640rpx;
background-color: #fff;
border-radius: 28rpx;
z-index: 999;
animation: slideUp 0.3s ease;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
&:active {
background-color: #f5f5f5;
}
}
}
.modal-content {
padding: 36rpx 30rpx;
}
.modal-footer {
display: flex;
gap: 16rpx;
padding: 24rpx 30rpx 36rpx;
border-top: 1rpx solid #f0f0f0;
}
}
/* 表单样式 */
.form-group {
margin-bottom: 32rpx;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 14rpx;
}
.form-input {
width: 100%;
height: 88rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
transition: all 0.2s ease;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 4rpx rgba(24, 144, 255, 0.15);
}
&.date-picker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
line-height: 88rpx;
&.placeholder {
color: #999;
}
}
}
.date-picker-wrapper {
position: relative;
picker {
width: 100%;
}
}
.age-display {
margin-top: 24rpx;
padding: 28rpx 32rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 100rpx;
height: 100rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: 10%;
width: 60rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
}
.age-icon {
width: 72rpx;
height: 72rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(10rpx);
border: 2rpx solid rgba(255, 255, 255, 0.3);
}
.age-info {
flex: 1;
display: flex;
align-items: baseline;
gap: 8rpx;
.age-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.age-value {
font-size: 56rpx;
font-weight: 800;
color: #fff;
line-height: 1;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2);
}
.age-unit {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
}
}
.gender-selector {
display: flex;
gap: 20rpx;
.gender-option {
flex: 1;
height: 100rpx;
background-color: #f5f5f5;
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 26rpx;
color: #666;
transition: all 0.3s ease;
border: 2rpx solid transparent;
position: relative;
overflow: hidden;
&:active {
transform: scale(0.96);
}
&.active {
font-weight: 600;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-color: #1890ff;
color: #fff;
box-shadow: 0 6rpx 16rpx rgba(24, 144, 255, 0.3);
.gender-icon-box {
background-color: rgba(255, 255, 255, 0.2);
}
}
.gender-icon-box {
width: 56rpx;
height: 56rpx;
background-color: #e6f7ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
}
}
}
/* 弹窗按钮 */
.modal-cancel-btn,
.modal-confirm-btn {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
border: none;
font-size: 30rpx;
font-weight: 600;
transition: all 0.25s ease;
}
.modal-cancel-btn {
background-color: #f5f5f5;
color: #666;
&:active {
background-color: #e8e8e8;
transform: scale(0.96);
}
}
.modal-confirm-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #fff;
box-shadow: 0 6rpx 16rpx rgba(24, 144, 255, 0.35);
&:active {
transform: scale(0.96);
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.25);
}
}
/* 底部添加按钮 */
.bottom-add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 20rpx;
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
.add-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
.btn-text {
font-size: 32rpx;
color: #fff;
font-weight: 700;
}
}
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
</style>

View File

@@ -0,0 +1,722 @@
<template>
<view class="swim-timer-container">
<!-- 固定在顶部的计时栏 -->
<view class="total-time-section">
<view class="" style="position: relative;">
<view class="total-time">{{ formatTime(currentTime) }}</view>
<view class="" @click="Service.GoPage('/pages/userFunc/setCourse?type=1'+'&id='+planId)"
style="position: absolute; top: 0; right: 20rpx; ">
<up-icon name="setting" size="22"></up-icon>
</view>
</view>
<!-- 计划信息 -->
<view class="plan-info" v-if="planId">
<text class="plan-title">项目名称: {{ planName }}</text>
</view>
</view>
<!-- 选手列表 -->
<view class="athletes-section">
<view class="section-header">
<text class="section-title">间隔出发·{{ groupedAthletes.length }}</text>
<text class="athlete-count">{{ athletes.length }}位选手</text>
</view>
<view class="athletes-list">
<view v-for="group in groupedAthletes" :key="group.groupNumber" class="group-wrapper">
<view class="group-header">
<view class="group-title">
<u-icon name="grid" size="18" color="#1890ff"></u-icon>
<text class="group-label">{{ group.groupNumber }}</text>
</view>
<text class="group-count">{{ group.athletes.length }}</text>
</view>
<view class="group-athletes">
<view v-for="(athlete, indexInGroup) in group.athletes" :key="athlete.id"
@click="handleAthleteFirstButton(athlete, athletes.indexOf(athlete))" class="athlete-item"
:class="{ finished: athlete.finished }">
<view class="athlete-number">{{ athletes.indexOf(athlete) + 1 }}</view>
<view class="athlete-info">
<text class="athlete-name">{{ athlete.name }}</text>
</view>
<view class="time-wrapper">
<text class="athlete-time">{{ !athlete.time? ' 00:00:00 ':formatTime(athlete.time) }}</text>
<text class="best-time">最快:
{{ athlete.bestTime !== null ? formatTime(athlete.quicklyTime) : '无' }}</text>
</view>
<view class="" style="display: flex;align-items: center; gap: 10rpx;">
<view @click.stop="resetAthleteTime(athlete)">
<u-icon name="reload" :bold="true" size="24" color="#1890ff"></u-icon>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="" style="width: 100%; height: 100rpx;"></view>
<!-- 底部控制按钮 -->
<view class="control-buttons">
<view class="button-group">
<button v-if="!isRunning" class="start-btn" @click="startTimer">
<u-icon name="play-right" size="24" color="#fff"></u-icon>
<text class="btn-text">开始</text>
</button>
<button v-if="isRunning" class="record-btn" @click="recordNextAthlete">
<u-icon name="checkmark-circle" size="24" color="#fff"></u-icon>
<text class="btn-text">记录</text>
</button>
</view>
<view class="button-group">
<button v-if="!isRunning" class="reset-all-btn" @click="resetAll">
<u-icon name="reload" size="24" color="#fff"></u-icon>
<text class="btn-text">重置</text>
</button>
<button v-if="isRunning" class="reset-all-btn" @click="stopTimer">
<u-icon name="pause" size="24" color="#fff"></u-icon>
<text class="btn-text">暂停</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onUnmounted, computed, onMounted } from 'vue'
import { Service } from '@/Service/Service'
import { PlanService } from '@/Service/swimming/PlanService'
import { onLoad, onShow } from '@dcloudio/uni-app'
// 定义选手类型
interface Athlete {
id : string
number : string
name : string
gender : string
age : string
birthday : string
time : number
bestTime : number | null
finished : boolean
startOffset ?: number
}
// 定义分组类型
interface Group {
groupNumber : number
athletes : Athlete[]
}
// 计划ID
const planId = ref('')
// 项目名称
let planName=ref('')
// 选手列表
const athletes = ref<Array<any>>([])
// 分组设置
const groupSize = ref('0')
// 计时器状态
const isRunning = ref(false)
const currentTime = ref(0)
let timerInterval : number | null = null
let startTime : number = 0
// 当前要记录的选手索引
const currentAthleteIndex = ref(0)
// 秒表模式
const stopwatchMode = ref<'interval' | 'together'>('interval')
// 间隔时间(秒)
const intervalTime = ref('0')
onLoad((data : any) => {
planId.value = data.id
})
onShow(()=>{
getPlanInfo()
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
planName.value = res.data.plan.name
// 将计划数据转换为选手数据
athletes.value = res.data.plan.users
groupSize.value=res.data.plan.groupInt
stopwatchMode.value=res.data.plan.departType=='间隔出发'?'interval':'together'
intervalTime.value=res.data.plan.interval
} else {
Service.Msg(res.msg)
}
})
}
// 计算分组
const groupedAthletes = computed<Group[]>(() => {
const size = parseInt(groupSize.value) || 2
const groups : Group[] = []
let currentGroup : Group | null = null
athletes.value.forEach((athlete, index) => {
if (index % size === 0) {
currentGroup = {
groupNumber: groups.length + 1,
athletes: []
}
groups.push(currentGroup)
}
if (currentGroup) {
currentGroup.athletes.push(athlete)
}
})
return groups
})
// 计算选手的时间偏移量
const getAthleteOffset = (athlete : any) : number => {
if (stopwatchMode.value !== 'interval') {
return 0
}
const index = athletes.value.findIndex(a => a.studentId === athlete.studentId)
const interval = parseFloat(intervalTime.value) || 10
const size = parseInt(groupSize.value) || 2
// 根据分组计算偏移第n组偏移 (n-1) * interval
const groupNumber = Math.floor(index / size)
return groupNumber * interval
}
// 计算已完成的选手
const finishedAthletes = computed(() => {
return athletes.value.filter(a => a.finished)
})
// 格式化时间显示
const formatTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`
}
// 开始计时
const startTimer = () => {
if (isRunning.value) return
isRunning.value = true
startTime = Date.now() - currentTime.value * 1000
timerInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000
currentTime.value = elapsed
// 更新未完成选手的时间(考虑偏移量)
athletes.value.forEach(athlete => {
if (!athlete.finished) {
const offset = getAthleteOffset(athlete)
athlete.time = Math.max(0, elapsed - offset)
}
})
}, 10)
}
// 停止计时
const stopTimer = () => {
if (!isRunning.value) return
isRunning.value = false
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
// 记录下一个选手
const recordNextAthlete = () => {
// 查找下一个未完成的选手
while (currentAthleteIndex.value < athletes.value.length) {
const athlete = athletes.value[currentAthleteIndex.value]
if (athlete.finished) {
// 已手动计时的学生,跳过
currentAthleteIndex.value++
} else {
break
}
}
if (currentAthleteIndex.value >= athletes.value.length) {
stopTimer()
Service.Msg('所有选手已完成')
return
}
const athlete = athletes.value[currentAthleteIndex.value]
const offset = getAthleteOffset(athlete)
athlete.finished = true
athlete.time = Math.max(0, currentTime.value - offset)
athlete.startOffset = offset
currentAthleteIndex.value++
// 如果所有选手都完成了,停止计时
if (currentAthleteIndex.value >= athletes.value.length) {
stopTimer()
Service.Msg('所有选手已完成!')
}
}
// 重置选手时间
const resetAthleteTime = (athlete : Athlete) => {
athlete.time = 0
athlete.finished = false
athlete.startOffset = undefined
// 重新排序索引
reorderAthletes()
}
// 处理选手第一个按钮点击
const handleAthleteFirstButton = (athlete : Athlete, index : number) => {
// 如果是一起出发模式,记录该选手时间
if (athlete.finished) {
Service.Msg('该选手已记录')
return
}
const offset = getAthleteOffset(athlete)
athlete.finished = true
athlete.time = Math.max(0, currentTime.value - offset)
athlete.startOffset = offset
// 检查是否所有选手都完成了
const allFinished = athletes.value.every(a => a.finished)
if (allFinished) {
stopTimer()
Service.Msg('所有选手已完成!')
} else {
Service.Msg(`${athlete.name} 已记录`)
}
}
// 重新排序选手索引
const reorderAthletes = () => {
const unfinished = athletes.value.filter(a => !a.finished)
currentAthleteIndex.value = athletes.value.length - unfinished.length
}
// 记录所有未完成选手
const recordAll = () => {
let recordedCount = 0
athletes.value.forEach(athlete => {
if (!athlete.finished) {
athlete.finished = true
athlete.time = currentTime.value
recordedCount++
}
})
if (recordedCount > 0) {
Service.Msg(`已记录${recordedCount}位选手`, 'success')
} else {
Service.Msg('没有需要记录的选手')
}
}
// 重置所有
const resetAll = () => {
Service.Confirm('确定要重置所有计时吗?', () => {
stopTimer()
currentTime.value = 0
currentAthleteIndex.value = 0
athletes.value.forEach(athlete => {
athlete.time = 0
athlete.finished = false
athlete.startOffset = undefined
})
})
}
// 页面卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
}
})
</script>
<style lang="scss" scoped>
.swim-timer-container {
min-height: 100vh;
background-color: #f6f6f6;
padding: 20rpx 20rpx;
padding-top: 180rpx;
}
/* 总用时区域 - 固定在顶部 */
.total-time-section {
position: fixed;
top: 0rpx;
left: 0;
right: 0;
text-align: center;
padding: 40rpx 20rpx;
background-color: #f6f6f6;
z-index: 100;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
.total-time {
font-size: 80rpx;
font-weight: bold;
color: #ff0000;
}
.plan-info {
margin-top: 20rpx;
padding: 0 20rpx;
.plan-title {
font-size: 24rpx;
color: #666;
background-color: rgba(255, 255, 255, 0.8);
padding: 8rpx 16rpx;
border-radius: 16rpx;
display: inline-block;
}
}
}
/* 选手列表区域 */
.athletes-section {
margin-top: 20rpx;
margin-bottom: 120rpx;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333333;
}
.athlete-count {
font-size: 24rpx;
color: #999999;
background-color: #f5f5f5;
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
}
.athletes-list {
.group-wrapper {
margin-bottom: 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
overflow: hidden;
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
border-left: 6rpx solid #1890ff;
.group-title {
display: flex;
align-items: center;
gap: 12rpx;
.group-label {
font-size: 30rpx;
font-weight: 600;
color: #1890ff;
}
}
.group-count {
font-size: 24rpx;
color: #666;
background-color: rgba(255, 255, 255, 0.8);
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
}
.group-athletes {
padding: 8rpx 16rpx 16rpx 16rpx;
}
}
.athlete-item {
display: flex;
align-items: center;
padding: 24rpx;
margin-bottom: 12rpx;
background-color: #ffffff;
border-radius: 16rpx;
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.03);
transition: all 0.3s;
border: 2rpx solid transparent;
&.finished {
background-color: #f6ffed;
border: 2rpx solid #b7eb8f;
}
&.fastest {
background-color: #fff7e6;
border: 2rpx solid #ffd591;
}
.athlete-number {
width: 56rpx;
height: 56rpx;
background-color: #e6f7ff;
color: #1890ff;
font-size: 28rpx;
font-weight: 600;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.athlete-info {
flex: 1;
margin-right: 30rpx;
position: relative;
.athlete-name {
font-size: 30rpx;
font-weight: 500;
color: #333333;
margin-bottom: 6rpx;
display: block;
}
.athlete-meta {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.athlete-lane {
font-size: 24rpx;
color: #999999;
}
.start-offset {
font-size: 20rpx;
color: #fa8c16;
background-color: #fff7e6;
padding: 2rpx 10rpx;
border-radius: 8rpx;
display: inline-flex;
align-items: center;
gap: 4rpx;
width: fit-content;
}
.fastest-tag {
position: absolute;
top: 0;
right: 0;
font-size: 20rpx;
color: #fa8c16;
background-color: #fff7e6;
padding: 2rpx 10rpx;
border-radius: 8rpx;
}
}
.time-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 20rpx;
min-width: 160rpx;
.athlete-time {
font-size: 32rpx;
font-weight: bold;
color: #ff0000;
font-family: 'DIN Alternate', monospace;
}
.best-time {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
}
.reset-btn {
width: 48rpx;
height: 48rpx;
background-color: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
/* 底部控制按钮 */
.control-buttons {
display: flex;
gap: 24rpx;
justify-content: center;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #ffffff 100%);
padding: 32rpx 40rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.08);
.button-group {
flex: 1;
display: flex;
justify-content: center;
}
.back-btn,
.start-btn,
.pause-btn,
.reset-all-btn,
.record-btn {
flex: 1;
height: 100rpx;
border-radius: 20rpx;
border: none;
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
border-radius: 20rpx 20rpx 0 0;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2rpx;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%);
}
:deep(.u-icon) {
position: relative;
z-index: 1;
}
}
.start-btn {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(82, 196, 26, 0.4);
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.3);
}
}
.pause-btn {
background: linear-gradient(135deg, #fa541c 0%, #ff7a45 100%);
color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(250, 84, 28, 0.4);
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(250, 84, 28, 0.3);
}
}
.reset-all-btn {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(24, 144, 255, 0.4);
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(24, 144, 255, 0.3);
}
}
.back-btn {
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(114, 46, 209, 0.4);
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(114, 46, 209, 0.3);
}
}
.record-btn {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(82, 196, 26, 0.4);
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.3);
}
}
.btn-text {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
letter-spacing: 2rpx;
position: relative;
z-index: 1;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
}
</style>

BIN
src/static/index/Flag.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/static/index/pause.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,320 @@
## 2.5.0-202301012023-01-01
- 秋云图表组件 修改条件编译顺序确保uniapp的cli方式的项目依赖不完整时可以正常显示
- 秋云图表组件 恢复props属性directory的使用以修复vue3项目中开启echarts后echarts目录识别错误的bug
- uCharts.js 修复区域图、混合图只有一个数据时图表显示不正确的bug
- uCharts.js 修复折线图、区域图中时间轴类别图表tooltip指示点显示不正确的bug
- uCharts.js 修复x轴使用labelCount时并且boundaryGap = 'justify' 并且关闭Y轴显示的时候最后一个坐标值不显示的bug
- uCharts.js 修复折线图只有一组数据时 ios16 渲染颜色不正确的bug
- uCharts.js 修复玫瑰图半径显示不正确的bug
- uCharts.js 柱状图、山峰图增加正负图功能y轴网格如果需要显示0轴则由 min max 及 splitNumber 确定后续版本优化自动显示0轴
- uCharts.js 柱状图column增加 opts.extra.column.labelPosition数据标签位置有效值为 outside外部, insideTop内顶部, center内中间, bottom内底部
- uCharts.js 雷达图radar增加 opts.extra.radar.labelShow否显示各项标识文案是默认true
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.boxPadding提示窗边框填充距离默认3px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.fontSize提示窗字体大小配置默认13px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.lineHeight提示窗文字行高默认20px
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.legendShow是否显示左侧图例默认true
- uCharts.js 提示窗tooltip增加 opts.extra.tooltip.legendShape图例形状图例标识样式有效值为 auto自动跟随图例, diamond◆, circle●, triangle▲, square■, rect▬, line-
- uCharts.js 标记线markLine增加 opts.extra.markLine.labelFontSize字体大小配置默认13px
- uCharts.js 标记线markLine增加 opts.extra.markLine.labelPadding标签边框内填充距离默认6px
- uCharts.js 折线图line增加 opts.extra.line.linearType渐变色类型可选值 none关闭渐变色custom 自定义渐变色。使用自定义渐变色时请赋值serie.linearColor作为颜色值
- uCharts.js 折线图line增加 serie.linearColor渐变色数组格式为2维数组[起始位置,颜色值],例如[[0,'#0EE2F8'],[0.3,'#2BDCA8'],[0.6,'#1890FF'],[1,'#9A60B4']]
- uCharts.js 折线图line增加 opts.extra.line.onShadow是否开启折线阴影开启后请赋值serie.setShadow阴影设置
- uCharts.js 折线图line增加 serie.setShadow阴影配置格式为4位数组[offsetX,offsetY,blur,color]
- uCharts.js 折线图line增加 opts.extra.line.animation动画效果方向可选值为vertical 垂直动画效果horizontal 水平动画效果
- uCharts.js X轴xAxis增加 opts.xAxis.lineHeightX轴字体行高默认20px
- uCharts.js X轴xAxis增加 opts.xAxis.marginTopX轴文字距离轴线的距离默认0px
- uCharts.js X轴xAxis增加 opts.xAxis.title当前X轴标题
- uCharts.js X轴xAxis增加 opts.xAxis.titleFontSize标题字体大小默认13px
- uCharts.js X轴xAxis增加 opts.xAxis.titleOffsetY标题纵向偏移距离负数为向上偏移正数向下偏移
- uCharts.js X轴xAxis增加 opts.xAxis.titleOffsetX标题横向偏移距离负数为向左偏移正数向右偏移
- uCharts.js X轴xAxis增加 opts.xAxis.titleFontColor标题字体颜色默认#666666
## 报错TypeError: Cannot read properties of undefined (reading 'length')
- 如果是uni-modules版本组件请先登录HBuilderX账号
- 在HBuilderX中的manifest.json点击重新获取uniapp的appid或者删除appid重新粘贴重新运行
- 如果是cli项目请使用码云上的非uniCloud版本组件
- 或者添加uniCloud的依赖
- 或者使用原生uCharts
## 2.4.5-202211302022-11-30
- uCharts.js 优化tooltip当文字很多变为左侧显示时如果画布仍显显示不下提示框错位置变为以左侧0位置起画
- uCharts.js 折线图修复特殊情况下只有单点数据并改变线宽后点变为圆形的bug
- uCharts.js 修复Y轴disabled启用后无效并报错的bug
- uCharts.js 修复仪表盘起始结束角度特殊情况下显示不正确的bug
- uCharts.js 雷达图新增参数 opts.extra.radar.radius , 自定义雷达图半径
- uCharts.js 折线图、区域图增加tooltip指示点opts.extra.line.activeType/opts.extra.area.activeType可选值"none"不启用激活指示点,"hollow"空心点模式,"solid"实心点模式
## 2.4.4-202211022022-11-02
- 秋云图表组件 修复使用echarts时reload、reshow无法调用重新渲染的bug[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/40)
- 秋云图表组件 修复使用echarts时初始化时宽高不正确的bug[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/42)
- 秋云图表组件 修复uniapp的h5使用history模式时无法加载echarts的bug
- 秋云图表组件 小程序端@complete@scrollLeft@scrollRight@getTouchStart@getTouchMove@getTouchEnd事件增加opts参数传出,方便一些特殊需求的交互获取数据。
- uCharts.js 修复calTooltipYAxisData方法内formatter格式化方法未与y轴方法同步的问题[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/43)
- uCharts.js 地图新增参数opts.series[i].fillOpacity以透明度方式来设置颜色过度效果[详见码云PR](https://gitee.com/uCharts/uCharts/pulls/38)
- uCharts.js 地图新增参数opts.extra.map.active是否启用点击激活变色
- uCharts.js 地图新增参数opts.extra.map.activeTextColor是否启用点击激活变色
- uCharts.js 地图新增渲染完成事件renderComplete
- uCharts.js 漏斗图修复当部分数据相同时tooltip提示窗点击错误的bug
- uCharts.js 漏斗图新增参数series.data[i].centerText 居中标签文案
- uCharts.js 漏斗图新增参数series.data[i].centerTextSize 居中标签文案字体大小默认opts.fontSize
- uCharts.js 漏斗图新增参数series.data[i].centerTextColor 居中标签文案字体颜色,默认#FFFFFF
- uCharts.js 漏斗图新增参数opts.extra.funnel.minSize 最小值的最小宽度默认0
- uCharts.js 进度条新增参数opts.extra.arcbar.direction动画方向可选值为cw顺时针、ccw逆时针
- uCharts.js 混合图新增参数opts.extra.mix.line.width折线的宽度默认2
- uCharts.js 修复tooltip开启horizentalLine水平横线标注时图表显示错位的bug
- uCharts.js 优化tooltip当文字很多变为左侧显示时如果画布仍显显示不下提示框错位置变为以左侧0位置起画
- uCharts.js 修复开启滚动条后X轴文字超出绘图区域后的隐藏逻辑
- uCharts.js 柱状图、条状图修复堆叠模式不能通过{value,color}赋值单个柱子颜色的问题
- uCharts.js 气泡图修复不识别series.textSize和series.textColor的bug
## 报错TypeError: Cannot read properties of undefined (reading 'length')
1. 如果是uni-modules版本组件请先登录HBuilderX账号
2. 在HBuilderX中的manifest.json点击重新获取uniapp的appid或者删除appid重新粘贴重新运行
3. 如果是cli项目请使用码云上的非uniCloud版本组件
4. 或者添加uniCloud的依赖
5. 或者使用原生uCharts
## 2.4.3-202205052022-05-05
- 秋云图表组件 修复开启canvas2d后将series赋值为空数组显示加载图标时再次赋值后画布闪动的bug
- 秋云图表组件 修复升级hbx最新版后ECharts的highlight方法报错的bug
- uCharts.js 雷达图新增参数opts.extra.radar.gridEval数据点位网格抽希默认1
- uCharts.js 雷达图新增参数opts.extra.radar.axisLabel 是否显示刻度点值默认false
- uCharts.js 雷达图新增参数opts.extra.radar.axisLabelTofix刻度点值小数位数默认0
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointShow是否显示末端刻度圆点默认false
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointRadius刻度圆点的半径默认3
- uCharts.js 雷达图新增参数opts.extra.radar.labelPointColor刻度圆点的颜色默认#cccccc
- uCharts.js 雷达图新增参数opts.extra.radar.linearType渐变色类型可选值"none"关闭渐变,"custom"开启渐变
- uCharts.js 雷达图新增参数opts.extra.radar.customColor自定义渐变颜色数组类型对应series的数组长度以匹配不同series颜色的不同配色方案例如["#FA7D8D", "#EB88E2"]
- uCharts.js 雷达图优化支持series.textColor、series.textSize属性
- uCharts.js 柱状图中温度计式图标优化支持全圆角类型修复边框有缝隙的bug详见官网【演示】中的温度计图表
- uCharts.js 柱状图新增参数opts.extra.column.activeWidth当前点击柱状图的背景宽度默认一个单元格单位
- uCharts.js 混合图增加opts.extra.mix.area.gradient 区域图是否开启渐变色
- uCharts.js 混合图增加opts.extra.mix.area.opacity 区域图透明度默认0.2
- uCharts.js 饼图、圆环图、玫瑰图、漏斗图增加opts.series[0].data[i].labelText自定义标签文字避免formatter格式化的繁琐详见官网【演示】中的饼图
- uCharts.js 饼图、圆环图、玫瑰图、漏斗图增加opts.series[0].data[i].labelShow自定义是否显示某一个指示标签避免因饼图类别太多导致标签重复或者居多导致图形变形的问题详见官网【演示】中的饼图
- uCharts.js 增加opts.series[i].legendText/opts.series[0].data[i].legendText与series.name同级自定义图例显示文字的方法
- uCharts.js 优化X轴、Y轴formatter格式化方法增加形参统一为fromatter:function(value,index,opts){}
- uCharts.js 修复横屏模式下无法使用双指缩放方法的bug
- uCharts.js 修复当只有一条数据或者多条数据值相等的时候Y轴自动计算的最大值错误的bug
- 【官网模板】增加外部自定义图例与图表交互的例子,[点击跳转](https://www.ucharts.cn/v2/#/layout/info?id=2)
## 注意非unimodules 版本如因更新 hbx 至 3.4.7 导致报错如下,请到码云更新非 unimodules 版本组件,[点击跳转](https://gitee.com/uCharts/uCharts/tree/master/uni-app/uCharts-%E7%BB%84%E4%BB%B6)
> Error in callback for immediate watcher "uchartsOpts": "SyntaxError: Unexpected token u in JSON at position 0"
## 2.4.2-202204212022-04-21
- 秋云图表组件 修复HBX升级3.4.6.20220420版本后echarts报错的问题
## 2.4.2-202204202022-04-20
## 重要此版本uCharts新增了很多功能修复了诸多已知问题
- 秋云图表组件 新增onzoom开启双指缩放功能仅uCharts前提需要直角坐标系类图表类型并且ontouch为true、opts.enableScroll为true详见实例项目K线图
- 秋云图表组件 新增optsWatch是否监听opts变化关闭optsWatch后动态修改opts不会触发图表重绘
- 秋云图表组件 修复开启canvas2d功能后动态更新数据后画布闪动的bug
- 秋云图表组件 去除directory属性改为自动获取echarts.min.js路径升级不受影响
- 秋云图表组件 增加getImage()方法及@getImage事件通过ref调用getImage()方法获,触发@getImage事件获取当前画布的base64图片文件流
- 秋云图表组件 支付宝、字节跳动、飞书、快手小程序支持开启canvas2d同层渲染设置。
- 秋云图表组件 新增加【非uniCloud】版本组件避免有些不需要uniCloud的使用组件发布至小程序需要提交隐私声明问题请到码云[【非uniCloud版本】](https://gitee.com/uCharts/uCharts/tree/master/uni-app/uCharts-%E7%BB%84%E4%BB%B6)或npm[【非uniCloud版本】](https://www.npmjs.com/package/@qiun/uni-ucharts)下载使用。
- uCharts.js 新增dobuleZoom双指缩放功能
- uCharts.js 新增山峰图type="mount"数据格式为饼图类格式不需要传入categories具体详见新版官网在线演示
- uCharts.js 修复折线图当数据中存在null时tooltip报错的bug
- uCharts.js 修复饼图类当画布比较小时自动计算的半径是负数报错的bug
- uCharts.js 统一各图表类型的series.formatter格式化方法的形参为(val, index, series, opts),方便格式化时有更多参数可用
- uCharts.js 标记线功能增加labelText自定义显示文字增加labelAlign标签显示位置左侧或右侧增加标签显示位置微调labelOffsetX、labelOffsetY
- uCharts.js 修复条状图当数值很小时开启圆角后样式错误的bug
- uCharts.js 修复X轴开启disabled后X轴仍占用空间的bug
- uCharts.js 修复X轴开启滚动条并且开启rotateLabel后X轴文字与滚动条重叠的bug
- uCharts.js 增加X轴rotateAngle文字旋转自定义角度取值范围(-90至90)
- uCharts.js 修复地图文字标签层级显示不正确的bug
- uCharts.js 修复饼图、圆环图、玫瑰图当数据全部为0的时候不显示数据标签的bug
- uCharts.js 修复当opts.padding上边距为0时Y轴顶部刻度标签位置不正确的bug
## 另外我们还开发了各大原生小程序组件已发布至码云和npm
[https://gitee.com/uCharts/uCharts](https://gitee.com/uCharts/uCharts)
[https://www.npmjs.com/~qiun](https://www.npmjs.com/~qiun)
## 对于原生uCharts文档我们已上线新版官方网站详情点击下面链接进入官网
[https://www.uCharts.cn/v2/](https://www.ucharts.cn/v2/)
## 2.3.7-202201222022-01-22
## 重要使用vue3编译请使用cli模式并升级至最新依赖HbuilderX编译需要使用3.3.8以上版本
- uCharts.js 修复uni-app平台组件模式使用vue3编译到小程序报错的bug。
## 2.3.7-202201182022-01-18
## 注意使用vue3的前提是需要3.3.8.20220114-alpha版本的HBuilder
## 2.3.67-202201182022-01-18
- 秋云图表组件 组件初步支持vue3全端编译会有些问题具体详见下面修改
1. 小程序端运行时在uni_modules文件夹的qiun-data-charts.js中搜索 new uni_modules_qiunDataCharts_js_sdk_uCharts_uCharts.uCharts将.uCharts去掉。
2. 小程序端发行时在uni_modules文件夹的qiun-data-charts.js中搜索 new e.uCharts将.uCharts去掉变为 new e。
3. 如果觉得上述步骤比较麻烦如果您的项目只编译到小程序端可以修改u-charts.js最后一行导出方式将 export default uCharts;变更为 export default { uCharts: uCharts }; 这样变更后H5和App端的renderjs会有问题请开发者自行选择。此问题非组件问题请等待DC官方修复Vue3的小程序端
## 2.3.6-202201112022-01-11
- 秋云图表组件 修改组件 props 属性中的 background 默认值为 rgba(0,0,0,0)
## 2.3.6-202112012021-12-01
- uCharts.js 修复bar条状图开启圆角模式时值很小时圆角渲染错误的bug
## 2.3.5-202110142021-10-15
- uCharts.js 增加vue3的编译支持仅原生uChartsqiun-data-charts组件后续会支持请关注更新
## 2.3.4-202110122021-10-12
- 秋云图表组件 修复 mac os x 系统 mouseover 事件丢失的 bug
## 2.3.3-202107062021-07-06
- uCharts.js 增加雷达图开启数据点值opts.dataLabel的显示
## 2.3.2-202106272021-06-27
- 秋云图表组件 修复tooltipCustom个别情况下传值不正确报错TypeError: Cannot read property 'name' of undefined的bug
## 2.3.1-202106162021-06-16
- uCharts.js 修复圆角柱状图使用4角圆角时当数值过大时不正确的bug
## 2.3.0-202106122021-06-12
- uCharts.js 【重要】uCharts增加nvue兼容可在nvue项目中使用gcanvas组件渲染uCharts[详见码云uCharts-demo-nvue](https://gitee.com/uCharts/uCharts)
- 秋云图表组件 增加tapLegend属性是否开启图例点击交互事件
- 秋云图表组件 getIndex事件中增加返回uCharts实例中的opts参数以便在页面中调用参数
- 示例项目 pages/other/other.vue增加app端自定义tooltip的方法详见showOptsTooltip方法
## 2.2.1-202106032021-06-03
- uCharts.js 修复饼图、圆环图、玫瑰图当起始角度不为0时tooltip位置不准确的bug
- uCharts.js 增加温度计式柱状图开启顶部半圆形的配置
## 2.2.0-202105292021-05-29
- uCharts.js 增加条状图type="bar"
- 示例项目 pages/ucharts/ucharts.vue增加条状图的demo
## 2.1.7-202105242021-05-24
- uCharts.js 修复大数据量模式下曲线图不平滑的bug
## 2.1.6-202105232021-05-23
- 秋云图表组件 修复小程序端开启滚动条更新数据后滚动条位置不符合预期的bug
## 2.1.5-20210517022021-05-17
- uCharts.js 修复自定义Y轴min和max值为0时不能正确显示的bug
## 2.1.5-202105172021-05-17
- uCharts.js 修复Y轴自定义min和max时未按指定的最大值最小值显示坐标轴刻度的bug
## 2.1.4-202105162021-05-16
- 秋云图表组件 优化onWindowResize防抖方法
- 秋云图表组件 修复APP端uCharts更新数据时清空series显示loading图标后再显示图表图表抖动的bug
- uCharts.js 修复开启canvas2d后x轴、y轴、series自定义字体大小未按比例缩放的bug
- 示例项目 修复format-e.vue拼写错误导致app端使用uCharts渲染图表
## 2.1.3-202105132021-05-13
- 秋云图表组件 修改uCharts变更chartData数据为updateData方法支持带滚动条的数据动态打点
- 秋云图表组件 增加onWindowResize防抖方法 fix by ど誓言,如尘般染指流年づ
- 秋云图表组件 H5或者APP变更chartData数据显示loading图表时原数据闪现的bug
- 秋云图表组件 props增加errorReload禁用错误点击重新加载的方法
- uCharts.js 增加tooltip显示categoryx轴对应点位标题的功能opts.extra.tooltip.showCategory默认为false
- uCharts.js 修复mix混合图只有柱状图时tooltip的分割线显示位置不正确的bug
- uCharts.js 修复开启滚动条图表在拖动中动态打点滚动条位置不正确的bug
- uCharts.js 修复饼图类数据格式为echarts数据格式series为空数组报错的bug
- 示例项目 修改uCharts.js更新到v2.1.2版本后,@getIndex方法获取索引值变更为e.currentIndex.index
- 示例项目 pages/updata/updata.vue增加滚动条拖动更新数据动态打点的demo
- 示例项目 pages/other/other.vue增加errorReload禁用错误点击重新加载的demo
## 2.1.2-202105092021-05-09
秋云图表组件 修复APP端初始化时就传入chartData或lacaldata不显示图表的bug
## 2.1.1-202105092021-05-09
- 秋云图表组件 变更ECharts的eopts配置在renderjs内执行支持在config-echarts.js配置文件内写function配置。
- 秋云图表组件 修复APP端报错Prop being mutated: "onmouse"错误的bug。
- 秋云图表组件 修复APP端报错Error: Not FoundPage[6][-1,27] at view.umd.min.js:1的bug。
## 2.1.0-202105072021-05-07
- 秋云图表组件 修复初始化时就有数据或者数据更新的时候loading加载动画闪动的bug
- uCharts.js 修复x轴format方法categories为字符串类型时返回NaN的bug
- uCharts.js 修复series.textColor、legend.fontColor未执行全局默认颜色的bug
## 2.1.0-202105062021-05-06
- 秋云图表组件 修复极个别情况下报错item.properties undefined的bug
- 秋云图表组件 修复极个别情况下关闭加载动画reshow不起作用无法显示图表的bug
- 示例项目 pages/ucharts/ucharts.vue 增加时间轴折线图type="tline"、时间轴区域图type="tarea"、散点图type="scatter"、气泡图demotype="bubble"、倒三角形漏斗图opts.extra.funnel.type="triangle"、金字塔形漏斗图opts.extra.funnel.type="pyramid"
- 示例项目 pages/format-u/format-u.vue 增加X轴format格式化示例
- uCharts.js 升级至v2.1.0版本
- uCharts.js 修复 玫瑰图面积模式点击tooltip位置不正确的bug
- uCharts.js 修复 玫瑰图点击图例只剩一个类别显示空白的bug
- uCharts.js 修复 饼图类图点击图例其他图表tooltip位置某些情况下不准的bug
- uCharts.js 修复 x轴为矢量轴时间轴情况下点击tooltip位置不正确的bug
- uCharts.js 修复 词云图获取点击索引偶尔不准的bug
- uCharts.js 增加 直角坐标系图表X轴format格式化方法原生uCharts.js用法请使用formatter
- uCharts.js 增加 漏斗图扩展配置倒三角形opts.extra.funnel.type="triangle"金字塔形opts.extra.funnel.type="pyramid"
- uCharts.js 增加 散点图opts.type="scatter"、气泡图opts.type="bubble"
- 后期计划 完善散点图、气泡图增加markPoints标记点增加横向条状图。
## 2.0.0-202105022021-05-02
- uCharts.js 修复词云图获取点击索引不正确的bug
## 2.0.0-202105012021-05-01
- 秋云图表组件 修复QQ小程序、百度小程序在关闭动画效果情况下v-for循环使用图表显示不正确的bug
## 2.0.0-202104262021-04-26
- 秋云图表组件 修复QQ小程序不支持canvas2d的bug
- 秋云图表组件 修复钉钉小程序某些情况点击坐标计算错误的bug
- uCharts.js 增加 extra.column.categoryGap 参数柱状图类每个category点位X轴点柱子组之间的间距
- uCharts.js 增加 yAxis.data[i].titleOffsetY 参数,标题纵向偏移距离,负数为向上偏移,正数向下偏移
- uCharts.js 增加 yAxis.data[i].titleOffsetX 参数,标题横向偏移距离,负数为向左偏移,正数向右偏移
- uCharts.js 增加 extra.gauge.labelOffset 参数仪表盘标签文字径向便宜距离默认13px
## 2.0.0-20210422-22021-04-22
秋云图表组件 修复 formatterAssign 未判断 args[key] == null 的情况导致栈溢出的 bug
## 2.0.0-202104222021-04-22
- 秋云图表组件 修复H5、APP、支付宝小程序、微信小程序canvas2d模式下横屏模式的bug
## 2.0.0-202104212021-04-21
- uCharts.js 修复多行图例的情况下图例在上方或者下方时图例float为左侧或者右侧时第二行及以后的图例对齐方式不正确的bug
## 2.0.0-202104202021-04-20
- 秋云图表组件 修复微信小程序开启canvas2d模式后windows版微信小程序不支持canvas2d模式的bug
- 秋云图表组件 修改非uni_modules版本为v2.0版本qiun-data-charts组件
## 2.0.0-202104192021-04-19
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 修复混合图中柱状图单独设置颜色不生效的bug
- uCharts.js 修复多Y轴单独设置fontSize时开启canvas2d后未对应放大字体的bug
## 2.0.0-202104182021-04-18
- 秋云图表组件 增加directory配置修复H5端history模式下如果发布到二级目录无法正确加载echarts.min.js的bug
## 2.0.0-202104162021-04-16
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复APP端某些情况下报错`Not Found Page`的bugfix by 高级bug开发技术员
- 示例项目 修复APP端v-for循环某些情况下报错`Not Found Page`的bugfix by 高级bug开发技术员
- uCharts.js 修复非直角坐标系tooltip提示窗右侧超出未变换方向显示的bug
## 2.0.0-202104152021-04-15
- 秋云图表组件 修复H5端发布到二级目录下echarts无法加载的bug
- 秋云图表组件 修复某些情况下echarts.off('finished')移除监听事件报错的bug
## 2.0.0-202104142021-04-14
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX如仍不好用请重启电脑
## 如果是cli项目请尝试清理node_modules重新install还不行就删除项目再重新install。
## 此问题已于DCloud官方确认HBuilderX下个版本会修复。
## 其他图表不显示问题详见[常见问题选项卡](https://demo.ucharts.cn)
## <font color=#FF0000> 新手请先完整阅读帮助文档及常见问题3遍右侧蓝色按钮示例项目请看2遍 </font>
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在项目中的应用参见 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复H5端在cli项目下ECharts引用地址错误的bug
- 示例项目 增加ECharts的formatter用法的示例(详见示例项目format-e.vue)
- uCharts.js 增加圆环图中心背景色的配置extra.ring.centerColor
- uCharts.js 修复微信小程序安卓端柱状图开启透明色后显示不正确的bug
## 2.0.0-202104132021-04-13
- 秋云图表组件 修复百度小程序多个图表真机未能正确获取根元素dom尺寸的bug
- 秋云图表组件 修复百度小程序横屏模式方向不正确的bug
- 秋云图表组件 修改ontouch时@getTouchStart@getTouchMove@getTouchEnd的触发条件
- uCharts.js 修复饼图类数据格式series属性不生效的bug
- uCharts.js 增加时序区域图 详见示例项目中ucharts.vue
## 2.0.0-20210412-22021-04-12
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册&lt;qiun-data-charts&gt;组件请重启HBuilderX。如仍不好用请重启电脑此问题已于DCloud官方确认HBuilderX下个版本会修复。
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在uniCloudAdmin中的应用 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- 秋云图表组件 修复uCharts在APP端横屏模式下不能正确渲染的bug
- 示例项目 增加ECharts柱状图渐变色、圆角柱状图、横向柱状图条状图的示例
## 2.0.0-202104122021-04-12
- 秋云图表组件 修复created中判断echarts导致APP端无法识别改回mounted中判断echarts初始化
- uCharts.js 修复2d模式下series.textOffset未乘像素比的bug
## 2.0.0-202104112021-04-11
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧绿色【使用HBuilderX导入插件】即可使用示例项目请点击右侧蓝色按钮【使用HBuilderX导入示例项目】。
## 初次使用如果提示未注册<qiun-data-charts>组件请重启HBuilderX并清空小程序开发者工具缓存。
## [DEMO演示及在线生成工具v2.0文档https://demo.ucharts.cn](https://demo.ucharts.cn)
## [图表组件在uniCloudAdmin中的应用 UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 折线图区域图增加connectNulls断点续连的功能详见示例项目中ucharts.vue
- 秋云图表组件 变更初始化方法为created变更type2d默认值为true优化2d模式下组件初始化后dom获取不到的bug
- 秋云图表组件 修复左右布局时右侧图表点击坐标错误的bug修复tooltip柱状图自定义颜色显示object的bug
## 2.0.0-202104102021-04-10
- 修复左右布局时右侧图表点击坐标错误的bug修复柱状图自定义颜色tooltip显示object的bug
- 增加标记线及柱状图自定义颜色的demo
## 2.0.0-202104092021-04-08
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧【使用HBuilderX导入插件】即可体验DEMO演示及在线生成工具v2.0文档)[https://demo.ucharts.cn](https://demo.ucharts.cn)
## 图表组件在uniCloudAdmin中的应用 [UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
- uCharts.js 修复钉钉小程序百度小程序measureText不准确的bug修复2d模式下饼图类activeRadius为按比例放大的bug
- 修复组件在支付宝小程序端点击位置不准确的bug
## 2.0.0-202104082021-04-07
- 修复组件在支付宝小程序端不能显示的bug目前支付宝小程不能点击交互后续修复
- uCharts.js 修复高分屏下柱状图类,圆弧进度条 自定义宽度不能按比例放大的bug
## 2.0.0-202104072021-04-06
## v1.0版本已停更建议转uni_modules版本组件方式调用点击右侧【使用HBuilderX导入插件】即可体验DEMO演示及在线生成工具v2.0文档)[https://demo.ucharts.cn](https://demo.ucharts.cn)
## 增加 通过tofix和unit快速格式化y轴的demo add by `howcode`
## 增加 图表组件在uniCloudAdmin中的应用 [UReport数据报表](https://ext.dcloud.net.cn/plugin?id=4651)
## 2.0.0-202104062021-04-05
# 秋云图表组件+uCharts v2.0版本同步上线使用方法详见https://demo.ucharts.cn帮助页
## 2.0.02021-04-05
# 秋云图表组件+uCharts v2.0版本同步上线使用方法详见https://demo.ucharts.cn帮助页

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,162 @@
<template>
<view class="container loading1">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading1',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading1 {
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading1 .shape1 {
-webkit-animation: animation1shape1 0.5s ease 0s infinite alternate;
animation: animation1shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
@keyframes animation1shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, 16px);
transform: translate(16px, 16px);
}
}
.loading1 .shape2 {
-webkit-animation: animation1shape2 0.5s ease 0s infinite alternate;
animation: animation1shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
@keyframes animation1shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, 16px);
transform: translate(-16px, 16px);
}
}
.loading1 .shape3 {
-webkit-animation: animation1shape3 0.5s ease 0s infinite alternate;
animation: animation1shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
@keyframes animation1shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(16px, -16px);
transform: translate(16px, -16px);
}
}
.loading1 .shape4 {
-webkit-animation: animation1shape4 0.5s ease 0s infinite alternate;
animation: animation1shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
@keyframes animation1shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-16px, -16px);
transform: translate(-16px, -16px);
}
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<view class="container loading2">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading2',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading2 {
-webkit-transform: rotate(10deg);
transform: rotate(10deg);
}
.container.loading2 .shape {
border-radius: 5px;
}
.container.loading2{
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading2 .shape1 {
-webkit-animation: animation2shape1 0.5s ease 0s infinite alternate;
animation: animation2shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
@keyframes animation2shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, 20px);
transform: translate(20px, 20px);
}
}
.loading2 .shape2 {
-webkit-animation: animation2shape2 0.5s ease 0s infinite alternate;
animation: animation2shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
@keyframes animation2shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, 20px);
transform: translate(-20px, 20px);
}
}
.loading2 .shape3 {
-webkit-animation: animation2shape3 0.5s ease 0s infinite alternate;
animation: animation2shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
@keyframes animation2shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(20px, -20px);
transform: translate(20px, -20px);
}
}
.loading2 .shape4 {
-webkit-animation: animation2shape4 0.5s ease 0s infinite alternate;
animation: animation2shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
@keyframes animation2shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-20px, -20px);
transform: translate(-20px, -20px);
}
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<view class="container loading3">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading3',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading3 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading3 .shape1 {
border-top-left-radius: 10px;
}
.container.loading3 .shape2 {
border-top-right-radius: 10px;
}
.container.loading3 .shape3 {
border-bottom-left-radius: 10px;
}
.container.loading3 .shape4 {
border-bottom-right-radius: 10px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading3 .shape1 {
-webkit-animation: animation3shape1 0.5s ease 0s infinite alternate;
animation: animation3shape1 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
@keyframes animation3shape1 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, 5px);
transform: translate(5px, 5px);
}
}
.loading3 .shape2 {
-webkit-animation: animation3shape2 0.5s ease 0s infinite alternate;
animation: animation3shape2 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
@keyframes animation3shape2 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, 5px);
transform: translate(-5px, 5px);
}
}
.loading3 .shape3 {
-webkit-animation: animation3shape3 0.5s ease 0s infinite alternate;
animation: animation3shape3 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
@keyframes animation3shape3 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(5px, -5px);
transform: translate(5px, -5px);
}
}
.loading3 .shape4 {
-webkit-animation: animation3shape4 0.5s ease 0s infinite alternate;
animation: animation3shape4 0.5s ease 0s infinite alternate;
}
@-webkit-keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
@keyframes animation3shape4 {
from {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
to {
-webkit-transform: translate(-5px, -5px);
transform: translate(-5px, -5px);
}
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<view class="container loading5">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading5',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading5 .shape {
width: 15px;
height: 15px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading5 .shape1 {
animation: animation5shape1 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
@keyframes animation5shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
50% {
-webkit-transform: translate(15px, 15px);
transform: translate(15px, 15px);
}
75% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
}
.loading5 .shape2 {
animation: animation5shape2 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
@keyframes animation5shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
50% {
-webkit-transform: translate(-15px, 15px);
transform: translate(-15px, 15px);
}
75% {
-webkit-transform: translate(0, 15px);
transform: translate(0, 15px);
}
}
.loading5 .shape3 {
animation: animation5shape3 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
@keyframes animation5shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(15px, 0);
transform: translate(15px, 0);
}
50% {
-webkit-transform: translate(15px, -15px);
transform: translate(15px, -15px);
}
75% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
}
.loading5 .shape4 {
animation: animation5shape4 2s ease 0s infinite reverse;
}
@-webkit-keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
@keyframes animation5shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -15px);
transform: translate(0, -15px);
}
50% {
-webkit-transform: translate(-15px, -15px);
transform: translate(-15px, -15px);
}
75% {
-webkit-transform: translate(-15px, 0);
transform: translate(-15px, 0);
}
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<view class="container loading6">
<view class="shape shape1"></view>
<view class="shape shape2"></view>
<view class="shape shape3"></view>
<view class="shape shape4"></view>
</view>
</template>
<script>
export default {
name: 'loading6',
data() {
return {
};
}
}
</script>
<style scoped="true">
.container {
width: 30px;
height: 30px;
position: relative;
}
.container.loading6 {
-webkit-animation: rotation 1s infinite;
animation: rotation 1s infinite;
}
.container.loading6 .shape {
width: 12px;
height: 12px;
border-radius: 2px;
}
.container .shape {
position: absolute;
width: 10px;
height: 10px;
border-radius: 1px;
}
.container .shape.shape1 {
left: 0;
background-color: #1890FF;
}
.container .shape.shape2 {
right: 0;
background-color: #91CB74;
}
.container .shape.shape3 {
bottom: 0;
background-color: #FAC858;
}
.container .shape.shape4 {
bottom: 0;
right: 0;
background-color: #EE6666;
}
.loading6 .shape1 {
-webkit-animation: animation6shape1 2s linear 0s infinite normal;
animation: animation6shape1 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
@keyframes animation6shape1 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
50% {
-webkit-transform: translate(18px, 18px);
transform: translate(18px, 18px);
}
75% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
}
.loading6 .shape2 {
-webkit-animation: animation6shape2 2s linear 0s infinite normal;
animation: animation6shape2 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
@keyframes animation6shape2 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
50% {
-webkit-transform: translate(-18px, 18px);
transform: translate(-18px, 18px);
}
75% {
-webkit-transform: translate(0, 18px);
transform: translate(0, 18px);
}
}
.loading6 .shape3 {
-webkit-animation: animation6shape3 2s linear 0s infinite normal;
animation: animation6shape3 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
@keyframes animation6shape3 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(18px, 0);
transform: translate(18px, 0);
}
50% {
-webkit-transform: translate(18px, -18px);
transform: translate(18px, -18px);
}
75% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
}
.loading6 .shape4 {
-webkit-animation: animation6shape4 2s linear 0s infinite normal;
animation: animation6shape4 2s linear 0s infinite normal;
}
@-webkit-keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
@keyframes animation6shape4 {
0% {
-webkit-transform: translate(0, 0);
transform: translate(0, 0);
}
25% {
-webkit-transform: translate(0, -18px);
transform: translate(0, -18px);
}
50% {
-webkit-transform: translate(-18px, -18px);
transform: translate(-18px, -18px);
}
75% {
-webkit-transform: translate(-18px, 0);
transform: translate(-18px, 0);
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<view>
<Loading1 v-if="loadingType==1"/>
<Loading2 v-if="loadingType==2"/>
<Loading3 v-if="loadingType==3"/>
<Loading4 v-if="loadingType==4"/>
<Loading5 v-if="loadingType==5"/>
</view>
</template>
<script>
import Loading1 from "./loading1.vue";
import Loading2 from "./loading2.vue";
import Loading3 from "./loading3.vue";
import Loading4 from "./loading4.vue";
import Loading5 from "./loading5.vue";
export default {
components:{Loading1,Loading2,Loading3,Loading4,Loading5},
name: 'qiun-loading',
props: {
loadingType: {
type: Number,
default: 2
},
},
data() {
return {
};
},
}
</script>
<style>
</style>

View File

@@ -0,0 +1,422 @@
/*
* uCharts®
* 高性能跨平台图表库支持H5、APP、小程序微信/支付宝/百度/头条/QQ/360、Vue、Taro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释,感谢支持开源!
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 通用配置项
// 主题颜色配置如每个图表类型需要不同主题请在对应图表类型上更改color属性
const color = ['#1890FF', '#91CB74', '#FAC858', '#EE6666', '#73C0DE', '#3CA272', '#FC8452', '#9A60B4', '#ea7ccc'];
const cfe = {
//demotype为自定义图表类型
"type": ["pie", "ring", "rose", "funnel", "line", "column", "area", "radar", "gauge","candle","demotype"],
//增加自定义图表类型如果需要categories请在这里加入您的图表类型例如最后的"demotype"
"categories": ["line", "column", "area", "radar", "gauge", "candle","demotype"],
//instance为实例变量承载属性option为eopts承载属性不要删除
"instance": {},
"option": {},
//下面是自定义format配置因除H5端外的其他端无法通过props传递函数只能通过此属性对应下标的方式来替换
"formatter":{
"tooltipDemo1":function(res){
let result = ''
for (let i in res) {
if (i == 0) {
result += res[i].axisValueLabel + '年销售额'
}
let value = '--'
if (res[i].data !== null) {
value = res[i].data
}
// #ifdef H5
result += '\n' + res[i].seriesName + '' + value + ' 万元'
// #endif
// #ifdef APP-PLUS
result += '<br/>' + res[i].marker + res[i].seriesName + '' + value + ' 万元'
// #endif
}
return result;
},
legendFormat:function(name){
return "自定义图例+"+name;
},
yAxisFormatDemo:function (value, index) {
return value + '元';
},
seriesFormatDemo:function(res){
return res.name + '年' + res.value + '元';
}
},
//这里演示了自定义您的图表类型的option可以随意命名之后在组件上 type="demotype" 后组件会调用这个花括号里的option如果组件上还存在eopts参数会将demotype与eopts中option合并后渲染图表。
"demotype":{
"color": color,
//在这里填写echarts的option即可
},
//下面是自定义配置,请添加项目所需的通用配置
"column": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'bar',
"data": [],
"barwidth": 20,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"line": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'line',
"data": [],
"barwidth": 20,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"area": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'axis'
},
"grid": {
"top": 30,
"bottom": 50,
"right": 15,
"left": 40
},
"legend": {
"bottom": 'left',
},
"toolbox": {
"show": false,
},
"xAxis": {
"type": 'category',
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
"boundaryGap": true,
"data": []
},
"yAxis": {
"type": 'value',
"axisTick": {
"show": false,
},
"axisLabel": {
"color": '#666666'
},
"axisLine": {
"lineStyle": {
"color": '#CCCCCC'
}
},
},
"seriesTemplate": {
"name": '',
"type": 'line',
"data": [],
"areaStyle": {},
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"pie": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"grid": {
"top": 40,
"bottom": 30,
"right": 15,
"left": 15
},
"legend": {
"bottom": 'left',
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": '50%',
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
},
},
"ring": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"grid": {
"top": 40,
"bottom": 30,
"right": 15,
"left": 15
},
"legend": {
"bottom": 'left',
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": ['40%', '70%'],
"avoidLabelOverlap": false,
"label": {
"show": true,
"color": "#666666",
"position": 'top',
},
"labelLine": {
"show": true
},
},
},
"rose": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item'
},
"legend": {
"top": 'bottom'
},
"seriesTemplate": {
"name": '',
"type": 'pie',
"data": [],
"radius": "55%",
"center": ['50%', '50%'],
"roseType": 'area',
},
},
"funnel": {
"color": color,
"title": {
"text": ''
},
"tooltip": {
"trigger": 'item',
"formatter": "{b} : {c}%"
},
"legend": {
"top": 'bottom'
},
"seriesTemplate": {
"name": '',
"type": 'funnel',
"left": '10%',
"top": 60,
"bottom": 60,
"width": '80%',
"min": 0,
"max": 100,
"minSize": '0%',
"maxSize": '100%',
"sort": 'descending',
"gap": 2,
"label": {
"show": true,
"position": 'inside'
},
"labelLine": {
"length": 10,
"lineStyle": {
"width": 1,
"type": 'solid'
}
},
"itemStyle": {
"bordercolor": '#fff',
"borderwidth": 1
},
"emphasis": {
"label": {
"fontSize": 20
}
},
"data": [],
},
},
"gauge": {
"color": color,
"tooltip": {
"formatter": '{a} <br/>{b} : {c}%'
},
"seriesTemplate": {
"name": '业务指标',
"type": 'gauge',
"detail": {"formatter": '{value}%'},
"data": [{"value": 50, "name": '完成率'}]
},
},
"candle": {
"xAxis": {
"data": []
},
"yAxis": {},
"color": color,
"title": {
"text": ''
},
"dataZoom": [{
"type": 'inside',
"xAxisIndex": [0, 1],
"start": 10,
"end": 100
},
{
"show": true,
"xAxisIndex": [0, 1],
"type": 'slider',
"bottom": 10,
"start": 10,
"end": 100
}
],
"seriesTemplate": {
"name": '',
"type": 'k',
"data": [],
},
}
}
export default cfe;

View File

@@ -0,0 +1,606 @@
/*
* uCharts®
* 高性能跨平台图表库支持H5、APP、小程序微信/支付宝/百度/头条/QQ/360、Vue、Taro等支持canvas的框架平台
* Copyright (c) 2021 QIUN®秋云 https://www.ucharts.cn All rights reserved.
* Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
* 复制使用请保留本段注释,感谢支持开源!
*
* uCharts®官方网站
* https://www.uCharts.cn
*
* 开源地址:
* https://gitee.com/uCharts/uCharts
*
* uni-app插件市场地址
* http://ext.dcloud.net.cn/plugin?id=271
*
*/
// 主题颜色配置如每个图表类型需要不同主题请在对应图表类型上更改color属性
const color = ['#1890FF', '#91CB74', '#FAC858', '#EE6666', '#73C0DE', '#3CA272', '#FC8452', '#9A60B4', '#ea7ccc'];
//事件转换函数主要用作格式化x轴为时间轴根据需求自行修改
const formatDateTime = (timeStamp, returnType)=>{
var date = new Date();
date.setTime(timeStamp * 1000);
var y = date.getFullYear();
var m = date.getMonth() + 1;
m = m < 10 ? ('0' + m) : m;
var d = date.getDate();
d = d < 10 ? ('0' + d) : d;
var h = date.getHours();
h = h < 10 ? ('0' + h) : h;
var minute = date.getMinutes();
var second = date.getSeconds();
minute = minute < 10 ? ('0' + minute) : minute;
second = second < 10 ? ('0' + second) : second;
if(returnType == 'full'){return y + '-' + m + '-' + d + ' '+ h +':' + minute + ':' + second;}
if(returnType == 'y-m-d'){return y + '-' + m + '-' + d;}
if(returnType == 'h:m'){return h +':' + minute;}
if(returnType == 'h:m:s'){return h +':' + minute +':' + second;}
return [y, m, d, h, minute, second];
}
const cfu = {
//demotype为自定义图表类型一般不需要自定义图表类型只需要改根节点上对应的类型即可
"type":["pie","ring","rose","word","funnel","map","arcbar","line","column","mount","bar","area","radar","gauge","candle","mix","tline","tarea","scatter","bubble","demotype"],
"range":["饼状图","圆环图","玫瑰图","词云图","漏斗图","地图","圆弧进度条","折线图","柱状图","山峰图","条状图","区域图","雷达图","仪表盘","K线图","混合图","时间轴折线","时间轴区域","散点图","气泡图","自定义类型"],
//增加自定义图表类型如果需要categories请在这里加入您的图表类型例如最后的"demotype"
//自定义类型时需要注意"tline","tarea","scatter","bubble"等时间轴矢量x轴类图表没有categories不需要加入categories
"categories":["line","column","mount","bar","area","radar","gauge","candle","mix","demotype"],
//instance为实例变量承载属性不要删除
"instance":{},
//option为opts及eopts承载属性不要删除
"option":{},
//下面是自定义format配置因除H5端外的其他端无法通过props传递函数只能通过此属性对应下标的方式来替换
"formatter":{
"yAxisDemo1":function(val, index, opts){return val+'元'},
"yAxisDemo2":function(val, index, opts){return val.toFixed(2)},
"xAxisDemo1":function(val, index, opts){return val+'年';},
"xAxisDemo2":function(val, index, opts){return formatDateTime(val,'h:m')},
"seriesDemo1":function(val, index, series, opts){return val+'元'},
"tooltipDemo1":function(item, category, index, opts){
if(index==0){
return '随便用'+item.data+'年'
}else{
return '其他我没改'+item.data+'天'
}
},
"pieDemo":function(val, index, series, opts){
if(index !== undefined){
return series[index].name+''+series[index].data+'元'
}
},
},
//这里演示了自定义您的图表类型的option可以随意命名之后在组件上 type="demotype" 后组件会调用这个花括号里的option如果组件上还存在opts参数会将demotype与opts中option合并后渲染图表。
"demotype":{
//我这里把曲线图当做了自定义图表类型,您可以根据需要随意指定类型或配置
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"line": {
"type": "curve",
"width": 2
},
}
},
//下面是自定义配置,请添加项目所需的通用配置
"pie":{
"type": "pie",
"color": color,
"padding": [5,5,5,5],
"extra": {
"pie": {
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": true,
"borderWidth": 3,
"borderColor": "#FFFFFF"
},
}
},
"ring":{
"type": "ring",
"color": color,
"padding": [5,5,5,5],
"rotate": false,
"dataLabel": true,
"legend": {
"show": true,
"position": "right",
"lineHeight": 25,
},
"title": {
"name": "收益率",
"fontSize": 15,
"color": "#666666"
},
"subtitle": {
"name": "70%",
"fontSize": 25,
"color": "#7cb5ec"
},
"extra": {
"ring": {
"ringWidth":30,
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": true,
"borderWidth": 3,
"borderColor": "#FFFFFF"
},
},
},
"rose":{
"type": "rose",
"color": color,
"padding": [5,5,5,5],
"legend": {
"show": true,
"position": "left",
"lineHeight": 25,
},
"extra": {
"rose": {
"type": "area",
"minRadius": 50,
"activeOpacity": 0.5,
"activeRadius": 10,
"offsetAngle": 0,
"labelWidth": 15,
"border": false,
"borderWidth": 2,
"borderColor": "#FFFFFF"
},
}
},
"word":{
"type": "word",
"color": color,
"extra": {
"word": {
"type": "normal",
"autoColors": false
}
}
},
"funnel":{
"type": "funnel",
"color": color,
"padding": [15,15,0,15],
"extra": {
"funnel": {
"activeOpacity": 0.3,
"activeWidth": 10,
"border": true,
"borderWidth": 2,
"borderColor": "#FFFFFF",
"fillOpacity": 1,
"labelAlign": "right"
},
}
},
"map":{
"type": "map",
"color": color,
"padding": [0,0,0,0],
"dataLabel": true,
"extra": {
"map": {
"border": true,
"borderWidth": 1,
"borderColor": "#666666",
"fillOpacity": 0.6,
"activeBorderColor": "#F04864",
"activeFillColor": "#FACC14",
"activeFillOpacity": 1
},
}
},
"arcbar":{
"type": "arcbar",
"color": color,
"title": {
"name": "百分比",
"fontSize": 25,
"color": "#00FF00"
},
"subtitle": {
"name": "默认标题",
"fontSize": 15,
"color": "#666666"
},
"extra": {
"arcbar": {
"type": "default",
"width": 12,
"backgroundColor": "#E9E9E9",
"startAngle": 0.75,
"endAngle": 0.25,
"gap": 2
}
}
},
"line":{
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"line": {
"type": "straight",
"width": 2,
"activeType": "hollow"
},
}
},
"tline":{
"type": "line",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": false,
"boundaryGap":"justify",
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
"data":[
{
"min":0,
"max":80
}
]
},
"legend": {
},
"extra": {
"line": {
"type": "curve",
"width": 2,
"activeType": "hollow"
},
}
},
"tarea":{
"type": "area",
"color": color,
"padding": [15,10,0,15],
"xAxis": {
"disableGrid": true,
"boundaryGap":"justify",
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
"data":[
{
"min":0,
"max":80
}
]
},
"legend": {
},
"extra": {
"area": {
"type": "curve",
"opacity": 0.2,
"addLine": true,
"width": 2,
"gradient": true,
"activeType": "hollow"
},
}
},
"column":{
"type": "column",
"color": color,
"padding": [15,15,0,5],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"data":[{"min":0}]
},
"legend": {
},
"extra": {
"column": {
"type": "group",
"width": 30,
"activeBgColor": "#000000",
"activeBgOpacity": 0.08
},
}
},
"mount":{
"type": "mount",
"color": color,
"padding": [15,15,0,5],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"data":[{"min":0}]
},
"legend": {
},
"extra": {
"mount": {
"type": "mount",
"widthRatio": 1.5,
},
}
},
"bar":{
"type": "bar",
"color": color,
"padding": [15,30,0,5],
"xAxis": {
"boundaryGap":"justify",
"disableGrid":false,
"min":0,
"axisLine":false
},
"yAxis": {
},
"legend": {
},
"extra": {
"bar": {
"type": "group",
"width": 30,
"meterBorde": 1,
"meterFillColor": "#FFFFFF",
"activeBgColor": "#000000",
"activeBgOpacity": 0.08
},
}
},
"area":{
"type": "area",
"color": color,
"padding": [15,15,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"gridType": "dash",
"dashLength": 2,
},
"legend": {
},
"extra": {
"area": {
"type": "straight",
"opacity": 0.2,
"addLine": true,
"width": 2,
"gradient": false,
"activeType": "hollow"
},
}
},
"radar":{
"type": "radar",
"color": color,
"padding": [5,5,5,5],
"dataLabel": false,
"legend": {
"show": true,
"position": "right",
"lineHeight": 25,
},
"extra": {
"radar": {
"gridType": "radar",
"gridColor": "#CCCCCC",
"gridCount": 3,
"opacity": 0.2,
"max": 200,
"labelShow": true
},
}
},
"gauge":{
"type": "gauge",
"color": color,
"title": {
"name": "66Km/H",
"fontSize": 25,
"color": "#2fc25b",
"offsetY": 50
},
"subtitle": {
"name": "实时速度",
"fontSize": 15,
"color": "#1890ff",
"offsetY": -50
},
"extra": {
"gauge": {
"type": "default",
"width": 30,
"labelColor": "#666666",
"startAngle": 0.75,
"endAngle": 0.25,
"startNumber": 0,
"endNumber": 100,
"labelFormat": "",
"splitLine": {
"fixRadius": 0,
"splitNumber": 10,
"width": 30,
"color": "#FFFFFF",
"childNumber": 5,
"childWidth": 12
},
"pointer": {
"width": 24,
"color": "auto"
}
}
}
},
"candle":{
"type": "candle",
"color": color,
"padding": [15,15,0,15],
"enableScroll": true,
"enableMarkLine": true,
"dataLabel": false,
"xAxis": {
"labelCount": 4,
"itemCount": 40,
"disableGrid": true,
"gridColor": "#CCCCCC",
"gridType": "solid",
"dashLength": 4,
"scrollShow": true,
"scrollAlign": "left",
"scrollColor": "#A6A6A6",
"scrollBackgroundColor": "#EFEBEF"
},
"yAxis": {
},
"legend": {
},
"extra": {
"candle": {
"color": {
"upLine": "#f04864",
"upFill": "#f04864",
"downLine": "#2fc25b",
"downFill": "#2fc25b"
},
"average": {
"show": true,
"name": ["MA5","MA10","MA30"],
"day": [5,10,20],
"color": ["#1890ff","#2fc25b","#facc14"]
}
},
"markLine": {
"type": "dash",
"dashLength": 5,
"data": [
{
"value": 2150,
"lineColor": "#f04864",
"showLabel": true
},
{
"value": 2350,
"lineColor": "#f04864",
"showLabel": true
}
]
}
}
},
"mix":{
"type": "mix",
"color": color,
"padding": [15,15,0,15],
"xAxis": {
"disableGrid": true,
},
"yAxis": {
"disabled": false,
"disableGrid": false,
"splitNumber": 5,
"gridType": "dash",
"dashLength": 4,
"gridColor": "#CCCCCC",
"padding": 10,
"showTitle": true,
"data": []
},
"legend": {
},
"extra": {
"mix": {
"column": {
"width": 20
}
},
}
},
"scatter":{
"type": "scatter",
"color":color,
"padding":[15,15,0,15],
"dataLabel":false,
"xAxis": {
"disableGrid": false,
"gridType":"dash",
"splitNumber":5,
"boundaryGap":"justify",
"min":0
},
"yAxis": {
"disableGrid": false,
"gridType":"dash",
},
"legend": {
},
"extra": {
"scatter": {
},
}
},
"bubble":{
"type": "bubble",
"color":color,
"padding":[15,15,0,15],
"xAxis": {
"disableGrid": false,
"gridType":"dash",
"splitNumber":5,
"boundaryGap":"justify",
"min":0,
"max":250
},
"yAxis": {
"disableGrid": false,
"gridType":"dash",
"data":[{
"min":0,
"max":150
}]
},
"legend": {
},
"extra": {
"bubble": {
"border":2,
"opacity": 0.5,
},
}
}
}
export default cfu;

View File

@@ -0,0 +1,5 @@
# uCharts JSSDK说明
1、如不使用uCharts组件可直接引用u-charts.js打包编译后会`自动压缩`,压缩后体积约为`120kb`
2、如果120kb的体积仍需压缩请手到uCharts官网通过在线定制选择您需要的图表。
3、config-ucharts.js为uCharts组件的用户配置文件升级前请`自行备份config-ucharts.js`文件,以免被强制覆盖。
4、config-echarts.js为ECharts组件的用户配置文件升级前请`自行备份config-echarts.js`文件,以免被强制覆盖。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,100 @@
{
"id": "qiun-data-charts",
"displayName": "秋云 ucharts echarts 高性能跨全端图表组件",
"version": "2.5.0-20230101",
"description": "uCharts 新增正负柱状图支持H5及APP用 ucharts echarts 渲染图表uniapp可视化首选组件",
"keywords": [
"ucharts",
"echarts",
"f2",
"图表",
"可视化"
],
"repository": "https://gitee.com/uCharts/uCharts",
"engines": {
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "474119"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/~qiun",
"type": "component-vue",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "-",
"vue3": "-"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "-",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-",
"xhs": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}

View File

@@ -0,0 +1,84 @@
![logo](https://img-blog.csdnimg.cn/4a276226973841468c1be356f8d9438b.png)
[![star](https://gitee.com/uCharts/uCharts/badge/star.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/stargazers)
[![fork](https://gitee.com/uCharts/uCharts/badge/fork.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/members)
[![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![npm package](https://img.shields.io/npm/v/@qiun/ucharts.svg?style=flat-square)](https://www.npmjs.com/~qiun)
## uCharts简介
`uCharts`是一款基于`canvas API`开发的适用于所有前端应用的图表库,开发者编写一套代码,可运行到 Web、iOS、Android基于 uni-app / taro )、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等更多支持 canvas API 的平台。
## 官方网站
## [https://www.ucharts.cn](https://www.ucharts.cn)
## 快速体验
一套代码编到多个平台依次扫描二维码亲自体验uCharts图表跨平台效果其他平台请自行编译。
![](https://www.ucharts.cn/images/web/guide/qrcode20220224.png)
![](https://img-blog.csdnimg.cn/7d0115593ff24ac39a224fb7c6ed72a4.png)
## 致开发者
感谢各位开发者`五年`来对秋云及uCharts的支持uCharts的进步离不开各位开发者的鼓励与贡献。为更好的帮助各位开发者使用图表工具我们推出了新版官网增加了在线定制、问答社区、在线配置等一些增值服务为确保您能更好的应用图表组件建议您先`仔细阅读官网指南`以及`常见问题`,而不是下载下来`直接使用`。如仍然不能解决,请到`官网社区`或开通会员后加入`专属VIP会员群`提问将会很快得到回答。
## 视频教程
## [uCharts新手入门教程](https://www.bilibili.com/video/BV1qA411Q7se/?share_source=copy_web&vd_source=42a1242f9aaade6427736af69eb2e1d9)
## 社群支持
uCharts官方拥有5个2000人的QQ群及专属VIP会员群支持庞大的用户量证明我们一直在努力请各位放心使用uCharts的开源图表组件的开发团队付出了大量的时间与精力经过四来的考验不会有比较明显的bug请各位放心使用。如果您有更好的想法可以在`码云提交Pull Requests`以帮助更多开发者完成需求再次感谢各位对uCharts的鼓励与支持
#### 官方交流群
- 交流群1371774600已满
- 交流群2619841586已满
- 交流群3955340127已满
- 交流群4641669795已满
- 交流群5236294809只能扫码加入
![](https://www.ucharts.cn/images/web/qq5.jpg)
- 口令`uniapp`
#### 专属VIP会员群
- 开通会员后详见【账号详情】页面中顶部的滚动通知
- 口令`您的用户ID`
## 版权信息
uCharts始终坚持开源遵循 [Apache Licence 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) 开源协议意味着您无需支付任何费用即可将uCharts应用到您的产品中。
注意这并不意味着您可以将uCharts应用到非法的领域比如涉及赌博暴力等方面。如因此产生纠纷或法律问题uCharts相关方及秋云科技不承担任何责任。
## 合作伙伴
[![DIY官网](https://www.ucharts.cn/images/web/guide/links/diy-gw.png)](https://www.diygw.com/)
[![HasChat](https://www.ucharts.cn/images/web/guide/links/haschat.png)](https://gitee.com/howcode/has-chat)
[![uViewUI](https://www.ucharts.cn/images/web/guide/links/uView.png)](https://www.uviewui.com/)
[![图鸟UI](https://www.ucharts.cn/images/web/guide/links/tuniao.png)](https://ext.dcloud.net.cn/plugin?id=7088)
[![thorui](https://www.ucharts.cn/images/web/guide/links/thorui.png)](https://ext.dcloud.net.cn/publisher?id=202)
[![FirstUI](https://www.ucharts.cn/images/web/guide/links/first.png)](https://www.firstui.cn/)
[![nProUI](https://www.ucharts.cn/images/web/guide/links/nPro.png)](https://ext.dcloud.net.cn/plugin?id=5169)
[![GraceUI](https://www.ucharts.cn/images/web/guide/links/grace.png)](https://www.graceui.com/)
## 更新记录
详见官网指南中说明,[点击此处查看](https://www.ucharts.cn/v2/#/guide/index?id=100)
## 相关链接
- [uCharts官网](https://www.ucharts.cn)
- [DCloud插件市场地址](https://ext.dcloud.net.cn/plugin?id=271)
- [uCharts码云开源托管地址](https://gitee.com/uCharts/uCharts) [![star](https://gitee.com/uCharts/uCharts/badge/star.svg?theme=gvp)](https://gitee.com/uCharts/uCharts/stargazers)
- [uCharts npm开源地址](https://www.ucharts.cn)
- [ECharts官网](https://echarts.apache.org/zh/index.html)
- [ECharts配置手册](https://echarts.apache.org/zh/option.html)
- [图表组件在项目中的应用 ReportPlus数据报表](https://www.ucharts.cn/v2/#/layout/info?id=1)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,101 @@
# Vue2 动态插槽名兼容性解决方案
## 问题描述
在Vue2环境中使用动态插槽名 `:name="dynamicSlotName"` 时,可能会遇到以下错误:
```
v-slot 不支持动态插槽名,请设置 scopedSlotsCompiler 为 augmented
```
## 解决方案
### 方案1配置Vue编译器推荐
在项目根目录创建或修改 `vue.config.js`
```javascript
module.exports = {
configureWebpack: {
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
}
},
// 设置scopedSlotsCompiler为augmented
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
options.scopedSlotsCompiler = 'augmented'
return options
})
}
}
```
### 方案2代码层面解决已实现
sl-table组件已经修改为兼容写法
```vue
<!-- 修改前有问题 -->
<slot :name="cell.slot" :row="tableData[cell.rowIndex]" :cell="cell">
<!-- 修改后兼容 -->
<template v-for="slotName in slotList" :key="slotName">
<slot
v-if="cell.slot === slotName"
:name="slotName"
:row="tableData[cell.rowIndex]"
:cell="cell">
</slot>
</template>
```
### 方案3uni-app项目配置
如果是uni-app项目`manifest.json` 中添加:
```json
{
"h5": {
"devServer": {
"disableHostCheck": true
}
},
"mp-weixin": {
"setting": {
"urlCheck": false
}
}
}
```
## 使用示例
### Vue2环境
```vue
<template>
<sl-table :columns="columns" :tableData="data">
<template slot="customSlot" slot-scope="{ row, cell }">
<view>{{ row.name }}</view>
</template>
</sl-table>
</template>
```
### 微信小程序环境
```vue
<template>
<sl-table columns="{{columns}}" tableData="{{data}}">
<template slot="customSlot" slot-scope="{{slotData}}">
<view>{{slotData.row.name}}</view>
</template>
</sl-table>
</template>
```
## 注意事项
1. 确保Vue版本为2.6+
2. 如果使用方案1需要重启开发服务器
3. 方案2已经内置在组件中无需额外配置
4. 微信小程序环境建议使用方案2的代码兼容方式

View File

@@ -0,0 +1,55 @@
## 1.5.72026-01-30
修复已知bug
## 1.5.62026-01-30
修复已知bug
## 1.5.52026-01-06
修复微信小程序兼容样式问题
## 1.5.42025-12-29
修复已知bug
## 1.5.32025-12-23
修复bug
## 1.5.22025-12-19
表头排序:新增表头排序功能
## 1.5.12025-12-10
优化
## 1.52025-12-10
1.单选/多选新增单选和多选功能支持受控模式selectedRows
2.序号列新增行号列功能showRowIndex支持自定义配置
## 1.4.12025-12-10
动态合并单元格:支持竖向动态合并单元格
## 1.42025-12-04
-**动态横向合并单元格**: 横向列合并单元格
## 1.3.62025-11-24
修复bug添加第一页的完成加载方法
## 1.3.52025-11-24
修复bug
## 1.3.42025-11-24
修改示例
## 1.3.32025-11-24
- ✅ empty插槽支持空数据插槽配置
- ✅ 更新columns表头样式配置项和tableData单元格样式配置项
## 1.3.22025-11-14
修复已知bug
## 1.3.12025-11-14
✅ 多级表头:支持到三级表头
## 1.3.02025-11-14
-**新增横向滚动支持**:表格支持横向滚动,自动处理宽度计算
-**新增固定列功能**:支持左侧和右侧固定列
-**固定列阴影效果**:滚动时固定列显示阴影提示
-**百分比宽度优化**百分比宽度自动转换为px确保精确显示
-**性能优化**:优化追加数据时的渲染性能,使用样式缓存机制
-**加载更多优化**:加载更多提示固定在可视区域,不随横向滚动
## 1.2.22025-11-13
固定高度场景下表头不参与滚动,新增上拉加载能力
## 1.2.12025-09-22
修复已知bug
## 1.2.02025-09-11
1.新增微信小程序适配支持
## 1.1.22025-08-08
更新readme.md文件
## 1.1.12025-08-08
修复bug
## 1.1.02025-08-08
适配vue3
## 1.0.12025-03-03
修改组件结构

View File

@@ -0,0 +1,853 @@
<template>
<view class="header-container">
<view :style="[getGridStyle]" class="header-grid">
<!-- 选择列 -->
<view
v-if="selection"
class="header-item selection-header"
:style="[getSelectionHeaderStyle]"
@click.stop="handleSelectAllClick"
>
<view
class="header-item-label selection-label"
@click.stop="handleSelectAllClick"
>
<checkbox
style="transform: scale(0.7)"
v-if="selection === 'multiple'"
:checked="isAllSelected"
:indeterminate="indeterminate"
color="#1890ff"
@click.stop="handleSelectAllClick"
/>
<text v-else></text>
</view>
</view>
<!-- 行号列 -->
<view
v-if="showRowIndex"
class="header-item row-index-header"
:style="[getRowIndexHeaderStyle]"
>
<view class="header-item-label">{{
(rowIndexConfig && rowIndexConfig.label) || "序号"
}}</view>
</view>
<!-- 数据列 -->
<view
v-for="(header, index) in gridCell"
:key="index"
:class="[
'header-item',
header.fixed === 'left' && scrollLeft_b > 0
? 'fixed-left-box-shadow'
: '',
header.fixed === 'right' && scrollLeft_b < allWidth - containerWidth
? 'fixed-right-box-shadow'
: '',
header.isLastColumn ? 'last-header-item' : '',
]"
:style="[cellStyles(index)]"
>
<view class="header-item-label">
<view class="label-text">{{ header.label }}</view>
<view
v-if="header.sort"
class="sort-wrapper"
:class="{
'sort-active': isCurrentSortColumn(header),
'sort-asc':
isCurrentSortColumn(header) &&
getCurrentSortOrder(header) === 'asc',
'sort-desc':
isCurrentSortColumn(header) &&
getCurrentSortOrder(header) === 'desc',
}"
@click.stop="handleSortChange(header)"
>
<view class="sort-icon">
<view
class="sort-icon-up"
:class="{
active:
isCurrentSortColumn(header) &&
getCurrentSortOrder(header) === 'asc',
}"
></view>
<view
class="sort-icon-down"
:class="{
active:
isCurrentSortColumn(header) &&
getCurrentSortOrder(header) === 'desc',
}"
></view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
/**
* 表头组件
* 支持最多三级表头
* @author shiliu
*
* @props {columns} 表头数据列表
*
* @tips 示例数据columns: [
{
label: '分类',
children: [],
prop: 'type',
width:'40%',
},
{
label: '总实收营业额(元)',
width:'30%',
children: [
{ label: '数值(占比)', prop: 'totalPercent', slot: 'customSlot',width:'100%', },
]
},
{
label: '总毛利额(元)',
width:'30%',
children: [
{ label: '数值(占比)', prop: 'name',width:'100%' },
]
},
],
*
*/
// Vue版本检测工具函数
const isVue2 = () => {
const Vue =
(typeof window !== "undefined" && window.Vue) ||
(typeof global !== "undefined" && global.Vue);
return Vue && Vue.version && Vue.version.startsWith("2.");
};
export default {
name: "DataTableHeader",
props: {
columns: {
type: Array,
default: () => [],
},
flattenColumns: {
type: Array,
default: () => [],
},
containerWidth: {
type: Number,
default: 0,
},
scrollLeft_b: {
type: Number,
default: 0,
},
allWidth: {
type: Number,
default: 0,
},
selection: {
type: [String, Boolean],
default: false,
},
isAllSelected: {
type: Boolean,
default: false,
},
indeterminate: {
type: Boolean,
default: false,
},
showRowIndex: {
type: Boolean,
default: false,
},
rowIndexConfig: {
type: Object,
default: () => ({
width: "60px",
label: "序号",
fixed: "left",
}),
},
debug: {
type: Boolean,
default: false,
},
defaultSort: {
type: Object,
default: () => {},
},
},
data() {
return {
timer: null,
// scrollLeft: 0
};
},
destroyed() {
this.timer && clearTimeout(this.timer);
this.timer = null;
},
computed: {
sortField() {
return this.defaultSort?.prop || "";
},
sortOrder() {
return this.defaultSort?.order || "";
},
getGridStyle() {
// 使用 flattenColumns包含选择列
const columnWidths = this.flattenColumns
.map((col) => {
const width = col.width || "1fr";
// 如果是百分比转换为px
if (
typeof width === "string" &&
width.endsWith("%") &&
this.containerWidth > 0
) {
const percent = parseFloat(width);
if (!isNaN(percent)) {
return `${(this.containerWidth * percent) / 100}px`;
}
}
return width;
})
.join(" ");
return {
display: "grid",
gridTemplateColumns: columnWidths,
width: "100%",
};
},
getSelectionHeaderStyle() {
// 获取最大层级,选择列需要跨所有层级
const getMaxLevel = (cols, currentLevel = 1) => {
let max = currentLevel;
cols.forEach((item) => {
if (item.children?.length) {
const childLevel = getMaxLevel(item.children, currentLevel + 1);
max = Math.max(max, childLevel);
}
});
return max;
};
const maxLevel = getMaxLevel(this.columns, 1);
return {
gridColumn: "1",
gridRow: `1 / span ${maxLevel}`,
position: "sticky",
left: "0",
zIndex: 1001,
backgroundColor: "#F5F6FA",
};
},
getRowIndexHeaderStyle() {
// 获取最大层级,行号列需要跨所有层级
const getMaxLevel = (cols, currentLevel = 1) => {
let max = currentLevel;
cols.forEach((item) => {
if (item.children?.length) {
const childLevel = getMaxLevel(item.children, currentLevel + 1);
max = Math.max(max, childLevel);
}
});
return max;
};
const maxLevel = getMaxLevel(this.columns, 1);
// 找到行号列在 flattenColumns 中的索引
const rowIndexColIndex = this.flattenColumns.findIndex(
(col) => col.isRowIndex
);
// 计算左侧偏移量
let leftOffset = "auto";
if (rowIndexColIndex >= 0 && this.rowIndexConfig?.fixed === "left") {
// 使用 getStickyOffset 方法计算正确的偏移量
leftOffset = this.getStickyOffset(rowIndexColIndex, "left", 1);
}
// 计算右侧偏移量
let rightOffset = "auto";
if (rowIndexColIndex >= 0 && this.rowIndexConfig?.fixed === "right") {
rightOffset = this.getStickyOffset(rowIndexColIndex, "right", 1);
}
// 确定 gridColumn 位置
let gridColumn = "1";
if (this.selection && rowIndexColIndex >= 0) {
// 如果启用了选择列,行号列应该在第二列
gridColumn = "2";
} else if (rowIndexColIndex >= 0) {
// 如果没有选择列,行号列在第一列
gridColumn = "1";
}
// zIndex 层级:选择列(1001) > 行号列(1002) > 数据列(1000)
// 确保行号列不会被数据列覆盖
const zIndexValue = this.rowIndexConfig?.fixed ? 1002 : 1;
return {
gridColumn: gridColumn,
gridRow: `1 / span ${maxLevel}`,
position: this.rowIndexConfig?.fixed ? "sticky" : "static",
left: leftOffset,
right: rightOffset,
zIndex: zIndexValue,
backgroundColor: "#F5F6FA",
};
},
gridCell() {
// 递归计算最大层级
const getMaxLevel = (cols, currentLevel = 1) => {
let max = currentLevel;
cols.forEach((item) => {
if (item.children?.length) {
const childLevel = getMaxLevel(item.children, currentLevel + 1);
max = Math.max(max, childLevel);
}
});
return max;
};
const maxLevel = getMaxLevel(this.columns, 1);
// 递归计算列的实际跨度(计算所有叶子节点的数量)
const getColspan = (item) => {
if (!item.children?.length) {
return 1;
}
return item.children.reduce((sum, child) => sum + getColspan(child), 0);
};
// 计算最底层的列数(所有叶子节点的数量)
const getTotalCols = (cols) => {
return cols.reduce((sum, item) => {
if (!item.children?.length) {
return sum + 1;
}
return sum + getTotalCols(item.children);
}, 0);
};
const totalCols = getTotalCols(this.columns);
// 初始化cells数组为每一层创建足够的单元格
let cells = Array(maxLevel)
.fill()
.map(() =>
Array(totalCols)
.fill(null)
.map(() => ({}))
);
// 填充实际的单元格数据
const fillCells = (columns, level, startIndex) => {
let currentIndex = startIndex;
columns.forEach((item) => {
const colspan = getColspan(item);
if (!item.children?.length) {
// 没有子列需要计算rowspan跨到最底层
const rowspan = maxLevel - level;
cells[level][currentIndex] = {
...item,
rowspan: rowspan,
colspan: 1,
display: true,
rowIndex: level,
colIndex: currentIndex,
};
// 填充下方被合并的行
for (let i = 1; i < rowspan; i++) {
if (level + i < maxLevel) {
cells[level + i][currentIndex] = {
...cells[level + i][currentIndex],
display: false,
rowIndex: level,
colIndex: currentIndex,
};
}
}
// 移动到下一个索引
currentIndex++;
} else {
// 有子列,需要合并列
cells[level][currentIndex] = {
...item,
rowspan: 1,
colspan: colspan,
display: true,
rowIndex: level,
colIndex: currentIndex,
};
// 填充右侧被合并的列
for (let i = 1; i < colspan; i++) {
cells[level][currentIndex + i] = {
...cells[level][currentIndex + i],
display: false,
rowIndex: level,
colIndex: currentIndex + i,
};
}
// 递归处理子列
item.children.forEach((child) => {
currentIndex = fillCells([child], level + 1, currentIndex);
});
}
});
return currentIndex;
};
fillCells(this.columns, 0, 0);
const flattened = cells.flat().filter((cell) => cell && cell.display);
flattened.forEach((cell) => {
if (!cell) return;
const colIndex = cell.colIndex ?? 0;
const span = cell.colspan ?? 1;
// 调整 colIndex 以匹配 flattenColumns 中的实际索引
// flattenColumns 的顺序:选择列(0) + 行号列(1) + 数据列(2+)
let adjustedColIndex = colIndex;
if (this.selection) {
adjustedColIndex += 1; // 加上选择列
}
if (this.showRowIndex) {
adjustedColIndex += 1; // 加上行号列
}
cell.colIndex = adjustedColIndex;
// 判断是否是最后一列
if (colIndex + span >= totalCols) {
cell.isLastColumn = true;
}
});
console.log(flattened);
return flattened;
},
flattenColumnList() {
const result = [];
const flatten = (cols, parent) => {
cols.forEach((col) => {
if (col.children && col.children.length) {
flatten(col.children, col);
} else {
if (parent?.width && col?.width) {
let pWidth = Number(parent.width.split("%")[0]) * 0.01;
let cWidth = Number(col.width.split("%")[0]) * 0.01;
result.push({
...col,
width: pWidth * cWidth * 100 + "%",
});
} else {
result.push(col);
}
// result.push(col)
}
});
};
flatten(this.columns, null);
return result;
},
cellStyles() {
return function (index) {
const styles = {};
this.gridCell.forEach((cell, index) => {
styles[index] = this.getCellStyle(cell, cell.colIndex);
});
return styles[index];
};
},
},
methods: {
parseWidth(widthStr) {
if (!widthStr) {
return {
percent: 0,
px: 0,
valid: false,
};
}
const normalized = String(widthStr).trim();
const numericValue = parseFloat(normalized);
if (Number.isNaN(numericValue)) {
return {
percent: 0,
px: 0,
valid: false,
};
}
if (normalized.endsWith("%")) {
return {
percent: numericValue,
px: 0,
valid: true,
};
}
if (normalized.endsWith("rpx")) {
if (typeof uni !== "undefined" && typeof uni.upx2px === "function") {
return {
percent: 0,
px: uni.upx2px(numericValue),
valid: true,
};
}
return {
percent: 0,
px: numericValue,
valid: true,
};
}
if (normalized.endsWith("px")) {
return {
percent: 0,
px: numericValue,
valid: true,
};
}
return {
percent: 0,
px: 0,
valid: false,
};
},
getStickyOffset(colIndex, direction = "left", colspan = 1) {
const columns = this.flattenColumns || [];
if (!columns.length) return "0px";
let percent = 0;
let px = 0;
if (direction === "left") {
for (let i = 0; i < colIndex; i++) {
const colWidth = columns[i]?.width;
if (!colWidth) continue;
// 如果是百分比需要转换为px使用容器宽度
if (
typeof colWidth === "string" &&
colWidth.endsWith("%") &&
this.containerWidth > 0
) {
const percentValue = parseFloat(colWidth);
if (!isNaN(percentValue)) {
px += (this.containerWidth * percentValue) / 100;
}
} else {
const { percent: p, px: x } = this.parseWidth(colWidth);
percent += p;
px += x;
}
}
} else {
for (let i = colIndex + colspan; i < columns.length; i++) {
const colWidth = columns[i]?.width;
if (!colWidth) continue;
// 如果是百分比需要转换为px使用容器宽度
if (
typeof colWidth === "string" &&
colWidth.endsWith("%") &&
this.containerWidth > 0
) {
const percentValue = parseFloat(colWidth);
if (!isNaN(percentValue)) {
px += (this.containerWidth * percentValue) / 100;
}
} else {
const { percent: p, px: x } = this.parseWidth(colWidth);
percent += p;
px += x;
}
}
}
if (percent && px) {
return `calc(${percent}% + ${px}px)`;
}
if (percent) {
return `${percent}%`;
}
return `${px}px`;
},
handleScroll(e) {
//添加防抖
this.timer && clearTimeout(this.timer);
this.timer = setTimeout(() => {
let scrollLeft = e.detail.scrollLeft;
this.$emit("scroll", scrollLeft);
}, 100);
},
// 计算每个label的高度
getCellStyle(cell, colIndex) {
// colIndex 已经是调整后的索引(在 gridCell 中已经考虑了选择列和行号列)
// 直接使用 colIndex 作为 flattenColumns 中的索引
const actualColIndex = colIndex;
const columnMeta = this.flattenColumns[actualColIndex] || {};
const baseWidth = columnMeta.width;
let width = baseWidth;
// 处理列合并的宽度
if (cell.colspan > 1) {
const colWidths = [];
for (let i = 0; i < cell.colspan; i++) {
const actualIndex = actualColIndex + i;
colWidths.push(this.flattenColumns[actualIndex]?.width || "100px");
}
width =
colWidths.reduce((sum, w) => {
const numWidth = parseInt(w);
return sum + (isNaN(numWidth) ? 100 : numWidth);
}, 0) + "px";
}
// 判断是否是 fixed='right' 的列
const isFixedRight =
cell.fixed === "right" || columnMeta.fixed === "right";
// 判断是否是挨着 fixed='right' 列最近的左侧列
const isAdjacentToFixedRight =
this.isAdjacentToFixedRight(actualColIndex);
this.debug && console.log(cell);
return {
// width: width,
flex: baseWidth ? "none" : "1",
display: cell.display ? "flex" : "none",
color: cell.textColor || "#333",
fontWeight: cell.bold ? "bold" : "normal",
gridRow: `span ${cell.rowspan}`,
gridColumn: `span ${cell.colspan}`,
position: cell.fixed ? "sticky" : "static",
left:
cell.fixed === "left"
? this.getStickyOffset(actualColIndex, "left", cell.colspan)
: "auto",
right:
cell.fixed === "right"
? this.getStickyOffset(actualColIndex, "right", cell.colspan)
: "auto",
zIndex: cell.fixed ? 1000 : 1,
backgroundColor: "#F5F6FA",
borderLeft: isFixedRight ? "1px solid #E7EAF2" : undefined,
borderRight: isAdjacentToFixedRight ? "none" : undefined,
...(cell.headerStyle || {}),
};
},
// 判断当前列是否是挨着 fixed='right' 列最近的左侧列
isAdjacentToFixedRight(colIndex) {
const nextColIndex = colIndex + 1;
if (nextColIndex >= this.flattenColumns.length) {
return false;
}
const currentCol = this.flattenColumns[colIndex] || {};
const nextCol = this.flattenColumns[nextColIndex] || {};
// 当前列不是 fixed且下一列是 fixed='right'
return !currentCol.fixed && nextCol.fixed === "right";
},
// 处理全选点击
handleSelectAllClick() {
if (this.selection === "multiple") {
this.$emit("select-all", !this.isAllSelected);
}
},
/**
* 判断当前列是否是排序列
* @param {Object} header 表头配置
* @returns {Boolean}
*/
isCurrentSortColumn(header) {
return this.sortField === header.prop && this.sortOrder;
},
/**
* 获取当前列的排序顺序
* @param {Object} header 表头配置
* @returns {String} 'asc' | 'desc' | ''
*/
getCurrentSortOrder(header) {
if (this.isCurrentSortColumn(header)) {
return this.sortOrder;
}
return "";
},
/**
* 处理排序变化
* @param {Object} header 表头配置
*/
handleSortChange(header) {
if (!header.sort || !header.prop) {
return;
}
let newOrder = "desc"; // 默认降序
// 如果当前列已经是排序列
if (this.isCurrentSortColumn(header)) {
// 切换排序顺序desc -> asc -> desc
if (this.sortOrder === "desc") {
newOrder = "asc";
} else if (this.sortOrder === "asc") {
newOrder = "desc";
}
}
// 触发排序变化事件
this.$emit("sort-change", {
prop: header.prop,
order: newOrder,
});
},
},
};
</script>
<style lang="scss">
.header-container {
display: flex;
// width: max-content;
background-color: #f5f6fa;
// border-top: 1px solid #E7EAF2;
// border-left: 1px solid #E7EAF2;
box-sizing: border-box;
position: sticky;
top: 0;
left: 0;
z-index: 3;
.header-grid {
display: grid;
}
.header-item {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
border-right: 1px solid #e7eaf2;
border-bottom: 1px solid #e7eaf2;
box-sizing: border-box;
position: relative;
.header-item-label {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f6fa;
padding: 8px;
font-size: 10px;
color: #1a1a1a;
text-align: center;
word-break: break-all;
box-sizing: border-box;
position: relative;
& + .header-item-label {
border-top: 1px solid #e7eaf2;
}
.label-text {
flex: 1;
text-align: center;
}
.sort-wrapper {
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
cursor: pointer;
user-select: none;
padding: 2px;
transition: all 0.2s;
&:hover {
opacity: 0.8;
}
&.sort-active {
.sort-icon {
opacity: 1;
}
}
.sort-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 12px;
height: 14px;
opacity: 0.4;
transition: opacity 0.2s;
.sort-icon-up,
.sort-icon-down {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
transition: border-color 0.2s;
}
.sort-icon-up {
border-bottom: 5px solid #c0c4cc;
margin-bottom: 2px;
&.active {
border-bottom-color: #1890ff;
}
}
.sort-icon-down {
border-top: 5px solid #c0c4cc;
&.active {
border-top-color: #1890ff;
}
}
}
&.sort-asc .sort-icon,
&.sort-desc .sort-icon {
opacity: 1;
}
}
}
.flex {
display: flex;
border-top: 1px solid #e7eaf2; // 添加边框以确保对齐
}
.flex .header-item-label {
flex: 1;
& + .header-item-label {
border-left: 1px solid #e7eaf2;
}
}
&.last-header-item {
border-right: none;
}
&.selection-header {
cursor: pointer;
.selection-label {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
<!-- Vue2 使用示例兼容微信小程序 -->
<template>
<view class="example-container">
<view class="example-title">Vue2兼容示例</view>
<!-- 基础表格 -->
<view class="section">
<view class="section-title">基础表格</view>
<sl-table
:columns="basicColumns"
:tableData="basicData"
@cell-click="handleCellClick"
/>
</view>
<!-- 合并单元格表格 -->
<view class="section">
<view class="section-title">合并单元格表格</view>
<sl-table
:columns="mergeColumns"
:tableData="mergeData"
@cell-click="handleCellClick"
>
<!-- Vue2插槽写法兼容微信小程序 -->
<template slot="customSlot" slot-scope="{ row, cell }">
<view class="custom-slot">
<text class="highlight">{{ row.customField }}</text>
</view>
</template>
<template slot="customSlot2" slot-scope="{ row, cell }">
<view class="custom-slot">
<text class="highlight">{{ row.customField }}</text>
</view>
</template>
</sl-table>
</view>
</view>
</template>
<script>
export default {
name: 'Vue2Example',
data() {
return {
// 基础表格配置
basicColumns: [
{
label: '姓名',
prop: 'name',
width: '30%'
},
{
label: '年龄',
prop: 'age',
width: '20%'
},
{
label: '职位',
prop: 'position',
width: '25%'
},
{
label: '部门',
prop: 'department',
width: '25%'
}
],
basicData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部' },
{ name: '赵六', age: 35, position: '设计师', department: '设计部' }
],
// 合并表格配置
mergeColumns: [
{
label: '基本信息',
width: '40%',
children: [
{ label: '姓名', prop: 'name', width: '50%' },
{ label: '年龄', prop: 'age', width: '50%' }
]
},
{
label: '工作信息',
width: '60%',
children: [
{ label: '职位', prop: 'position', width: '50%' },
{ label: '自定义', prop: 'customField', slot: 'customSlot', width: '25%' },
{ label: '自定义', prop: 'customField', slot: 'customSlot2', width: '25%' },
]
}
],
mergeData: [
{
name: '张三',
age: {
value: '28岁',
rowspan: 2,
backgroundColor: '#e8f4fd',
textColor: '#1890ff'
},
position: '前端工程师',
customField: '优秀员工'
},
{
name: '李四',
age: {
display: false // 被合并的单元格
},
position: '后端工程师',
customField: '技术专家'
},
{
name: {
value: '王五',
backgroundColor: '#fff2e8',
textColor: '#fa8c16',
bold: true
},
age: '29岁',
position: '产品经理',
customField: '产品达人'
}
]
}
},
methods: {
handleCellClick(event) {
console.log('Vue2 - 单元格点击事件:', event)
this.$emit('cell-clicked', event)
}
},
// 微信小程序兼容说明:
// 1. slot用法使用 slot="slotName" slot-scope="{ row, cell }" 而不是 #slotName
// 2. 数据绑定:微信小程序中使用 {{}} 语法
// 3. 事件绑定:微信小程序中使用 bind:eventName 语法
// 4. 样式绑定:组件内部已优化,:style="method()" 改为计算属性
// 5. 动态插槽:组件内部已优化,避免动态插槽名问题
// 6. 组件注册:微信小程序中使用 Page() 函数而不是 export default
}
</script>
<style lang="scss" scoped>
.example-container {
padding: 20px;
.example-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
color: #1890ff;
}
.section {
margin-bottom: 30px;
.section-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
.custom-slot {
display: flex;
align-items: center;
justify-content: center;
.highlight {
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-weight: bold;
}
}
}
</style>

View File

@@ -0,0 +1,303 @@
<!-- Vue2 使用示例兼容微信小程序 -->
<template>
<view class="example-container">
<view class="example-title">Vue2兼容示例</view>
<!-- 基础表格 -->
<view class="section">
<view class="section-title">基础表格</view>
<sl-table
:columns="basicColumns"
:tableData="basicData"
@cell-click="handleCellClick"
/>
</view>
<!-- 固定列表格 -->
<view class="section">
<view class="section-title">固定列表格支持横向滚动</view>
<sl-table
:columns="fixedColumns"
:tableData="fixedData"
@cell-click="handleCellClick"
/>
</view>
<!-- 合并单元格表格 -->
<view class="section">
<view class="section-title">合并单元格表格</view>
<sl-table
:columns="mergeColumns"
:tableData="mergeData"
@cell-click="handleCellClick"
>
<!-- Vue2插槽写法兼容微信小程序 -->
<template slot="customSlot" slot-scope="{ row, cell }">
<view class="custom-slot">
<text class="highlight">{{ row.customField }}</text>
</view>
</template>
</sl-table>
</view>
<!-- 上拉加载表格 -->
<view class="section">
<view class="section-title">上拉加载表格</view>
<sl-table
:columns="basicColumns"
:tableData="loadMoreData"
:enableLoadMore="true"
@load-more="handleLoadMore"
@cell-click="handleCellClick"
/>
</view>
<!-- 空数据插槽 -->
<view class="section">
<view class="section-title">空数据插槽</view>
<sl-table
:columns="basicColumns"
:tableData="[]"
@cell-click="handleCellClick"
>
<template slot="empty">
<view class="empty-container">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无数据请稍后再试</text>
</view>
</template>
</sl-table>
</view>
</view>
</template>
<script>
export default {
name: 'Vue2Example',
data() {
return {
// 基础表格配置
basicColumns: [
{
label: '姓名',
prop: 'name',
width: '30%'
},
{
label: '年龄',
prop: 'age',
width: '20%'
},
{
label: '职位',
prop: 'position',
width: '25%'
},
{
label: '部门',
prop: 'department',
width: '25%'
}
],
basicData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部' },
{ name: '赵六', age: 35, position: '设计师', department: '设计部' }
],
// 固定列表格配置
fixedColumns: [
{
label: '姓名',
prop: 'name',
width: '100px',
fixed: 'left'
},
{
label: '年龄',
prop: 'age',
width: '80px'
},
{
label: '职位',
prop: 'position',
width: '150px'
},
{
label: '部门',
prop: 'department',
width: '120px'
},
{
label: '邮箱',
prop: 'email',
width: '200px'
},
{
label: '操作',
prop: 'action',
width: '100px',
fixed: 'right'
}
],
fixedData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部', email: 'zhangsan@example.com', action: '编辑' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部', email: 'lisi@example.com', action: '编辑' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部', email: 'wangwu@example.com', action: '编辑' }
],
// 合并表格配置
mergeColumns: [
{
label: '基本信息',
width: '40%',
children: [
{ label: '姓名', prop: 'name', width: '50%' },
{ label: '年龄', prop: 'age', width: '50%' }
]
},
{
label: '工作信息',
width: '60%',
children: [
{ label: '职位', prop: 'position', width: '50%' },
{ label: '自定义', prop: 'customField', slot: 'customSlot', width: '50%' }
]
}
],
mergeData: [
{
name: '张三',
age: {
value: '28岁',
rowspan: 2,
cellStyle: {
backgroundColor: '#e8f4fd',
color: '#1890ff'
}
},
position: '前端工程师',
customField: '优秀员工'
},
{
name: '李四',
age: {
display: false // 被合并的单元格
},
position: '后端工程师',
customField: '技术专家'
},
{
name: {
value: '王五',
cellStyle: {
backgroundColor: '#fff2e8',
color: '#fa8c16',
fontWeight: 'bold'
}
},
age: '29岁',
position: '产品经理',
customField: '产品达人'
}
],
// 上拉加载数据
loadMoreData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部' }
],
loadMorePage: 1
}
},
methods: {
handleCellClick(event) {
console.log('Vue2 - 单元格点击事件:', event)
uni.showToast({
title: `点击了第${event.rowIndex + 1}行第${event.colIndex + 1}`,
icon: 'none'
})
},
handleLoadMore({ pageNum, done }) {
console.log('Vue2 - 上拉加载,当前页码:', pageNum)
// 模拟异步加载数据
setTimeout(() => {
const newData = [
{ name: `新用户${pageNum}-1`, age: 25, position: '测试工程师', department: '技术部' },
{ name: `新用户${pageNum}-2`, age: 27, position: 'UI设计师', department: '设计部' }
]
this.loadMoreData.push(...newData)
// 模拟加载到第3页后结束
const isLastPage = pageNum >= 3
done(isLastPage)
}, 1000)
}
},
// 微信小程序兼容说明:
// 1. slot用法使用 slot="slotName" slot-scope="{ row, cell }" 而不是 #slotName
// 2. 数据绑定:微信小程序中使用 {{}} 语法
// 3. 事件绑定:微信小程序中使用 bind:eventName 语法
// 4. 样式绑定:组件内部已优化,:style="method()" 改为计算属性
// 5. 动态插槽:组件内部已优化,避免动态插槽名问题
// 6. 组件注册:微信小程序中使用 Page() 函数而不是 export default
}
</script>
<style lang="scss" scoped>
.example-container {
padding: 20px;
.example-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
color: #1890ff;
}
.section {
margin-bottom: 30px;
.section-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
.custom-slot {
display: flex;
align-items: center;
justify-content: center;
.highlight {
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-weight: bold;
}
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
.empty-icon {
font-size: 48px;
margin-bottom: 10px;
}
.empty-text {
font-size: 14px;
color: #999;
}
}
}
</style>

View File

@@ -0,0 +1,297 @@
<!-- Vue3 使用示例 -->
<template>
<view class="example-container">
<view class="example-title">Vue3兼容示例</view>
<!-- 基础表格 -->
<view class="section">
<view class="section-title">基础表格</view>
<sl-table
:columns="basicColumns"
:tableData="basicData"
@cell-click="handleCellClick"
/>
</view>
<!-- 固定列表格 -->
<view class="section">
<view class="section-title">固定列表格支持横向滚动</view>
<sl-table
:columns="fixedColumns"
:tableData="fixedData"
@cell-click="handleCellClick"
/>
</view>
<!-- 合并单元格表格 -->
<view class="section">
<view class="section-title">合并单元格表格</view>
<sl-table
:columns="mergeColumns"
:tableData="mergeData"
@cell-click="handleCellClick"
>
<!-- Vue3风格的自定义插槽 -->
<template #customSlot="{ row, cell }">
<view class="custom-slot">
<text class="highlight">{{ row.customField }}</text>
</view>
</template>
</sl-table>
</view>
<!-- 上拉加载表格 -->
<view class="section">
<view class="section-title">上拉加载表格</view>
<sl-table
:columns="basicColumns"
:tableData="loadMoreData"
:enableLoadMore="true"
@load-more="handleLoadMore"
@cell-click="handleCellClick"
/>
</view>
<!-- 空数据插槽 -->
<view class="section">
<view class="section-title">空数据插槽</view>
<sl-table
:columns="basicColumns"
:tableData="[]"
@cell-click="handleCellClick"
>
<template #empty>
<view class="empty-container">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无数据请稍后再试</text>
</view>
</template>
</sl-table>
</view>
</view>
</template>
<script>
export default {
name: 'Vue3Example',
// Vue3的emits声明
emits: ['cell-clicked'],
data() {
return {
// 基础表格配置
basicColumns: [
{
label: '姓名',
prop: 'name',
width: '30%'
},
{
label: '年龄',
prop: 'age',
width: '20%'
},
{
label: '职位',
prop: 'position',
width: '25%'
},
{
label: '部门',
prop: 'department',
width: '25%'
}
],
basicData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部' },
{ name: '赵六', age: 35, position: '设计师', department: '设计部' }
],
// 固定列表格配置
fixedColumns: [
{
label: '姓名',
prop: 'name',
width: '100px',
fixed: 'left'
},
{
label: '年龄',
prop: 'age',
width: '80px'
},
{
label: '职位',
prop: 'position',
width: '150px'
},
{
label: '部门',
prop: 'department',
width: '120px'
},
{
label: '邮箱',
prop: 'email',
width: '200px'
},
{
label: '操作',
prop: 'action',
width: '100px',
fixed: 'right'
}
],
fixedData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部', email: 'zhangsan@example.com', action: '编辑' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部', email: 'lisi@example.com', action: '编辑' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部', email: 'wangwu@example.com', action: '编辑' }
],
// 合并表格配置
mergeColumns: [
{
label: '基本信息',
width: '40%',
children: [
{ label: '姓名', prop: 'name', width: '50%' },
{ label: '年龄', prop: 'age', width: '50%' }
]
},
{
label: '工作信息',
width: '60%',
children: [
{ label: '职位', prop: 'position', width: '50%' },
{ label: '自定义', prop: 'customField', slot: 'customSlot', width: '50%' }
]
}
],
mergeData: [
{
name: '张三',
age: {
value: '28岁',
rowspan: 2,
cellStyle: {
backgroundColor: '#e8f4fd',
color: '#1890ff'
}
},
position: '前端工程师',
customField: '优秀员工'
},
{
name: '李四',
age: {
display: false // 被合并的单元格
},
position: '后端工程师',
customField: '技术专家'
},
{
name: {
value: '王五',
cellStyle: {
backgroundColor: '#fff2e8',
color: '#fa8c16',
fontWeight: 'bold'
}
},
age: '29岁',
position: '产品经理',
customField: '产品达人'
}
],
// 上拉加载数据
loadMoreData: [
{ name: '张三', age: 28, position: '前端工程师', department: '技术部' },
{ name: '李四', age: 32, position: '后端工程师', department: '技术部' },
{ name: '王五', age: 29, position: '产品经理', department: '产品部' }
],
loadMorePage: 1
}
},
methods: {
handleCellClick(event) {
console.log('Vue3 - 单元格点击事件:', event)
uni.showToast({
title: `点击了第${event.rowIndex + 1}行第${event.colIndex + 1}`,
icon: 'none'
})
},
handleLoadMore({ pageNum, done }) {
console.log('Vue3 - 上拉加载,当前页码:', pageNum)
// 模拟异步加载数据
setTimeout(() => {
const newData = [
{ name: `新用户${pageNum}-1`, age: 25, position: '测试工程师', department: '技术部' },
{ name: `新用户${pageNum}-2`, age: 27, position: 'UI设计师', department: '设计部' }
]
this.loadMoreData.push(...newData)
// 模拟加载到第3页后结束
const isLastPage = pageNum >= 3
done(isLastPage)
}, 1000)
}
}
}
</script>
<style lang="scss" scoped>
.example-container {
padding: 20px;
.example-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
color: #52c41a;
}
.section {
margin-bottom: 30px;
.section-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
.custom-slot {
display: flex;
align-items: center;
justify-content: center;
.highlight {
background: linear-gradient(45deg, #52c41a, #1890ff);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-weight: bold;
}
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
.empty-icon {
font-size: 48px;
margin-bottom: 10px;
}
.empty-text {
font-size: 14px;
color: #999;
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
{
"id": "sl-table",
"displayName": "可合并单元格表格",
"version": "1.5.7",
"description": "自定义合并单元格和多级表头支持多个自定义slot插槽表头支持配置宽度百分比支持vue3",
"keywords": [
"合并单元格",
"表格",
"多级表头"
],
"repository": "",
"engines": {
"HBuilderX": "^4.36"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "610947208"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "u",
"app-uvue": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,441 @@
# sl-table
一个支持Vue2和Vue3的高性能表格组件
## 特性
-**Vue2/Vue3兼容**: 同时支持Vue2和Vue3环境
-**表头合并**: 支持多级表头和单元格合并
-**单元格合并**: 支持行合并和列合并
-**自定义插槽**: 支持单元格自定义内容
-**响应式**: 自适应不同屏幕尺寸
-**虚拟滚动**: 支持大量数据的分页加载
-**样式自定义**: 支持自定义单元格样式
-**横向滚动**: 支持表格横向滚动,自动处理宽度计算
-**固定列**: 支持左侧和右侧固定列,滚动时显示阴影提示
-**动态合并单元格**: 横竖向列合并单元格
-**单选/多选**: 支持单选和多选表格行
## 兼容性
| Vue版本 | 支持状态 | 说明 |
|---------|----------|------|
| Vue 2.6+ | ✅ 完全支持 | 使用Options API |
| Vue 3.0+ | ✅ 完全支持 | 使用Options API兼容Composition API |
| 平台 | 支持状态 | 说明 |
|------|----------|------|
| H5 | ✅ 完全支持 | 所有功能正常 |
| 微信小程序 | ✅ 完全支持 | 插槽需要提前注册 |
| App | ✅ 完全支持 | 所有功能正常 |
## 安装
```bash
# 将sl-table文件夹复制到项目的uni_modules目录下插件中有examples文件夹可查看示例
```
## 基础用法
### Vue3风格推荐
```vue
<template>
<sl-table
:columns="columns"
:tableData="tableData"
@cell-click="handleCellClick"
:enableLoadMore="true"
selection="multiple"
:showRowIndex="true"
@load-more="handleLoadMore"
:selectedRows="selectedRows"
@selection-change="handleSelectionChange"
>
<!-- Vue3自定义插槽 -->
<template #customSlot="{ row, cell }">
<view class="custom-content">
{{ row.customField }}
</view>
</template>
<template #empty>
<view class="empty-content">
暂无数据插槽展示
</view>
</template>
</sl-table>
</template>
```
### Vue2风格
```vue
<template>
<sl-table
:columns="columns"
:tableData="tableData"
@cell-click="handleCellClick"
:enableLoadMore="true"
selection="multiple"
@load-more="handleLoadMore"
:selectedRows="selectedRows"
@selection-change="handleSelectionChange"
>
<!-- VUE2自定义插槽 -->
<template #customSlot="{ row, cell }">
<view class="custom-slot">
<text class="highlight">{{ row.customField }}</text>
</view>
</template>
</sl-table>
</template>
<script>
export default {
//```你的代码```
methods:{
handleLoadMore({pageNum,done}){
//进行分页获取
let isLastPage = true //是否是最后一页
done(isLastPage) //结束后一定要执行这个方法isLastPage默认为false
},
handleSelectionChange(e) {
console.log('选择变化', e)
}
}
}
</script>
```
### 固定列
支持左侧和右侧固定列,在横向滚动时固定列会保持可见,并显示阴影提示:
```javascript
columns: [
{
label: '姓名',
prop: 'name',
width: '100px',
fixed: 'left' // 固定在左侧
},
{
label: '年龄',
prop: 'age',
width: '80px'
// 不固定,可横向滚动
},
{
label: '操作',
prop: 'action',
width: '100px',
fixed: 'right' // 固定在右侧
}
]
```
**固定列特性**
- 支持 `fixed: 'left'` 左侧固定
- 支持 `fixed: 'right'` 右侧固定
- 横向滚动时,固定列会显示阴影效果,提示用户有固定列存在
- 左侧固定列在滚动后显示右侧阴影
- 右侧固定列在未滚动到最右时显示左侧阴影
### 横向滚动
表格支持横向滚动,当列宽度总和超过容器宽度时自动启用:
```javascript
columns: [
{
label: '列1',
prop: 'col1',
width: '200px' // 固定宽度
},
{
label: '列2',
prop: 'col2',
width: '30%' // 百分比宽度会自动转换为px
},
{
label: '列3',
prop: 'col3',
width: '1fr' // 自适应宽度
}
]
```
**宽度说明**
- 支持 `px``rpx``%` 等单位
- 百分比宽度会根据容器实际宽度自动转换为 `px`
- 未设置宽度时默认使用 `1fr` 自适应
### 微信小程序插槽注册
在微信小程序中使用自定义插槽时,需要在组件中提前注册插槽:
```vue
<script>
export default {
data() {
return {
columns: [
{
label: '姓名',
prop: 'name',
width: '30%',
fixed: 'left',
headerStyle:{
backgroundColor: '#f0f0f0',
color: '#333',
fontWeight: 'bold',
},
},
{
label: '自定义',
prop: 'custom',
slot: 'customSlot', // 使用注册的插槽
width: '70%'
}
],
tableData: [
{ name: '张三', custom: 'custom data' },
{ name: '李四', custom: 'another data' }
]
}
}
}
</script>
```
<script>
export default {
data() {
return {
columns: [
{
label: '姓名',
prop: 'name',
width: '30%',
fixed: 'left',
headerStyle:{
backgroundColor: '#f0f0f0',
color: '#333',
fontWeight: 'bold',
},
},
{
label: '信息',
width: '70%',
children: [
{
label: '年龄',
prop: 'age',
width: '50%'
},
{
label: '自定义',
prop: 'custom',
slot: 'customSlot',
width: '50%'
}
]
}
],
tableData: [
{
name: '张三',
age: 25,
custom: 'custom data'
},
{
name: '李四',
age: 30,
custom: 'another data'
}
]
}
},
methods: {
handleCellClick(event) {
console.log('单元格点击:', event)
}
}
}
</script>
```
## 高级用法
### 单元格合并
```javascript
tableData: [
{
name: '张三',
info: {
value: '合并单元格',
rowspan: 2, // 跨2行
colspan: 1, // 跨1列
cellStyle:{
backgroundColor: '#f0f0f0',
color: '#333',
fontWeight: 'bold',
}
}
},
{
name: '李四',
info: {
display: false // 被合并的单元格设为不显示
}
}
]
```
### 自定义样式
```javascript
tableData: [
{
name: {
value: '重要数据',
cellStyle:{
backgroundColor: '#f0f0f0',
color: '#333',
fontWeight: 'bold',
}
}
}
]
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| columns | 表头配置 | Array | [] |
| tableData | 表格数据 | Array | [] |
| enableLoadMore | 是否开启上拉加载 | Boolean | false |
| useDynamicMergeCellsCol | 是否使用动态横向合并单元格 | Boolean | false |
| useDynamicMergeCellsRow | 是否使用动态竖向合并单元格 | Boolean | false |
| selection | 选择模式:'single' 单选,'multiple' 多选false 不启用选择 | String\|Boolean | false |
| selectedRows | 已选中的行数据(受控模式),支持传入行数据对象数组、索引数组或键值数组 | Array | [] |
| rowKey | 行的唯一标识字段,如果不提供则使用行索引 | String | null |
| showRowIndex | 是否在表格左侧显示行号列从1开始 | Boolean | false |
| rowIndexConfig | 行号列配置,如 { width: '60px', label: '序号', fixed: 'left' } | Object | { width: '60px', label: '序号', fixed: 'left' } |
### Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| cell-click | 单元格点击事件 | { rowIndex, colIndex, value, cell } |
| load-more | 上拉加载事件 | {pageNum,done} |
| selection-change | 选择变化事件 | { selectedRowKeys: Array, selectedRows: Array } |
| sort-change | 选择变化事件 | { prop: String, order: String } |
### Columns配置
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| label | 表头显示文本 | String | - |
| prop | 对应数据字段 | String | - |
| width | 列宽度支持px、rpx、%、1fr | String | '1fr' |
| fixed | 固定列('left' 或 'right' | String | - |
| slot | 自定义插槽名 | String | - |
| children | 子列配置 | Array | - |
| headerStyle | 表头样式 | Object | {} |
| sort | 是否排序 | Boolean | false |
### 单元格数据配置
> **注意**:以下带删除线的配置项已废弃,请使用 `cellStyle` 对象来设置单元格样式。
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| value | 显示值 | Any | - |
| rowspan | 行合并数 | Number | 1 |
| colspan | 列合并数 | Number | 1 |
| ~~display~~ | ~~是否显示~~ | ~~Boolean~~ | ~~true~~ |
| ~~backgroundColor~~ | ~~背景色~~ | ~~String~~ | ~~'#fff'~~ |
| ~~textColor~~ | ~~文字颜色~~ | ~~String~~ | ~~'#1A1A1A'~~ |
| ~~bold~~ | ~~是否加粗~~ | ~~Boolean~~ | ~~false~~ |
| cellStyle | 单元格样式 | Object | {} |
## 兼容性处理
组件内部会自动检测Vue版本并进行相应的兼容性处理
- **生命周期钩子**: 同时支持Vue2的`destroyed`和Vue3的`unmounted`
- **事件声明**: Vue3环境下自动添加`emits`声明
- **响应式数据**: 兼容两个版本的响应式系统
## 注意事项
1. 在uni-app项目中使用时确保已正确配置easycom
2. **插槽语法**
- 请使用 `<template #name="props">` 语法
- 自定义插槽的作用域数据在两个版本中保持一致
3. **微信小程序特殊要求**
- 使用自定义插槽时,必须在组件中提前注册
- 不支持动态插槽所有插槽都需要在sl-table页面中静态注册
4. 样式使用scss编写确保项目支持scss编译
5. 组件会自动检测Vue版本并适配相应的插槽渲染方式
## 更新日志
### v1.5.2
- ✅ 表头排序:新增表头排序功能
### v1.5
- ✅ 单选/多选新增单选和多选功能支持受控模式selectedRows
- ✅ 序号列新增行号列功能showRowIndex支持自定义配置
### v1.4.1
- ✅ 动态合并单元格:支持竖向动态合并单元格
### v1.4
- ✅ 动态合并单元格:支持横向动态合并单元格
### v1.3.3
- ✅ empty插槽支持空数据插槽配置
- ✅ 更新columns表头样式配置项和tableData单元格样式配置项
### v1.3.1
- ✅ 多级表头:支持到三级表头
### v1.3.0
-**新增横向滚动支持**:表格支持横向滚动,自动处理宽度计算
-**新增固定列功能**:支持左侧和右侧固定列
-**固定列阴影效果**:滚动时固定列显示阴影提示
-**百分比宽度优化**百分比宽度自动转换为px确保精确显示
-**性能优化**:优化追加数据时的渲染性能,使用样式缓存机制
-**加载更多优化**:加载更多提示固定在可视区域,不随横向滚动
### v1.2.2
- ✅ 表格体验优化:固定高度场景下表头不参与滚动
- ✅ 功能升级新增上拉加载能力需手动引入uni-load-more
### v1.2.0
- ✅ 新增微信小程序适配支持
- ✅ 优化插槽注册机制
- ✅ 完善小程序平台兼容性
### v1.1.0
- ✅ 新增Vue2/Vue3兼容性支持
- ✅ 优化生命周期钩子处理
- ✅ 改进事件系统兼容性
### v1.0.0
- ✅ 基础表格功能
- ✅ 表头合并支持
- ✅ 单元格合并支持
- ✅ 自定义插槽支持

View File

@@ -0,0 +1,161 @@
<template>
<view class="test-container">
<view class="test-title">插槽兼容性测试</view>
<view class="vue-version">
当前Vue版本: {{ vueVersion }}
</view>
<!-- 测试表格 -->
<sl-table
:columns="testColumns"
:tableData="testData"
@cell-click="handleCellClick"
>
<!-- Vue2语法 -->
<template slot="vue2Slot" slot-scope="slotProps">
<view class="vue2-slot">
<text style="color: #1890ff; font-weight: bold;">Vue2插槽: {{ slotProps.row.vue2Data }}</text>
</view>
</template>
<!-- Vue3语法 -->
<template #vue3Slot="slotProps">
<view class="vue3-slot">
<text style="color: #52c41a; font-weight: bold;">Vue3插槽: {{ slotProps.row.vue3Data }}</text>
</view>
</template>
<!-- 通用插槽 -->
<template slot="universalSlot" slot-scope="slotProps">
<view class="universal-slot">
<view style="display: flex; align-items: center; justify-content: center;">
<text style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px;">
通用: {{ slotProps.row.universalData }}
</text>
</view>
</view>
</template>
</sl-table>
<!-- 调试信息 -->
<view class="debug-info">
<view class="debug-title">调试信息:</view>
<view>$slots: {{ JSON.stringify(Object.keys($slots || {})) }}</view>
<view>$scopedSlots: {{ JSON.stringify(Object.keys($scopedSlots || {})) }}</view>
</view>
</view>
</template>
<script>
import { getVueVersion } from '../utils/vue-compat.js'
export default {
name: 'SlotTest',
data() {
return {
vueVersion: getVueVersion(),
testColumns: [
{
label: '基础列',
prop: 'name',
width: '25%'
},
{
label: 'Vue2插槽',
prop: 'vue2Data',
slot: 'vue2Slot',
width: '25%'
},
{
label: 'Vue3插槽',
prop: 'vue3Data',
slot: 'vue3Slot',
width: '25%'
},
{
label: '通用插槽',
prop: 'universalData',
slot: 'universalSlot',
width: '25%'
}
],
testData: [
{
name: '测试行1',
vue2Data: 'Vue2数据1',
vue3Data: 'Vue3数据1',
universalData: '通用数据1'
},
{
name: '测试行2',
vue2Data: 'Vue2数据2',
vue3Data: 'Vue3数据2',
universalData: '通用数据2'
},
{
name: '测试行3',
vue2Data: 'Vue2数据3',
vue3Data: 'Vue3数据3',
universalData: '通用数据3'
}
]
}
},
mounted() {
console.log('Vue版本:', this.vueVersion)
console.log('$slots:', this.$slots)
console.log('$scopedSlots:', this.$scopedSlots)
},
methods: {
handleCellClick(event) {
console.log('插槽测试 - 单元格点击:', event)
}
}
}
</script>
<style lang="scss" scoped>
.test-container {
padding: 20px;
.test-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
text-align: center;
}
.vue-version {
font-size: 14px;
color: #666;
margin-bottom: 20px;
text-align: center;
}
.vue2-slot, .vue3-slot, .universal-slot {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
.debug-info {
margin-top: 20px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
.debug-title {
font-weight: bold;
margin-bottom: 5px;
}
view {
font-size: 12px;
margin-bottom: 2px;
word-break: break-all;
}
}
}
</style>

View File

@@ -0,0 +1,139 @@
/**
* Vue2/Vue3 兼容性工具
* @author shiliu
*
* 提供Vue2和Vue3之间的兼容性处理
*/
// 检测Vue版本
export const getVueVersion = () => {
// 尝试获取Vue实例
const Vue = (typeof window !== 'undefined' && window.Vue) ||
(typeof global !== 'undefined' && global.Vue) ||
(typeof self !== 'undefined' && self.Vue)
if (Vue && Vue.version) {
return Vue.version
}
// 如果无法直接获取,尝试通过其他方式检测
try {
// Vue3的特征检测
if (typeof createApp !== 'undefined') {
return '3.x'
}
// Vue2的特征检测
if (typeof Vue !== 'undefined' && Vue.config) {
return '2.x'
}
} catch (e) {
// 忽略错误
}
return 'unknown'
}
// 检测是否为Vue2
export const isVue2 = () => {
const version = getVueVersion()
return version.startsWith('2.')
}
// 检测是否为Vue3
export const isVue3 = () => {
const version = getVueVersion()
return version.startsWith('3.')
}
// 兼容性生命周期钩子映射
export const lifecycleHooks = {
// Vue2 -> Vue3 映射
beforeDestroy: 'beforeUnmount',
destroyed: 'unmounted'
}
// 获取当前环境支持的生命周期钩子
export const getLifecycleHook = (hookName) => {
if (isVue3() && lifecycleHooks[hookName]) {
return lifecycleHooks[hookName]
}
return hookName
}
// 兼容性事件处理
export const createCompatEmit = (instance) => {
if (isVue2()) {
// Vue2使用$emit
return (event, ...args) => {
instance.$emit(event, ...args)
}
} else {
// Vue3使用emit函数
return (event, ...args) => {
instance.emit(event, ...args)
}
}
}
// 兼容性响应式数据处理
export const createCompatReactive = (data) => {
if (isVue2()) {
// Vue2直接返回数据对象
return data
} else {
// Vue3可能需要reactive包装在组合式API中
// 但在选项式API中data函数返回的对象会自动变成响应式
return data
}
}
// 检查组件是否需要emits声明
export const needsEmitsDeclaration = () => {
return isVue3()
}
// 生成兼容的组件选项
export const createCompatOptions = (options) => {
const compatOptions = { ...options }
if (isVue3()) {
// Vue3需要emits声明
if (!compatOptions.emits && options.methods) {
// 自动检测可能的emit事件
const emits = []
const methods = options.methods
Object.keys(methods).forEach(methodName => {
const methodStr = methods[methodName].toString()
// 简单的正则匹配$emit调用
const emitMatches = methodStr.match(/\$emit\(['"`]([^'"`]+)['"`]/g)
if (emitMatches) {
emitMatches.forEach(match => {
const eventName = match.match(/\$emit\(['"`]([^'"`]+)['"`]/)[1]
if (!emits.includes(eventName)) {
emits.push(eventName)
}
})
}
})
if (emits.length > 0) {
compatOptions.emits = emits
}
}
}
return compatOptions
}
export default {
getVueVersion,
isVue2,
isVue3,
lifecycleHooks,
getLifecycleHook,
createCompatEmit,
createCompatReactive,
needsEmitsDeclaration,
createCompatOptions
}

View File

@@ -0,0 +1,30 @@
## 1.4.122024-09-21
- 修复 calendar在选择日期范围后重新选择日期需要点两次的Bug
## 1.4.112024-01-10
- 修复 回到今天时,月份显示不一致问题
## 1.4.102023-04-10
- 修复 某些情况 monthSwitch 未触发的Bug
## 1.4.92023-02-02
- 修复 某些情况切换月份错误的Bug
## 1.4.82023-01-30
- 修复 某些情况切换月份错误的Bug [详情](https://ask.dcloud.net.cn/question/161964)
## 1.4.72022-09-16
- 优化 支持使用 uni-scss 控制主题色
## 1.4.62022-09-08
- 修复 表头年月切换导致改变当前日期为选择月1号且未触发change事件的Bug
## 1.4.52022-02-25
- 修复 条件编译 nvue 不支持的 css 样式的Bug
## 1.4.42022-02-25
- 修复 条件编译 nvue 不支持的 css 样式的Bug
## 1.4.32021-09-22
- 修复 startDate、 endDate 属性失效的Bug
## 1.4.22021-08-24
- 新增 支持国际化
## 1.4.12021-08-05
- 修复 弹出层被 tabbar 遮盖的Bug
## 1.4.02021-07-30
- 组件兼容 vue3如何创建vue3项目详见 [uni-app 项目支持 vue3 介绍](https://ask.dcloud.net.cn/article/37834)
## 1.3.162021-05-12
- 新增 组件示例地址
## 1.3.152021-02-04
- 调整为uni_modules目录规范

View File

@@ -0,0 +1,544 @@
/**
* @1900-2100区间内的公历、农历互转
* @charset UTF-8
* @github https://github.com/jjonline/calendar.js
* @Author Jea杨(JJonline@JJonline.Cn)
* @Time 2014-7-21
* @Time 2016-8-13 Fixed 2033hex、Attribution Annals
* @Time 2016-9-25 Fixed lunar LeapMonth Param Bug
* @Time 2017-7-24 Fixed use getTerm Func Param Error.use solar year,NOT lunar year
* @Version 1.0.3
* @公历转农历calendar.solar2lunar(1987,11,01); //[you can ignore params of prefix 0]
* @农历转公历calendar.lunar2solar(1987,09,10); //[you can ignore params of prefix 0]
*/
/* eslint-disable */
var calendar = {
/**
* 农历1900-2100的润大小信息表
* @Array Of Property
* @return Hex
*/
lunarInfo: [0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
/** Add By JJonline@JJonline.Cn**/
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520], // 2100
/**
* 公历每个月份的天数普通表
* @Array Of Property
* @return Number
*/
solarMonth: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
/**
* 天干地支之天干速查表
* @Array Of Property trans["甲","乙","丙","丁","戊","己","庚","辛","壬","癸"]
* @return Cn string
*/
Gan: ['\u7532', '\u4e59', '\u4e19', '\u4e01', '\u620a', '\u5df1', '\u5e9a', '\u8f9b', '\u58ec', '\u7678'],
/**
* 天干地支之地支速查表
* @Array Of Property
* @trans["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"]
* @return Cn string
*/
Zhi: ['\u5b50', '\u4e11', '\u5bc5', '\u536f', '\u8fb0', '\u5df3', '\u5348', '\u672a', '\u7533', '\u9149', '\u620c', '\u4ea5'],
/**
* 天干地支之地支速查表<=>生肖
* @Array Of Property
* @trans["鼠","牛","虎","兔","龙","蛇","马","羊","猴","鸡","狗","猪"]
* @return Cn string
*/
Animals: ['\u9f20', '\u725b', '\u864e', '\u5154', '\u9f99', '\u86c7', '\u9a6c', '\u7f8a', '\u7334', '\u9e21', '\u72d7', '\u732a'],
/**
* 24节气速查表
* @Array Of Property
* @trans["小寒","大寒","立春","雨水","惊蛰","春分","清明","谷雨","立夏","小满","芒种","夏至","小暑","大暑","立秋","处暑","白露","秋分","寒露","霜降","立冬","小雪","大雪","冬至"]
* @return Cn string
*/
solarTerm: ['\u5c0f\u5bd2', '\u5927\u5bd2', '\u7acb\u6625', '\u96e8\u6c34', '\u60ca\u86f0', '\u6625\u5206', '\u6e05\u660e', '\u8c37\u96e8', '\u7acb\u590f', '\u5c0f\u6ee1', '\u8292\u79cd', '\u590f\u81f3', '\u5c0f\u6691', '\u5927\u6691', '\u7acb\u79cb', '\u5904\u6691', '\u767d\u9732', '\u79cb\u5206', '\u5bd2\u9732', '\u971c\u964d', '\u7acb\u51ac', '\u5c0f\u96ea', '\u5927\u96ea', '\u51ac\u81f3'],
/**
* 1900-2100各年的24节气日期速查表
* @Array Of Property
* @return 0x string For splice
*/
sTermInfo: ['9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e', '97bcf97c3598082c95f8c965cc920f',
'97bd0b06bdb0722c965ce1cfcc920f', 'b027097bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e',
'97bcf97c359801ec95f8c965cc920f', '97bd0b06bdb0722c965ce1cfcc920f', 'b027097bd097c36b0b6fc9274c91aa',
'97b6b97bd19801ec9210c965cc920e', '97bcf97c359801ec95f8c965cc920f', '97bd0b06bdb0722c965ce1cfcc920f',
'b027097bd097c36b0b6fc9274c91aa', '9778397bd19801ec9210c965cc920e', '97b6b97bd19801ec95f8c965cc920f',
'97bd09801d98082c95f8e1cfcc920f', '97bd097bd097c36b0b6fc9210c8dc2', '9778397bd197c36c9210c9274c91aa',
'97b6b97bd19801ec95f8c965cc920e', '97bd09801d98082c95f8e1cfcc920f', '97bd097bd097c36b0b6fc9210c8dc2',
'9778397bd097c36c9210c9274c91aa', '97b6b97bd19801ec95f8c965cc920e', '97bcf97c3598082c95f8e1cfcc920f',
'97bd097bd097c36b0b6fc9210c8dc2', '9778397bd097c36c9210c9274c91aa', '97b6b97bd19801ec9210c965cc920e',
'97bcf97c3598082c95f8c965cc920f', '97bd097bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b97bd19801ec9210c965cc920e', '97bcf97c3598082c95f8c965cc920f', '97bd097bd097c35b0b6fc920fb0722',
'9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e', '97bcf97c359801ec95f8c965cc920f',
'97bd097bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e',
'97bcf97c359801ec95f8c965cc920f', '97bd097bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b97bd19801ec9210c965cc920e', '97bcf97c359801ec95f8c965cc920f', '97bd097bd07f595b0b6fc920fb0722',
'9778397bd097c36b0b6fc9210c8dc2', '9778397bd19801ec9210c9274c920e', '97b6b97bd19801ec95f8c965cc920f',
'97bd07f5307f595b0b0bc920fb0722', '7f0e397bd097c36b0b6fc9210c8dc2', '9778397bd097c36c9210c9274c920e',
'97b6b97bd19801ec95f8c965cc920f', '97bd07f5307f595b0b0bc920fb0722', '7f0e397bd097c36b0b6fc9210c8dc2',
'9778397bd097c36c9210c9274c91aa', '97b6b97bd19801ec9210c965cc920e', '97bd07f1487f595b0b0bc920fb0722',
'7f0e397bd097c36b0b6fc9210c8dc2', '9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e',
'97bcf7f1487f595b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b97bd19801ec9210c965cc920e', '97bcf7f1487f595b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722',
'9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e', '97bcf7f1487f531b0b0bb0b6fb0722',
'7f0e397bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa', '97b6b97bd19801ec9210c965cc920e',
'97bcf7f1487f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b97bd19801ec9210c9274c920e', '97bcf7f0e47f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b0bc920fb0722',
'9778397bd097c36b0b6fc9210c91aa', '97b6b97bd197c36c9210c9274c920e', '97bcf7f0e47f531b0b0bb0b6fb0722',
'7f0e397bd07f595b0b0bc920fb0722', '9778397bd097c36b0b6fc9210c8dc2', '9778397bd097c36c9210c9274c920e',
'97b6b7f0e47f531b0723b0b6fb0722', '7f0e37f5307f595b0b0bc920fb0722', '7f0e397bd097c36b0b6fc9210c8dc2',
'9778397bd097c36b0b70c9274c91aa', '97b6b7f0e47f531b0723b0b6fb0721', '7f0e37f1487f595b0b0bb0b6fb0722',
'7f0e397bd097c35b0b6fc9210c8dc2', '9778397bd097c36b0b6fc9274c91aa', '97b6b7f0e47f531b0723b0b6fb0721',
'7f0e27f1487f595b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722',
'9778397bd097c36b0b6fc9274c91aa', '97b6b7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722',
'7f0e397bd097c35b0b6fc920fb0722', '9778397bd097c36b0b6fc9274c91aa', '97b6b7f0e47f531b0723b0b6fb0721',
'7f0e27f1487f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b0bc920fb0722', '9778397bd097c36b0b6fc9274c91aa',
'97b6b7f0e47f531b0723b0787b0721', '7f0e27f0e47f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b0bc920fb0722',
'9778397bd097c36b0b6fc9210c91aa', '97b6b7f0e47f149b0723b0787b0721', '7f0e27f0e47f531b0723b0b6fb0722',
'7f0e397bd07f595b0b0bc920fb0722', '9778397bd097c36b0b6fc9210c8dc2', '977837f0e37f149b0723b0787b0721',
'7f07e7f0e47f531b0723b0b6fb0722', '7f0e37f5307f595b0b0bc920fb0722', '7f0e397bd097c35b0b6fc9210c8dc2',
'977837f0e37f14998082b0787b0721', '7f07e7f0e47f531b0723b0b6fb0721', '7f0e37f1487f595b0b0bb0b6fb0722',
'7f0e397bd097c35b0b6fc9210c8dc2', '977837f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721',
'7f0e27f1487f531b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722', '977837f0e37f14998082b0787b06bd',
'7f07e7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722', '7f0e397bd097c35b0b6fc920fb0722',
'977837f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722',
'7f0e397bd07f595b0b0bc920fb0722', '977837f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721',
'7f0e27f1487f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b0bc920fb0722', '977837f0e37f14998082b0787b06bd',
'7f07e7f0e47f149b0723b0787b0721', '7f0e27f0e47f531b0b0bb0b6fb0722', '7f0e397bd07f595b0b0bc920fb0722',
'977837f0e37f14998082b0723b06bd', '7f07e7f0e37f149b0723b0787b0721', '7f0e27f0e47f531b0723b0b6fb0722',
'7f0e397bd07f595b0b0bc920fb0722', '977837f0e37f14898082b0723b02d5', '7ec967f0e37f14998082b0787b0721',
'7f07e7f0e47f531b0723b0b6fb0722', '7f0e37f1487f595b0b0bb0b6fb0722', '7f0e37f0e37f14898082b0723b02d5',
'7ec967f0e37f14998082b0787b0721', '7f07e7f0e47f531b0723b0b6fb0722', '7f0e37f1487f531b0b0bb0b6fb0722',
'7f0e37f0e37f14898082b0723b02d5', '7ec967f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721',
'7f0e37f1487f531b0b0bb0b6fb0722', '7f0e37f0e37f14898082b072297c35', '7ec967f0e37f14998082b0787b06bd',
'7f07e7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722', '7f0e37f0e37f14898082b072297c35',
'7ec967f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722',
'7f0e37f0e366aa89801eb072297c35', '7ec967f0e37f14998082b0787b06bd', '7f07e7f0e47f149b0723b0787b0721',
'7f0e27f1487f531b0b0bb0b6fb0722', '7f0e37f0e366aa89801eb072297c35', '7ec967f0e37f14998082b0723b06bd',
'7f07e7f0e47f149b0723b0787b0721', '7f0e27f0e47f531b0723b0b6fb0722', '7f0e37f0e366aa89801eb072297c35',
'7ec967f0e37f14998082b0723b06bd', '7f07e7f0e37f14998083b0787b0721', '7f0e27f0e47f531b0723b0b6fb0722',
'7f0e37f0e366aa89801eb072297c35', '7ec967f0e37f14898082b0723b02d5', '7f07e7f0e37f14998082b0787b0721',
'7f07e7f0e47f531b0723b0b6fb0722', '7f0e36665b66aa89801e9808297c35', '665f67f0e37f14898082b0723b02d5',
'7ec967f0e37f14998082b0787b0721', '7f07e7f0e47f531b0723b0b6fb0722', '7f0e36665b66a449801e9808297c35',
'665f67f0e37f14898082b0723b02d5', '7ec967f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721',
'7f0e36665b66a449801e9808297c35', '665f67f0e37f14898082b072297c35', '7ec967f0e37f14998082b0787b06bd',
'7f07e7f0e47f531b0723b0b6fb0721', '7f0e26665b66a449801e9808297c35', '665f67f0e37f1489801eb072297c35',
'7ec967f0e37f14998082b0787b06bd', '7f07e7f0e47f531b0723b0b6fb0721', '7f0e27f1487f531b0b0bb0b6fb0722'],
/**
* 数字转中文速查表
* @Array Of Property
* @trans ['日','一','二','三','四','五','六','七','八','九','十']
* @return Cn string
*/
nStr1: ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', '\u4e03', '\u516b', '\u4e5d', '\u5341'],
/**
* 日期转农历称呼速查表
* @Array Of Property
* @trans ['初','十','廿','卅']
* @return Cn string
*/
nStr2: ['\u521d', '\u5341', '\u5eff', '\u5345'],
/**
* 月份转农历称呼速查表
* @Array Of Property
* @trans ['正','一','二','三','四','五','六','七','八','九','十','冬','腊']
* @return Cn string
*/
nStr3: ['\u6b63', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', '\u4e03', '\u516b', '\u4e5d', '\u5341', '\u51ac', '\u814a'],
/**
* 返回农历y年一整年的总天数
* @param lunar Year
* @return Number
* @eg:var count = calendar.lYearDays(1987) ;//count=387
*/
lYearDays: function (y) {
var i; var sum = 348
for (i = 0x8000; i > 0x8; i >>= 1) { sum += (this.lunarInfo[y - 1900] & i) ? 1 : 0 }
return (sum + this.leapDays(y))
},
/**
* 返回农历y年闰月是哪个月若y年没有闰月 则返回0
* @param lunar Year
* @return Number (0-12)
* @eg:var leapMonth = calendar.leapMonth(1987) ;//leapMonth=6
*/
leapMonth: function (y) { // 闰字编码 \u95f0
return (this.lunarInfo[y - 1900] & 0xf)
},
/**
* 返回农历y年闰月的天数 若该年没有闰月则返回0
* @param lunar Year
* @return Number (0、29、30)
* @eg:var leapMonthDay = calendar.leapDays(1987) ;//leapMonthDay=29
*/
leapDays: function (y) {
if (this.leapMonth(y)) {
return ((this.lunarInfo[y - 1900] & 0x10000) ? 30 : 29)
}
return (0)
},
/**
* 返回农历y年m月非闰月的总天数计算m为闰月时的天数请使用leapDays方法
* @param lunar Year
* @return Number (-1、29、30)
* @eg:var MonthDay = calendar.monthDays(1987,9) ;//MonthDay=29
*/
monthDays: function (y, m) {
if (m > 12 || m < 1) { return -1 }// 月份参数从1至12参数错误返回-1
return ((this.lunarInfo[y - 1900] & (0x10000 >> m)) ? 30 : 29)
},
/**
* 返回公历(!)y年m月的天数
* @param solar Year
* @return Number (-1、28、29、30、31)
* @eg:var solarMonthDay = calendar.leapDays(1987) ;//solarMonthDay=30
*/
solarDays: function (y, m) {
if (m > 12 || m < 1) { return -1 } // 若参数错误 返回-1
var ms = m - 1
if (ms == 1) { // 2月份的闰平规律测算后确认返回28或29
return (((y % 4 == 0) && (y % 100 != 0) || (y % 400 == 0)) ? 29 : 28)
} else {
return (this.solarMonth[ms])
}
},
/**
* 农历年份转换为干支纪年
* @param lYear 农历年的年份数
* @return Cn string
*/
toGanZhiYear: function (lYear) {
var ganKey = (lYear - 3) % 10
var zhiKey = (lYear - 3) % 12
if (ganKey == 0) ganKey = 10// 如果余数为0则为最后一个天干
if (zhiKey == 0) zhiKey = 12// 如果余数为0则为最后一个地支
return this.Gan[ganKey - 1] + this.Zhi[zhiKey - 1]
},
/**
* 公历月、日判断所属星座
* @param cMonth [description]
* @param cDay [description]
* @return Cn string
*/
toAstro: function (cMonth, cDay) {
var s = '\u9b54\u7faf\u6c34\u74f6\u53cc\u9c7c\u767d\u7f8a\u91d1\u725b\u53cc\u5b50\u5de8\u87f9\u72ee\u5b50\u5904\u5973\u5929\u79e4\u5929\u874e\u5c04\u624b\u9b54\u7faf'
var arr = [20, 19, 21, 21, 21, 22, 23, 23, 23, 23, 22, 22]
return s.substr(cMonth * 2 - (cDay < arr[cMonth - 1] ? 2 : 0), 2) + '\u5ea7'// 座
},
/**
* 传入offset偏移量返回干支
* @param offset 相对甲子的偏移量
* @return Cn string
*/
toGanZhi: function (offset) {
return this.Gan[offset % 10] + this.Zhi[offset % 12]
},
/**
* 传入公历(!)y年获得该年第n个节气的公历日期
* @param y公历年(1900-2100)n二十四节气中的第几个节气(1~24)从n=1(小寒)算起
* @return day Number
* @eg:var _24 = calendar.getTerm(1987,3) ;//_24=4;意即1987年2月4日立春
*/
getTerm: function (y, n) {
if (y < 1900 || y > 2100) { return -1 }
if (n < 1 || n > 24) { return -1 }
var _table = this.sTermInfo[y - 1900]
var _info = [
parseInt('0x' + _table.substr(0, 5)).toString(),
parseInt('0x' + _table.substr(5, 5)).toString(),
parseInt('0x' + _table.substr(10, 5)).toString(),
parseInt('0x' + _table.substr(15, 5)).toString(),
parseInt('0x' + _table.substr(20, 5)).toString(),
parseInt('0x' + _table.substr(25, 5)).toString()
]
var _calday = [
_info[0].substr(0, 1),
_info[0].substr(1, 2),
_info[0].substr(3, 1),
_info[0].substr(4, 2),
_info[1].substr(0, 1),
_info[1].substr(1, 2),
_info[1].substr(3, 1),
_info[1].substr(4, 2),
_info[2].substr(0, 1),
_info[2].substr(1, 2),
_info[2].substr(3, 1),
_info[2].substr(4, 2),
_info[3].substr(0, 1),
_info[3].substr(1, 2),
_info[3].substr(3, 1),
_info[3].substr(4, 2),
_info[4].substr(0, 1),
_info[4].substr(1, 2),
_info[4].substr(3, 1),
_info[4].substr(4, 2),
_info[5].substr(0, 1),
_info[5].substr(1, 2),
_info[5].substr(3, 1),
_info[5].substr(4, 2)
]
return parseInt(_calday[n - 1])
},
/**
* 传入农历数字月份返回汉语通俗表示法
* @param lunar month
* @return Cn string
* @eg:var cnMonth = calendar.toChinaMonth(12) ;//cnMonth='腊月'
*/
toChinaMonth: function (m) { // 月 => \u6708
if (m > 12 || m < 1) { return -1 } // 若参数错误 返回-1
var s = this.nStr3[m - 1]
s += '\u6708'// 加上月字
return s
},
/**
* 传入农历日期数字返回汉字表示法
* @param lunar day
* @return Cn string
* @eg:var cnDay = calendar.toChinaDay(21) ;//cnMonth='廿一'
*/
toChinaDay: function (d) { // 日 => \u65e5
var s
switch (d) {
case 10:
s = '\u521d\u5341'; break
case 20:
s = '\u4e8c\u5341'; break
case 30:
s = '\u4e09\u5341'; break
default :
s = this.nStr2[Math.floor(d / 10)]
s += this.nStr1[d % 10]
}
return (s)
},
/**
* 年份转生肖[!仅能大致转换] => 精确划分生肖分界线是“立春”
* @param y year
* @return Cn string
* @eg:var animal = calendar.getAnimal(1987) ;//animal='兔'
*/
getAnimal: function (y) {
return this.Animals[(y - 4) % 12]
},
/**
* 传入阳历年月日获得详细的公历、农历object信息 <=>JSON
* @param y solar year
* @param m solar month
* @param d solar day
* @return JSON object
* @eg:console.log(calendar.solar2lunar(1987,11,01));
*/
solar2lunar: function (y, m, d) { // 参数区间1900.1.31~2100.12.31
// 年份限定、上限
if (y < 1900 || y > 2100) {
return -1// undefined转换为数字变为NaN
}
// 公历传参最下限
if (y == 1900 && m == 1 && d < 31) {
return -1
}
// 未传参 获得当天
if (!y) {
var objDate = new Date()
} else {
var objDate = new Date(y, parseInt(m) - 1, d)
}
var i; var leap = 0; var temp = 0
// 修正ymd参数
var y = objDate.getFullYear()
var m = objDate.getMonth() + 1
var d = objDate.getDate()
var offset = (Date.UTC(objDate.getFullYear(), objDate.getMonth(), objDate.getDate()) - Date.UTC(1900, 0, 31)) / 86400000
for (i = 1900; i < 2101 && offset > 0; i++) {
temp = this.lYearDays(i)
offset -= temp
}
if (offset < 0) {
offset += temp; i--
}
// 是否今天
var isTodayObj = new Date()
var isToday = false
if (isTodayObj.getFullYear() == y && isTodayObj.getMonth() + 1 == m && isTodayObj.getDate() == d) {
isToday = true
}
// 星期几
var nWeek = objDate.getDay()
var cWeek = this.nStr1[nWeek]
// 数字表示周几顺应天朝周一开始的惯例
if (nWeek == 0) {
nWeek = 7
}
// 农历年
var year = i
var leap = this.leapMonth(i) // 闰哪个月
var isLeap = false
// 效验闰月
for (i = 1; i < 13 && offset > 0; i++) {
// 闰月
if (leap > 0 && i == (leap + 1) && isLeap == false) {
--i
isLeap = true; temp = this.leapDays(year) // 计算农历闰月天数
} else {
temp = this.monthDays(year, i)// 计算农历普通月天数
}
// 解除闰月
if (isLeap == true && i == (leap + 1)) { isLeap = false }
offset -= temp
}
// 闰月导致数组下标重叠取反
if (offset == 0 && leap > 0 && i == leap + 1) {
if (isLeap) {
isLeap = false
} else {
isLeap = true; --i
}
}
if (offset < 0) {
offset += temp; --i
}
// 农历月
var month = i
// 农历日
var day = offset + 1
// 天干地支处理
var sm = m - 1
var gzY = this.toGanZhiYear(year)
// 当月的两个节气
// bugfix-2017-7-24 11:03:38 use lunar Year Param `y` Not `year`
var firstNode = this.getTerm(y, (m * 2 - 1))// 返回当月「节」为几日开始
var secondNode = this.getTerm(y, (m * 2))// 返回当月「节」为几日开始
// 依据12节气修正干支月
var gzM = this.toGanZhi((y - 1900) * 12 + m + 11)
if (d >= firstNode) {
gzM = this.toGanZhi((y - 1900) * 12 + m + 12)
}
// 传入的日期的节气与否
var isTerm = false
var Term = null
if (firstNode == d) {
isTerm = true
Term = this.solarTerm[m * 2 - 2]
}
if (secondNode == d) {
isTerm = true
Term = this.solarTerm[m * 2 - 1]
}
// 日柱 当月一日与 1900/1/1 相差天数
var dayCyclical = Date.UTC(y, sm, 1, 0, 0, 0, 0) / 86400000 + 25567 + 10
var gzD = this.toGanZhi(dayCyclical + d - 1)
// 该日期所属的星座
var astro = this.toAstro(m, d)
return { 'lYear': year, 'lMonth': month, 'lDay': day, 'Animal': this.getAnimal(year), 'IMonthCn': (isLeap ? '\u95f0' : '') + this.toChinaMonth(month), 'IDayCn': this.toChinaDay(day), 'cYear': y, 'cMonth': m, 'cDay': d, 'gzYear': gzY, 'gzMonth': gzM, 'gzDay': gzD, 'isToday': isToday, 'isLeap': isLeap, 'nWeek': nWeek, 'ncWeek': '\u661f\u671f' + cWeek, 'isTerm': isTerm, 'Term': Term, 'astro': astro }
},
/**
* 传入农历年月日以及传入的月份是否闰月获得详细的公历、农历object信息 <=>JSON
* @param y lunar year
* @param m lunar month
* @param d lunar day
* @param isLeapMonth lunar month is leap or not.[如果是农历闰月第四个参数赋值true即可]
* @return JSON object
* @eg:console.log(calendar.lunar2solar(1987,9,10));
*/
lunar2solar: function (y, m, d, isLeapMonth) { // 参数区间1900.1.31~2100.12.1
var isLeapMonth = !!isLeapMonth
var leapOffset = 0
var leapMonth = this.leapMonth(y)
var leapDay = this.leapDays(y)
if (isLeapMonth && (leapMonth != m)) { return -1 }// 传参要求计算该闰月公历 但该年得出的闰月与传参的月份并不同
if (y == 2100 && m == 12 && d > 1 || y == 1900 && m == 1 && d < 31) { return -1 }// 超出了最大极限值
var day = this.monthDays(y, m)
var _day = day
// bugFix 2016-9-25
// if month is leap, _day use leapDays method
if (isLeapMonth) {
_day = this.leapDays(y, m)
}
if (y < 1900 || y > 2100 || d > _day) { return -1 }// 参数合法性效验
// 计算农历的时间差
var offset = 0
for (var i = 1900; i < y; i++) {
offset += this.lYearDays(i)
}
var leap = 0; var isAdd = false
for (var i = 1; i < m; i++) {
leap = this.leapMonth(y)
if (!isAdd) { // 处理闰月
if (leap <= i && leap > 0) {
offset += this.leapDays(y); isAdd = true
}
}
offset += this.monthDays(y, i)
}
// 转换闰月农历 需补充该年闰月的前一个月的时差
if (isLeapMonth) { offset += day }
// 1900年农历正月一日的公历时间为1900年1月30日0时0分0秒(该时间也是本农历的最开始起始点)
var stmap = Date.UTC(1900, 1, 30, 0, 0, 0)
var calObj = new Date((offset + d - 31) * 86400000 + stmap)
var cY = calObj.getUTCFullYear()
var cM = calObj.getUTCMonth() + 1
var cD = calObj.getUTCDate()
return this.solar2lunar(cY, cM, cD)
}
}
export default calendar

View File

@@ -0,0 +1,12 @@
{
"uni-calender.ok": "ok",
"uni-calender.cancel": "cancel",
"uni-calender.today": "today",
"uni-calender.MON": "MON",
"uni-calender.TUE": "TUE",
"uni-calender.WED": "WED",
"uni-calender.THU": "THU",
"uni-calender.FRI": "FRI",
"uni-calender.SAT": "SAT",
"uni-calender.SUN": "SUN"
}

View File

@@ -0,0 +1,8 @@
import en from './en.json'
import zhHans from './zh-Hans.json'
import zhHant from './zh-Hant.json'
export default {
en,
'zh-Hans': zhHans,
'zh-Hant': zhHant
}

View File

@@ -0,0 +1,12 @@
{
"uni-calender.ok": "确定",
"uni-calender.cancel": "取消",
"uni-calender.today": "今日",
"uni-calender.SUN": "日",
"uni-calender.MON": "一",
"uni-calender.TUE": "二",
"uni-calender.WED": "三",
"uni-calender.THU": "四",
"uni-calender.FRI": "五",
"uni-calender.SAT": "六"
}

View File

@@ -0,0 +1,12 @@
{
"uni-calender.ok": "確定",
"uni-calender.cancel": "取消",
"uni-calender.today": "今日",
"uni-calender.SUN": "日",
"uni-calender.MON": "一",
"uni-calender.TUE": "二",
"uni-calender.WED": "三",
"uni-calender.THU": "四",
"uni-calender.FRI": "五",
"uni-calender.SAT": "六"
}

View File

@@ -0,0 +1,187 @@
<template>
<view class="uni-calendar-item__weeks-box" :class="{
'uni-calendar-item--disable':weeks.disable,
'uni-calendar-item--isDay':calendar.fullDate === weeks.fullDate && weeks.isDay,
'uni-calendar-item--checked':(calendar.fullDate === weeks.fullDate && !weeks.isDay) ,
'uni-calendar-item--before-checked':weeks.beforeMultiple,
'uni-calendar-item--multiple': weeks.multiple,
'uni-calendar-item--after-checked':weeks.afterMultiple,
}"
@click="choiceDate(weeks)">
<view class="uni-calendar-item__weeks-box-item">
<text v-if="selected&&weeks.extraInfo" class="uni-calendar-item__weeks-box-circle"></text>
<text class="uni-calendar-item__weeks-box-text" :class="{
'uni-calendar-item--isDay-text': weeks.isDay,
'uni-calendar-item--isDay':calendar.fullDate === weeks.fullDate && weeks.isDay,
'uni-calendar-item--checked':calendar.fullDate === weeks.fullDate && !weeks.isDay,
'uni-calendar-item--before-checked':weeks.beforeMultiple,
'uni-calendar-item--multiple': weeks.multiple,
'uni-calendar-item--after-checked':weeks.afterMultiple,
'uni-calendar-item--disable':weeks.disable,
}">{{weeks.date}}</text>
<text v-if="!lunar&&!weeks.extraInfo && weeks.isDay" class="uni-calendar-item__weeks-lunar-text" :class="{
'uni-calendar-item--isDay-text':weeks.isDay,
'uni-calendar-item--isDay':calendar.fullDate === weeks.fullDate && weeks.isDay,
'uni-calendar-item--checked':calendar.fullDate === weeks.fullDate && !weeks.isDay,
'uni-calendar-item--before-checked':weeks.beforeMultiple,
'uni-calendar-item--multiple': weeks.multiple,
'uni-calendar-item--after-checked':weeks.afterMultiple,
}">{{todayText}}</text>
<text v-if="lunar&&!weeks.extraInfo" class="uni-calendar-item__weeks-lunar-text" :class="{
'uni-calendar-item--isDay-text':weeks.isDay,
'uni-calendar-item--isDay':calendar.fullDate === weeks.fullDate && weeks.isDay,
'uni-calendar-item--checked':calendar.fullDate === weeks.fullDate && !weeks.isDay,
'uni-calendar-item--before-checked':weeks.beforeMultiple,
'uni-calendar-item--multiple': weeks.multiple,
'uni-calendar-item--after-checked':weeks.afterMultiple,
'uni-calendar-item--disable':weeks.disable,
}">{{weeks.isDay ? todayText : (weeks.lunar.IDayCn === '初一'?weeks.lunar.IMonthCn:weeks.lunar.IDayCn)}}</text>
<text v-if="weeks.extraInfo&&weeks.extraInfo.info" class="uni-calendar-item__weeks-lunar-text" :class="{
'uni-calendar-item--extra':weeks.extraInfo.info,
'uni-calendar-item--isDay-text':weeks.isDay,
'uni-calendar-item--isDay':calendar.fullDate === weeks.fullDate && weeks.isDay,
'uni-calendar-item--checked':calendar.fullDate === weeks.fullDate && !weeks.isDay,
'uni-calendar-item--before-checked':weeks.beforeMultiple,
'uni-calendar-item--multiple': weeks.multiple,
'uni-calendar-item--after-checked':weeks.afterMultiple,
'uni-calendar-item--disable':weeks.disable,
}">{{weeks.extraInfo.info}}</text>
</view>
</view>
</template>
<script>
import { initVueI18n } from '@dcloudio/uni-i18n'
import i18nMessages from './i18n/index.js'
const { t } = initVueI18n(i18nMessages)
export default {
emits:['change'],
props: {
weeks: {
type: Object,
default () {
return {}
}
},
calendar: {
type: Object,
default: () => {
return {}
}
},
selected: {
type: Array,
default: () => {
return []
}
},
lunar: {
type: Boolean,
default: false
}
},
computed: {
todayText() {
return t("uni-calender.today")
},
},
methods: {
choiceDate(weeks) {
this.$emit('change', weeks)
}
}
}
</script>
<style lang="scss" scoped>
$uni-font-size-base:14px;
$uni-text-color:#333;
$uni-font-size-sm:12px;
$uni-color-error: #e43d33;
$uni-opacity-disabled: 0.3;
$uni-text-color-disable:#c0c0c0;
$uni-primary: #2979ff !default;
.uni-calendar-item__weeks-box {
flex: 1;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
justify-content: center;
align-items: center;
}
.uni-calendar-item__weeks-box-text {
font-size: $uni-font-size-base;
color: $uni-text-color;
}
.uni-calendar-item__weeks-lunar-text {
font-size: $uni-font-size-sm;
color: $uni-text-color;
}
.uni-calendar-item__weeks-box-item {
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
justify-content: center;
align-items: center;
width: 100rpx;
height: 100rpx;
}
.uni-calendar-item__weeks-box-circle {
position: absolute;
top: 5px;
right: 5px;
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $uni-color-error;
}
.uni-calendar-item--disable {
background-color: rgba(249, 249, 249, $uni-opacity-disabled);
color: $uni-text-color-disable;
}
.uni-calendar-item--isDay-text {
color: $uni-primary;
}
.uni-calendar-item--isDay {
background-color: $uni-primary;
opacity: 0.8;
color: #fff;
}
.uni-calendar-item--extra {
color: $uni-color-error;
opacity: 0.8;
}
.uni-calendar-item--checked {
background-color: $uni-primary;
color: #fff;
opacity: 0.8;
}
.uni-calendar-item--multiple {
background-color: $uni-primary;
color: #fff;
opacity: 0.8;
}
.uni-calendar-item--before-checked {
background-color: #ff5a5f;
color: #fff;
}
.uni-calendar-item--after-checked {
background-color: #ff5a5f;
color: #fff;
}
</style>

View File

@@ -0,0 +1,567 @@
<template>
<view class="uni-calendar">
<view v-if="!insert&&show" class="uni-calendar__mask" :class="{'uni-calendar--mask-show':aniMaskShow}" @click="clean"></view>
<view v-if="insert || show" class="uni-calendar__content" :class="{'uni-calendar--fixed':!insert,'uni-calendar--ani-show':aniMaskShow}">
<view v-if="!insert" class="uni-calendar__header uni-calendar--fixed-top">
<view class="uni-calendar__header-btn-box" @click="close">
<text class="uni-calendar__header-text uni-calendar--fixed-width">{{cancelText}}</text>
</view>
<view class="uni-calendar__header-btn-box" @click="confirm">
<text class="uni-calendar__header-text uni-calendar--fixed-width">{{okText}}</text>
</view>
</view>
<view class="uni-calendar__header">
<view class="uni-calendar__header-btn-box" @click.stop="pre">
<view class="uni-calendar__header-btn uni-calendar--left"></view>
</view>
<picker mode="date" :value="date" fields="month" @change="bindDateChange">
<text class="uni-calendar__header-text">{{ (nowDate.year||'') +' / '+( nowDate.month||'')}}</text>
</picker>
<view class="uni-calendar__header-btn-box" @click.stop="next">
<view class="uni-calendar__header-btn uni-calendar--right"></view>
</view>
<text class="uni-calendar__backtoday" @click="backToday">{{todayText}}</text>
</view>
<view class="uni-calendar__box">
<view v-if="showMonth" class="uni-calendar__box-bg">
<text class="uni-calendar__box-bg-text">{{nowDate.month}}</text>
</view>
<view class="uni-calendar__weeks">
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{SUNText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{monText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{TUEText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{WEDText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{THUText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{FRIText}}</text>
</view>
<view class="uni-calendar__weeks-day">
<text class="uni-calendar__weeks-day-text">{{SATText}}</text>
</view>
</view>
<view class="uni-calendar__weeks" v-for="(item,weekIndex) in weeks" :key="weekIndex">
<view class="uni-calendar__weeks-item" v-for="(weeks,weeksIndex) in item" :key="weeksIndex">
<calendar-item class="uni-calendar-item--hook" :weeks="weeks" :calendar="calendar" :selected="selected" :lunar="lunar" @change="choiceDate"></calendar-item>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import Calendar from './util.js';
import CalendarItem from './uni-calendar-item.vue'
import { initVueI18n } from '@dcloudio/uni-i18n'
import i18nMessages from './i18n/index.js'
const { t } = initVueI18n(i18nMessages)
/**
* Calendar 日历
* @description 日历组件可以查看日期,选择任意范围内的日期,打点操作。常用场景如:酒店日期预订、火车机票选择购买日期、上下班打卡等
* @tutorial https://ext.dcloud.net.cn/plugin?id=56
* @property {String} date 自定义当前时间,默认为今天
* @property {Boolean} lunar 显示农历
* @property {String} startDate 日期选择范围-开始日期
* @property {String} endDate 日期选择范围-结束日期
* @property {Boolean} range 范围选择
* @property {Boolean} insert = [true|false] 插入模式,默认为false
* @value true 弹窗模式
* @value false 插入模式
* @property {Boolean} clearDate = [true|false] 弹窗模式是否清空上次选择内容
* @property {Array} selected 打点,期待格式[{date: '2019-06-27', info: '签到', data: { custom: '自定义信息', name: '自定义消息头',xxx:xxx... }}]
* @property {Boolean} showMonth 是否选择月份为背景
* @event {Function} change 日期改变,`insert :ture` 时生效
* @event {Function} confirm 确认选择`insert :false` 时生效
* @event {Function} monthSwitch 切换月份时触发
* @example <uni-calendar :insert="true":lunar="true" :start-date="'2019-3-2'":end-date="'2019-5-20'"@change="change" />
*/
export default {
components: {
CalendarItem
},
emits:['close','confirm','change','monthSwitch'],
props: {
date: {
type: String,
default: ''
},
selected: {
type: Array,
default () {
return []
}
},
lunar: {
type: Boolean,
default: false
},
startDate: {
type: String,
default: ''
},
endDate: {
type: String,
default: ''
},
range: {
type: Boolean,
default: false
},
insert: {
type: Boolean,
default: true
},
showMonth: {
type: Boolean,
default: true
},
clearDate: {
type: Boolean,
default: true
}
},
data() {
return {
show: false,
weeks: [],
calendar: {},
nowDate: '',
aniMaskShow: false
}
},
computed:{
/**
* for i18n
*/
okText() {
return t("uni-calender.ok")
},
cancelText() {
return t("uni-calender.cancel")
},
todayText() {
return t("uni-calender.today")
},
monText() {
return t("uni-calender.MON")
},
TUEText() {
return t("uni-calender.TUE")
},
WEDText() {
return t("uni-calender.WED")
},
THUText() {
return t("uni-calender.THU")
},
FRIText() {
return t("uni-calender.FRI")
},
SATText() {
return t("uni-calender.SAT")
},
SUNText() {
return t("uni-calender.SUN")
},
},
watch: {
date(newVal) {
// this.cale.setDate(newVal)
this.init(newVal)
},
startDate(val){
this.cale.resetSatrtDate(val)
this.cale.setDate(this.nowDate.fullDate)
this.weeks = this.cale.weeks
},
endDate(val){
this.cale.resetEndDate(val)
this.cale.setDate(this.nowDate.fullDate)
this.weeks = this.cale.weeks
},
selected(newVal) {
this.cale.setSelectInfo(this.nowDate.fullDate, newVal)
this.weeks = this.cale.weeks
}
},
created() {
this.cale = new Calendar({
selected: this.selected,
startDate: this.startDate,
endDate: this.endDate,
range: this.range,
})
this.init(this.date)
},
methods: {
// 取消穿透
clean() {},
bindDateChange(e) {
const value = e.detail.value + '-1'
this.setDate(value)
const { year,month } = this.cale.getDate(value)
this.$emit('monthSwitch', {
year,
month
})
},
/**
* 初始化日期显示
* @param {Object} date
*/
init(date) {
this.cale.setDate(date)
this.weeks = this.cale.weeks
this.nowDate = this.calendar = this.cale.getInfo(date)
},
/**
* 打开日历弹窗
*/
open() {
// 弹窗模式并且清理数据
if (this.clearDate && !this.insert) {
this.cale.cleanMultipleStatus()
// this.cale.setDate(this.date)
this.init(this.date)
}
this.show = true
this.$nextTick(() => {
setTimeout(() => {
this.aniMaskShow = true
}, 50)
})
},
/**
* 关闭日历弹窗
*/
close() {
this.aniMaskShow = false
this.$nextTick(() => {
setTimeout(() => {
this.show = false
this.$emit('close')
}, 300)
})
},
/**
* 确认按钮
*/
confirm() {
this.setEmit('confirm')
this.close()
},
/**
* 变化触发
*/
change() {
if (!this.insert) return
this.setEmit('change')
},
/**
* 选择月份触发
*/
monthSwitch() {
let {
year,
month
} = this.nowDate
this.$emit('monthSwitch', {
year,
month: Number(month)
})
},
/**
* 派发事件
* @param {Object} name
*/
setEmit(name) {
let {
year,
month,
date,
fullDate,
lunar,
extraInfo
} = this.calendar
this.$emit(name, {
range: this.cale.multipleStatus,
year,
month,
date,
fulldate: fullDate,
lunar,
extraInfo: extraInfo || {}
})
},
/**
* 选择天触发
* @param {Object} weeks
*/
choiceDate(weeks) {
if (weeks.disable) return
this.calendar = weeks
// 设置多选
this.cale.setMultiple(this.calendar.fullDate)
this.weeks = this.cale.weeks
this.change()
},
/**
* 回到今天
*/
backToday() {
const nowYearMonth = `${this.nowDate.year}-${this.nowDate.month}`
const date = this.cale.getDate(new Date())
const todayYearMonth = `${date.year}-${date.month}`
this.init(date.fullDate)
if(nowYearMonth !== todayYearMonth) {
this.monthSwitch()
}
this.change()
},
/**
* 上个月
*/
pre() {
const preDate = this.cale.getDate(this.nowDate.fullDate, -1, 'month').fullDate
this.setDate(preDate)
this.monthSwitch()
},
/**
* 下个月
*/
next() {
const nextDate = this.cale.getDate(this.nowDate.fullDate, +1, 'month').fullDate
this.setDate(nextDate)
this.monthSwitch()
},
/**
* 设置日期
* @param {Object} date
*/
setDate(date) {
this.cale.setDate(date)
this.weeks = this.cale.weeks
this.nowDate = this.cale.getInfo(date)
}
}
}
</script>
<style lang="scss" scoped>
$uni-bg-color-mask: rgba($color: #000000, $alpha: 0.4);
$uni-border-color: #EDEDED;
$uni-text-color: #333;
$uni-bg-color-hover:#f1f1f1;
$uni-font-size-base:14px;
$uni-text-color-placeholder: #808080;
$uni-color-subtitle: #555555;
$uni-text-color-grey:#999;
.uni-calendar {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
}
.uni-calendar__mask {
position: fixed;
bottom: 0;
top: 0;
left: 0;
right: 0;
background-color: $uni-bg-color-mask;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 0;
/* #ifndef APP-NVUE */
z-index: 99;
/* #endif */
}
.uni-calendar--mask-show {
opacity: 1
}
.uni-calendar--fixed {
position: fixed;
/* #ifdef APP-NVUE */
bottom: 0;
/* #endif */
left: 0;
right: 0;
transition-property: transform;
transition-duration: 0.3s;
transform: translateY(460px);
/* #ifndef APP-NVUE */
bottom: calc(var(--window-bottom));
z-index: 99;
/* #endif */
}
.uni-calendar--ani-show {
transform: translateY(0);
}
.uni-calendar__content {
background-color: #fff;
}
.uni-calendar__header {
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
justify-content: center;
align-items: center;
height: 50px;
border-bottom-color: $uni-border-color;
border-bottom-style: solid;
border-bottom-width: 1px;
}
.uni-calendar--fixed-top {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
justify-content: space-between;
border-top-color: $uni-border-color;
border-top-style: solid;
border-top-width: 1px;
}
.uni-calendar--fixed-width {
width: 50px;
}
.uni-calendar__backtoday {
position: absolute;
right: 0;
top: 25rpx;
padding: 0 5px;
padding-left: 10px;
height: 25px;
line-height: 25px;
font-size: 12px;
border-top-left-radius: 25px;
border-bottom-left-radius: 25px;
color: $uni-text-color;
background-color: $uni-bg-color-hover;
}
.uni-calendar__header-text {
text-align: center;
width: 100px;
font-size: $uni-font-size-base;
color: $uni-text-color;
}
.uni-calendar__header-btn-box {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
}
.uni-calendar__header-btn {
width: 10px;
height: 10px;
border-left-color: $uni-text-color-placeholder;
border-left-style: solid;
border-left-width: 2px;
border-top-color: $uni-color-subtitle;
border-top-style: solid;
border-top-width: 2px;
}
.uni-calendar--left {
transform: rotate(-45deg);
}
.uni-calendar--right {
transform: rotate(135deg);
}
.uni-calendar__weeks {
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
}
.uni-calendar__weeks-item {
flex: 1;
}
.uni-calendar__weeks-day {
flex: 1;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
justify-content: center;
align-items: center;
height: 45px;
border-bottom-color: #F5F5F5;
border-bottom-style: solid;
border-bottom-width: 1px;
}
.uni-calendar__weeks-day-text {
font-size: 14px;
}
.uni-calendar__box {
position: relative;
}
.uni-calendar__box-bg {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.uni-calendar__box-bg-text {
font-size: 200px;
font-weight: bold;
color: $uni-text-color-grey;
opacity: 0.1;
text-align: center;
/* #ifndef APP-NVUE */
line-height: 1;
/* #endif */
}
</style>

View File

@@ -0,0 +1,360 @@
import CALENDAR from './calendar.js'
class Calendar {
constructor({
date,
selected,
startDate,
endDate,
range
} = {}) {
// 当前日期
this.date = this.getDate(new Date()) // 当前初入日期
// 打点信息
this.selected = selected || [];
// 范围开始
this.startDate = startDate
// 范围结束
this.endDate = endDate
this.range = range
// 多选状态
this.cleanMultipleStatus()
// 每周日期
this.weeks = {}
// this._getWeek(this.date.fullDate)
}
/**
* 设置日期
* @param {Object} date
*/
setDate(date) {
this.selectDate = this.getDate(date)
this._getWeek(this.selectDate.fullDate)
}
/**
* 清理多选状态
*/
cleanMultipleStatus() {
this.multipleStatus = {
before: '',
after: '',
data: []
}
}
/**
* 重置开始日期
*/
resetSatrtDate(startDate) {
// 范围开始
this.startDate = startDate
}
/**
* 重置结束日期
*/
resetEndDate(endDate) {
// 范围结束
this.endDate = endDate
}
/**
* 获取任意时间
*/
getDate(date, AddDayCount = 0, str = 'day') {
if (!date) {
date = new Date()
}
if (typeof date !== 'object') {
date = date.replace(/-/g, '/')
}
const dd = new Date(date)
switch (str) {
case 'day':
dd.setDate(dd.getDate() + AddDayCount) // 获取AddDayCount天后的日期
break
case 'month':
if (dd.getDate() === 31 && AddDayCount>0) {
dd.setDate(dd.getDate() + AddDayCount)
} else {
const preMonth = dd.getMonth()
dd.setMonth(preMonth + AddDayCount) // 获取AddDayCount天后的日期
const nextMonth = dd.getMonth()
// 处理 pre 切换月份目标月份为2月没有当前日(30 31) 切换错误问题
if(AddDayCount<0 && preMonth!==0 && nextMonth-preMonth>AddDayCount){
dd.setMonth(nextMonth+(nextMonth-preMonth+AddDayCount))
}
// 处理 next 切换月份目标月份为2月没有当前日(30 31) 切换错误问题
if(AddDayCount>0 && nextMonth-preMonth>AddDayCount){
dd.setMonth(nextMonth-(nextMonth-preMonth-AddDayCount))
}
}
break
case 'year':
dd.setFullYear(dd.getFullYear() + AddDayCount) // 获取AddDayCount天后的日期
break
}
const y = dd.getFullYear()
const m = dd.getMonth() + 1 < 10 ? '0' + (dd.getMonth() + 1) : dd.getMonth() + 1 // 获取当前月份的日期不足10补0
const d = dd.getDate() < 10 ? '0' + dd.getDate() : dd.getDate() // 获取当前几号不足10补0
return {
fullDate: y + '-' + m + '-' + d,
year: y,
month: m,
date: d,
day: dd.getDay()
}
}
/**
* 获取上月剩余天数
*/
_getLastMonthDays(firstDay, full) {
let dateArr = []
for (let i = firstDay; i > 0; i--) {
const beforeDate = new Date(full.year, full.month - 1, -i + 1).getDate()
dateArr.push({
date: beforeDate,
month: full.month - 1,
lunar: this.getlunar(full.year, full.month - 1, beforeDate),
disable: true
})
}
return dateArr
}
/**
* 获取本月天数
*/
_currentMonthDys(dateData, full) {
let dateArr = []
let fullDate = this.date.fullDate
for (let i = 1; i <= dateData; i++) {
let nowDate = full.year + '-' + (full.month < 10 ?
full.month : full.month) + '-' + (i < 10 ?
'0' + i : i)
// 是否今天
let isDay = fullDate === nowDate
// 获取打点信息
let info = this.selected && this.selected.find((item) => {
if (this.dateEqual(nowDate, item.date)) {
return item
}
})
// 日期禁用
let disableBefore = true
let disableAfter = true
if (this.startDate) {
// let dateCompBefore = this.dateCompare(this.startDate, fullDate)
// disableBefore = this.dateCompare(dateCompBefore ? this.startDate : fullDate, nowDate)
disableBefore = this.dateCompare(this.startDate, nowDate)
}
if (this.endDate) {
// let dateCompAfter = this.dateCompare(fullDate, this.endDate)
// disableAfter = this.dateCompare(nowDate, dateCompAfter ? this.endDate : fullDate)
disableAfter = this.dateCompare(nowDate, this.endDate)
}
let multiples = this.multipleStatus.data
let checked = false
let multiplesStatus = -1
if (this.range) {
if (multiples) {
multiplesStatus = multiples.findIndex((item) => {
return this.dateEqual(item, nowDate)
})
}
if (multiplesStatus !== -1) {
checked = true
}
}
let data = {
fullDate: nowDate,
year: full.year,
date: i,
multiple: this.range ? checked : false,
beforeMultiple: this.dateEqual(this.multipleStatus.before, nowDate),
afterMultiple: this.dateEqual(this.multipleStatus.after, nowDate),
month: full.month,
lunar: this.getlunar(full.year, full.month, i),
disable: !(disableBefore && disableAfter),
isDay
}
if (info) {
data.extraInfo = info
}
dateArr.push(data)
}
return dateArr
}
/**
* 获取下月天数
*/
_getNextMonthDays(surplus, full) {
let dateArr = []
for (let i = 1; i < surplus + 1; i++) {
dateArr.push({
date: i,
month: Number(full.month) + 1,
lunar: this.getlunar(full.year, Number(full.month) + 1, i),
disable: true
})
}
return dateArr
}
/**
* 获取当前日期详情
* @param {Object} date
*/
getInfo(date) {
if (!date) {
date = new Date()
}
const dateInfo = this.canlender.find(item => item.fullDate === this.getDate(date).fullDate)
return dateInfo
}
/**
* 比较时间大小
*/
dateCompare(startDate, endDate) {
// 计算截止时间
startDate = new Date(startDate.replace('-', '/').replace('-', '/'))
// 计算详细项的截止时间
endDate = new Date(endDate.replace('-', '/').replace('-', '/'))
if (startDate <= endDate) {
return true
} else {
return false
}
}
/**
* 比较时间是否相等
*/
dateEqual(before, after) {
// 计算截止时间
before = new Date(before.replace('-', '/').replace('-', '/'))
// 计算详细项的截止时间
after = new Date(after.replace('-', '/').replace('-', '/'))
if (before.getTime() - after.getTime() === 0) {
return true
} else {
return false
}
}
/**
* 获取日期范围内所有日期
* @param {Object} begin
* @param {Object} end
*/
geDateAll(begin, end) {
var arr = []
var ab = begin.split('-')
var ae = end.split('-')
var db = new Date()
db.setFullYear(ab[0], ab[1] - 1, ab[2])
var de = new Date()
de.setFullYear(ae[0], ae[1] - 1, ae[2])
var unixDb = db.getTime() - 24 * 60 * 60 * 1000
var unixDe = de.getTime() - 24 * 60 * 60 * 1000
for (var k = unixDb; k <= unixDe;) {
k = k + 24 * 60 * 60 * 1000
arr.push(this.getDate(new Date(parseInt(k))).fullDate)
}
return arr
}
/**
* 计算阴历日期显示
*/
getlunar(year, month, date) {
return CALENDAR.solar2lunar(year, month, date)
}
/**
* 设置打点
*/
setSelectInfo(data, value) {
this.selected = value
this._getWeek(data)
}
/**
* 获取多选状态
*/
setMultiple(fullDate) {
let {
before,
after
} = this.multipleStatus
if (!this.range) return
if (before && after) {
this.multipleStatus.before = fullDate
this.multipleStatus.after = ''
this.multipleStatus.data = []
} else {
if (!before) {
this.multipleStatus.before = fullDate
} else {
this.multipleStatus.after = fullDate
if (this.dateCompare(this.multipleStatus.before, this.multipleStatus.after)) {
this.multipleStatus.data = this.geDateAll(this.multipleStatus.before, this.multipleStatus.after);
} else {
this.multipleStatus.data = this.geDateAll(this.multipleStatus.after, this.multipleStatus.before);
}
}
}
this._getWeek(fullDate)
}
/**
* 获取每周数据
* @param {Object} dateData
*/
_getWeek(dateData) {
const {
year,
month
} = this.getDate(dateData)
let firstDay = new Date(year, month - 1, 1).getDay()
let currentDay = new Date(year, month, 0).getDate()
let dates = {
lastMonthDays: this._getLastMonthDays(firstDay, this.getDate(dateData)), // 上个月末尾几天
currentMonthDys: this._currentMonthDys(currentDay, this.getDate(dateData)), // 本月天数
nextMonthDays: [], // 下个月开始几天
weeks: []
}
let canlender = []
const surplus = 42 - (dates.lastMonthDays.length + dates.currentMonthDys.length)
dates.nextMonthDays = this._getNextMonthDays(surplus, this.getDate(dateData))
canlender = canlender.concat(dates.lastMonthDays, dates.currentMonthDys, dates.nextMonthDays)
let weeks = {}
// 拼接数组 上个月开始几天 + 本月天数+ 下个月开始几天
for (let i = 0; i < canlender.length; i++) {
if (i % 7 === 0) {
weeks[parseInt(i / 7)] = new Array(7)
}
weeks[parseInt(i / 7)][i % 7] = canlender[i]
}
this.canlender = canlender
this.weeks = weeks
}
//静态方法
// static init(date) {
// if (!this.instance) {
// this.instance = new Calendar(date);
// }
// return this.instance;
// }
}
export default Calendar

View File

@@ -0,0 +1,86 @@
{
"id": "uni-calendar",
"displayName": "uni-calendar 日历",
"version": "1.4.12",
"description": "日历组件",
"keywords": [
"uni-ui",
"uniui",
"日历",
"",
"打卡",
"日历选择"
],
"repository": "https://github.com/dcloudio/uni-ui",
"engines": {
"HBuilderX": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/@dcloudio/uni-ui",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y"
},
"快应用": {
"华为": "u",
"联盟": "u"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
}
}

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