commit
c6b3791076
|
|
@ -0,0 +1,46 @@
|
|||
# 依赖
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 构建输出
|
||||
dist/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
|
||||
# 环境变量
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 测试
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# 其他
|
||||
.trae/
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# uniapp-vue3-template
|
||||
|
||||
基于 Vue3 + Vite + Pinia + uview-plus 的 UniApp 项目模板
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **核心框架**: Vue3 + Vite
|
||||
- **状态管理**: Pinia(配置持久化存储)
|
||||
- **UI组件库**: uview-plus
|
||||
- **日期处理**: dayjs
|
||||
- **多端支持**: H5、微信小程序
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
uniapp-vue3-template/
|
||||
├── api/ # API 接口层
|
||||
│ ├── modules/ # 业务接口模块
|
||||
│ │ ├── auth.js # 登录注册接口
|
||||
│ │ └── user.js # 用户相关接口
|
||||
│ ├── request.js # HTTP 请求封装
|
||||
│ └── index.js # 接口统一导出
|
||||
├── components/ # 组件目录
|
||||
│ ├── common/ # 公共组件
|
||||
│ │ ├── lazy-image.vue # 图片懒加载
|
||||
│ │ ├── empty-state.vue # 空状态组件
|
||||
│ │ └── page-container.vue # 页面容器
|
||||
│ └── layout/ # 布局组件
|
||||
│ ├── layout-header.vue # 头部导航
|
||||
│ ├── layout-footer.vue # 底部标签栏
|
||||
│ └── layout-sidebar.vue # 侧边栏
|
||||
├── pages/ # 主包页面
|
||||
│ ├── index/ # 首页
|
||||
│ ├── login/ # 登录页
|
||||
│ ├── register/ # 注册页
|
||||
│ ├── user/ # 个人中心
|
||||
│ └── demo/ # 组件演示
|
||||
├── store/ # Pinia 状态管理
|
||||
│ ├── user.js # 用户状态
|
||||
│ ├── app.js # 应用状态
|
||||
│ └── index.js # 状态管理入口
|
||||
├── styles/ # 样式文件
|
||||
│ ├── uni.scss # 全局样式
|
||||
│ └── variable.scss # SCSS 变量
|
||||
├── subpkg/ # 分包目录
|
||||
│ ├── goods/ # 商品模块
|
||||
│ └── order/ # 订单模块
|
||||
├── utils/ # 工具函数
|
||||
│ ├── storage.js # 本地存储
|
||||
│ ├── validate.js # 表单验证
|
||||
│ ├── format.js # 格式化工具
|
||||
│ └── index.js # 工具导出
|
||||
├── App.vue # 应用入口
|
||||
├── main.js # 应用配置
|
||||
├── manifest.json # 应用配置文件
|
||||
├── pages.json # 页面配置
|
||||
├── package.json # 项目依赖
|
||||
└── vite.config.js # Vite 配置
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# H5 开发
|
||||
npm run dev:h5
|
||||
|
||||
# 微信小程序开发
|
||||
npm run dev:mp-weixin
|
||||
```
|
||||
|
||||
### 构建生产
|
||||
|
||||
```bash
|
||||
# H5 构建
|
||||
npm run build:h5
|
||||
|
||||
# 微信小程序构建
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ Pinia 状态管理 + 持久化存储
|
||||
- ✅ uview-plus 组件库集成
|
||||
- ✅ dayjs 日期处理
|
||||
- ✅ HTTP 请求封装(拦截器)
|
||||
- ✅ 路由分包加载
|
||||
- ✅ 图片懒加载组件
|
||||
- ✅ 页面缓存策略
|
||||
- ✅ 登录/注册功能模块
|
||||
- ✅ 基础布局组件(头部、底部、侧边栏)
|
||||
- ✅ 常用工具函数
|
||||
|
||||
## API 使用
|
||||
|
||||
```javascript
|
||||
import { get, post } from '@/api'
|
||||
import { login, register } from '@/api/modules/auth'
|
||||
|
||||
// GET 请求
|
||||
const list = await get('/user/list', { page: 1, pageSize: 10 })
|
||||
|
||||
// POST 请求
|
||||
const res = await login({ username: 'admin', password: '123456' })
|
||||
```
|
||||
|
||||
## Store 使用
|
||||
|
||||
```javascript
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 设置用户信息
|
||||
userStore.setUserInfo({ id: '1', username: 'admin' })
|
||||
|
||||
// 获取状态
|
||||
const isLoggedIn = userStore.isLoggedIn
|
||||
|
||||
// 退出登录
|
||||
userStore.logout()
|
||||
```
|
||||
|
||||
## 页面缓存
|
||||
|
||||
```javascript
|
||||
import { useAppStore } from '@/store/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 添加页面缓存
|
||||
appStore.addCachePage('user-profile')
|
||||
|
||||
// 移除页面缓存
|
||||
appStore.removeCachePage('user-profile')
|
||||
|
||||
// 清空缓存
|
||||
appStore.clearCache()
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 首次运行需要安装依赖
|
||||
2. 开发 H5 时访问 `http://localhost:3000`
|
||||
3. 微信小程序需要配置 AppID
|
||||
4. 记得修改 API 请求的 BASE_URL
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<title>uniapp-vue3-template</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "uniapp-vue3-template",
|
||||
"version": "1.0.0",
|
||||
"description": "基于Vue3 + Vite + Pinia + uview-plus的UniApp项目模板",
|
||||
"main": "main.ts",
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"preview:h5": "uni preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-components": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
|
||||
"dayjs": "^1.11.10",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"uview-plus": "^3.2.7",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4010520240507001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4010520240507001",
|
||||
"sass": "1.63.2",
|
||||
"sass-loader": "10.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<view style="padding: 50px; text-align: center;">
|
||||
<text style="font-size: 24px; color: blue;">UniApp 加载中...</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "uview-plus/index.scss";
|
||||
</style>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './auth'
|
||||
export * from './user'
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { get, post } from '../request'
|
||||
|
||||
export function login(data) {
|
||||
return post('/auth/login', data)
|
||||
}
|
||||
|
||||
export function register(data) {
|
||||
return post('/auth/register', data)
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return post('/auth/logout')
|
||||
}
|
||||
|
||||
export function getSmsCode(phone) {
|
||||
return get('/auth/smscode', { phone })
|
||||
}
|
||||
|
||||
export function resetPassword(data) {
|
||||
return post('/auth/resetPassword', data)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { get, post } from '../request'
|
||||
|
||||
export function getUserProfile() {
|
||||
return get('/user/profile')
|
||||
}
|
||||
|
||||
export function updateUserProfile(data) {
|
||||
return post('/user/profile', data)
|
||||
}
|
||||
|
||||
export function updateAvatar(avatar) {
|
||||
return post('/user/avatar', { avatar })
|
||||
}
|
||||
|
||||
export function bindPhone(phone, smsCode) {
|
||||
return post('/user/bindPhone', { phone, smsCode })
|
||||
}
|
||||
|
||||
export function getUserList(params) {
|
||||
return get('/user/list', params)
|
||||
}
|
||||
|
||||
export function getUserDetail(id) {
|
||||
return get(`/user/detail/${id}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const BASE_URL = 'https://api.example.com'
|
||||
|
||||
const requestInterceptor = (config) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (config.header) {
|
||||
config.header = {
|
||||
...config.header,
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userStore.token ? `Bearer ${userStore.token}` : ''
|
||||
}
|
||||
} else {
|
||||
config.header = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userStore.token ? `Bearer ${userStore.token}` : ''
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
const responseInterceptor = (response) => {
|
||||
const { statusCode, data } = response
|
||||
|
||||
if (statusCode === 200) {
|
||||
if (data.code === 0 || data.success) {
|
||||
return data.data || data.result
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: data.message || '请求失败',
|
||||
icon: 'none'
|
||||
})
|
||||
return Promise.reject(data)
|
||||
}
|
||||
} else if (statusCode === 401) {
|
||||
uni.showToast({
|
||||
title: '登录已过期,请重新登录',
|
||||
icon: 'none'
|
||||
})
|
||||
const userStore = useUserStore()
|
||||
userStore.clearUser()
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/index'
|
||||
})
|
||||
}, 1500)
|
||||
return Promise.reject(response)
|
||||
} else if (statusCode === 403) {
|
||||
uni.showToast({
|
||||
title: '没有权限访问',
|
||||
icon: 'none'
|
||||
})
|
||||
return Promise.reject(response)
|
||||
} else if (statusCode === 404) {
|
||||
uni.showToast({
|
||||
title: '请求的资源不存在',
|
||||
icon: 'none'
|
||||
})
|
||||
return Promise.reject(response)
|
||||
} else if (statusCode >= 500) {
|
||||
uni.showToast({
|
||||
title: '服务器错误,请稍后重试',
|
||||
icon: 'none'
|
||||
})
|
||||
return Promise.reject(response)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '网络请求失败',
|
||||
icon: 'none'
|
||||
})
|
||||
return Promise.reject(response)
|
||||
}
|
||||
}
|
||||
|
||||
function request(options) {
|
||||
const { url, method = 'GET', data, header, loading = true, loadingText = '加载中...' } = options
|
||||
|
||||
const config = {
|
||||
url: url.startsWith('http') ? url : `${BASE_URL}${url}`,
|
||||
method,
|
||||
data,
|
||||
header: requestInterceptor({ header }),
|
||||
timeout: 15000
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (loading) {
|
||||
uni.showLoading({
|
||||
title: loadingText,
|
||||
mask: true
|
||||
})
|
||||
}
|
||||
|
||||
uni.request({
|
||||
...config,
|
||||
success: (res) => {
|
||||
const result = responseInterceptor(res)
|
||||
resolve(result)
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({
|
||||
title: '网络请求失败',
|
||||
icon: 'none'
|
||||
})
|
||||
reject(err)
|
||||
},
|
||||
complete: () => {
|
||||
if (loading) {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function get(url, params, options) {
|
||||
return request({
|
||||
url,
|
||||
method: 'GET',
|
||||
data: params,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function post(url, data, options) {
|
||||
return request({
|
||||
url,
|
||||
method: 'POST',
|
||||
data,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function put(url, data, options) {
|
||||
return request({
|
||||
url,
|
||||
method: 'PUT',
|
||||
data,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function del(url, data, options) {
|
||||
return request({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
data,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function patch(url, data, options) {
|
||||
return request({
|
||||
url,
|
||||
method: 'PATCH',
|
||||
data,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export default request
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<up-button
|
||||
:type="type"
|
||||
:text="text"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:shape="shape"
|
||||
:size="size"
|
||||
:block="block"
|
||||
:hairline="hairline"
|
||||
:loadingMode="loadingMode"
|
||||
:loadingText="loadingText"
|
||||
:color="color"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
shape: {
|
||||
type: String,
|
||||
default: 'square'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal'
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hairline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingMode: {
|
||||
type: String,
|
||||
default: 'spinner'
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick(e) {
|
||||
emit('click', e)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<view class="custom-card" :class="{ 'card-shadow': shadow }" @click="handleClick">
|
||||
<view v-if="$slots.header || title" class="card-header">
|
||||
<slot name="header">
|
||||
<view class="default-header">
|
||||
<text class="card-title">{{ title }}</text>
|
||||
<text v-if="subTitle" class="card-subtitle">{{ subTitle }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
<view v-if="$slots.extra" class="card-extra">
|
||||
<slot name="extra"></slot>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<slot></slot>
|
||||
</view>
|
||||
<view v-if="$slots.footer" class="card-footer">
|
||||
<slot name="footer"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 30rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.default-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24rpx 30rpx;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20rpx 30rpx;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<view class="empty-state" :style="{ paddingTop: top + 'px' }">
|
||||
<image v-if="image" :src="image" class="empty-image" :style="{ width: imageSize + 'px', height: imageSize + 'px' }" />
|
||||
<view v-else class="empty-icon">
|
||||
<up-icon :name="icon" :size="imageSize" color="#ccc"></up-icon>
|
||||
</view>
|
||||
<text class="empty-text" :style="{ fontSize: fontSize + 'px', color: textColor }">{{ description }}</text>
|
||||
<view v-if="$slots.default" class="empty-action">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
image: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'order'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '暂无数据'
|
||||
},
|
||||
imageSize: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
fontSize: {
|
||||
type: Number,
|
||||
default: 14
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: '#999'
|
||||
},
|
||||
top: {
|
||||
type: Number,
|
||||
default: 100
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.empty-image,
|
||||
.empty-icon {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
text-align: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { default as LazyImage } from './lazy-image.vue'
|
||||
export { default as EmptyState } from './empty-state.vue'
|
||||
export { default as PageContainer } from './page-container.vue'
|
||||
export { default as BaseButton } from './base-button.vue'
|
||||
export { default as CustomCard } from './custom-card.vue'
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<image
|
||||
class="lazy-image"
|
||||
:class="{ loaded: isLoaded }"
|
||||
:src="src"
|
||||
:mode="mode"
|
||||
:lazy-load="lazyLoad"
|
||||
@load="handleLoad"
|
||||
@error="handleError"
|
||||
>
|
||||
<view v-if="!isLoaded && placeholder" class="lazy-placeholder">
|
||||
<image :src="placeholder" mode="aspectFit" class="placeholder-img" />
|
||||
</view>
|
||||
<view v-if="isLoading" class="lazy-loading">
|
||||
<up-loading-icon size="24" mode="circle" color="#ccc"></up-loading-icon>
|
||||
</view>
|
||||
</image>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'aspectFill'
|
||||
},
|
||||
lazyLoad: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['load', 'error', 'click'])
|
||||
|
||||
const isLoaded = ref(false)
|
||||
const isLoading = ref(true)
|
||||
|
||||
watch(() => props.src, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
isLoaded.value = false
|
||||
isLoading.value = true
|
||||
}
|
||||
})
|
||||
|
||||
function handleLoad(e) {
|
||||
isLoaded.value = true
|
||||
isLoading.value = false
|
||||
emit('load', e)
|
||||
}
|
||||
|
||||
function handleError(e) {
|
||||
isLoading.value = false
|
||||
emit('error', e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lazy-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.lazy-image.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lazy-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.placeholder-img {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.lazy-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<view class="page-container" :style="containerStyle">
|
||||
<slot></slot>
|
||||
<view v-if="loading" class="page-loading">
|
||||
<up-loading-icon size="32" mode="circle" color="#2979ff"></up-loading-icon>
|
||||
<text class="loading-text">{{ loadingText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: '加载中...'
|
||||
},
|
||||
pullToRefresh: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: '#f8f8f8'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh', 'scroll'])
|
||||
|
||||
const scrollTop = ref(0)
|
||||
const refreshing = ref(false)
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
minHeight: '100%',
|
||||
backgroundColor: props.bgColor
|
||||
}))
|
||||
|
||||
function handleScroll(e) {
|
||||
emit('scroll', e)
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
if (props.pullToRefresh && !refreshing.value) {
|
||||
refreshing.value = true
|
||||
emit('refresh', () => {
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pullToRefresh) {
|
||||
uni.startPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-loading {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 30rpx 50rpx;
|
||||
border-radius: 12rpx;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as LayoutHeader } from './layout-header.vue'
|
||||
export { default as LayoutFooter } from './layout-footer.vue'
|
||||
export { default as LayoutSidebar } from './layout-sidebar.vue'
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<view class="layout-footer">
|
||||
<view class="tab-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
|
||||
<view
|
||||
v-for="(item, index) in tabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ active: current === index }"
|
||||
@click="handleTabClick(index, item)"
|
||||
>
|
||||
<image v-if="item.iconPath" :src="current === index ? item.selectedIconPath : item.iconPath" class="tab-icon" />
|
||||
<up-icon v-else-if="item.icon" :name="item.icon" size="24" :color="current === index ? activeColor : inactiveColor"></up-icon>
|
||||
<text class="tab-text" :style="{ color: current === index ? activeColor : inactiveColor }">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeColor: {
|
||||
type: String,
|
||||
default: '#2979ff'
|
||||
},
|
||||
inactiveColor: {
|
||||
type: String,
|
||||
default: '#999999'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:current', 'change'])
|
||||
|
||||
const safeAreaBottom = ref(0)
|
||||
|
||||
watch(() => props.current, (val) => {
|
||||
emit('update:current', val)
|
||||
})
|
||||
|
||||
function handleTabClick(index, item) {
|
||||
if (props.current !== index) {
|
||||
emit('update:current', index)
|
||||
emit('change', index, item)
|
||||
if (item.pagePath) {
|
||||
uni.switchTab({ url: item.pagePath })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSafeAreaBottom() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync()
|
||||
safeAreaBottom.value = info.safeArea?.bottom - info.screenHeight || 0
|
||||
} catch (e) {
|
||||
safeAreaBottom.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
getSafeAreaBottom()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<view class="layout-header" :style="headerStyle">
|
||||
<view class="header-content" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="header-left" @click="handleLeftClick">
|
||||
<slot name="left">
|
||||
<view v-if="showBack" class="back-btn">
|
||||
<up-icon name="arrow-left" size="20" color="#333"></up-icon>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
<view class="header-title">
|
||||
<slot name="title">
|
||||
<text class="title-text">{{ title }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/store/app'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showBack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: '#ffffff'
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: '#333333'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'leftClick'])
|
||||
|
||||
const appStore = useAppStore()
|
||||
const statusBarHeight = computed(() => appStore.systemInfo?.statusBarHeight || 20)
|
||||
|
||||
const headerStyle = computed(() => ({
|
||||
backgroundColor: props.bgColor,
|
||||
color: props.textColor,
|
||||
position: props.fixed ? 'fixed' : 'relative',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 999
|
||||
}))
|
||||
|
||||
function handleLeftClick() {
|
||||
if (props.showBack) {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
} else {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
emit('back')
|
||||
} else {
|
||||
emit('leftClick')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<view class="layout-sidebar" :class="{ show: visible }" @click.self="handleOverlayClick">
|
||||
<view class="sidebar-mask" :class="{ show: visible }" @click="handleOverlayClick"></view>
|
||||
<view class="sidebar-content" :class="{ show: visible }" :style="{ width: width + 'px' }">
|
||||
<view class="sidebar-header">
|
||||
<slot name="header">
|
||||
<view class="default-header">
|
||||
<text class="sidebar-title">{{ title }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
<scroll-view class="sidebar-body" scroll-y>
|
||||
<slot></slot>
|
||||
</scroll-view>
|
||||
<view class="sidebar-footer">
|
||||
<slot name="footer"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '菜单'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 280
|
||||
},
|
||||
closeOnOverlay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'close'])
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleOverlayClick() {
|
||||
if (props.closeOnOverlay) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({ close })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-mask.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: #ffffff;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-content.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.default-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20rpx 30rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import uviewPlus from 'uview-plus'
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/styles/uni.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(uviewPlus)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"name": "uniapp-vue3-template",
|
||||
"appid": "",
|
||||
"description": "基于Vue3 + Vite + Pinia + uview-plus的UniApp项目模板",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"networkTimeout": {
|
||||
"request": 10000,
|
||||
"connectSocket": 10000,
|
||||
"uploadFile": 10000,
|
||||
"downloadFile": 10000
|
||||
},
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": ["<uses-permission android:name=\"android.permission.INTERNET\"/>"],
|
||||
"abiFilters": ["armeabi-v7a", "arm64-v8a"]
|
||||
},
|
||||
"ios": {
|
||||
"dSYMs": false
|
||||
},
|
||||
"sdkConfigs": {}
|
||||
},
|
||||
"screenOrientation": ["portrait-primary"]
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"h5": {
|
||||
"title": "uniapp-vue3-template",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 5173,
|
||||
"disableHostCheck": true,
|
||||
"https": false
|
||||
},
|
||||
"optimization": {
|
||||
"treeShaking": {
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/register/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "注册",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/demo/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "组件演示",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "uniapp-vue3-template",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f8f8f8",
|
||||
"backgroundTextStyle": "dark",
|
||||
"app-plus": {
|
||||
"background": "#efeff4"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#2979ff",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"height": "50px",
|
||||
"fontSize": "12px",
|
||||
"iconWidth": "24px",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页",
|
||||
"iconPath": "static/tabbar/home.png",
|
||||
"selectedIconPath": "static/tabbar/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/demo/index",
|
||||
"text": "演示",
|
||||
"iconPath": "static/tabbar/demo.png",
|
||||
"selectedIconPath": "static/tabbar/demo-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/user/index",
|
||||
"text": "我的",
|
||||
"iconPath": "static/tabbar/user.png",
|
||||
"selectedIconPath": "static/tabbar/user-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^u--(.*)": "uview-plus/components/u--$1/u--$1.vue",
|
||||
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
|
||||
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div>这是一个演示页面</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div class="container">首页</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
font-size: 80rpx;
|
||||
font-weight: bold;
|
||||
color: #2979ff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<view class="login-page">
|
||||
<view class="login-container">
|
||||
<view class="logo-section">
|
||||
<text class="logo-text">logo</text>
|
||||
</view>
|
||||
<view class="login-title">登录</view>
|
||||
<view class="login-form">
|
||||
<u-form>
|
||||
<u-form-item label="账号" prop="username">
|
||||
<u-input v-model="formData.username" placeholder="请输入账号" prefixIcon="account" size="large"></u-input>
|
||||
</u-form-item>
|
||||
<u-form-item label="密码" prop="password">
|
||||
<u-input v-model="formData.password" type="password" placeholder="请输入密码" prefixIcon="lock" size="large"
|
||||
password-icon></u-input>
|
||||
</u-form-item>
|
||||
|
||||
<u-form-item label="角色" prop="role">
|
||||
<view class="role-selector">
|
||||
<u-select :current="formData.role" :options="roleList" placeholder="请选择角色" size="large" showOptionsLabel
|
||||
@update:current="formData.role = $event"></u-select>
|
||||
</view>
|
||||
</u-form-item>
|
||||
|
||||
</u-form>
|
||||
</view>
|
||||
<view class="login-actions">
|
||||
<u-button type="primary" text="登录" :loading="loading" @click="handleLogin"></u-button>
|
||||
<text class="forget-password" @click="handleForgetPassword">忘记密码?</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'role1'
|
||||
})
|
||||
|
||||
const roleList = ref([
|
||||
{ name: '工地负责人', id: 'role1' },
|
||||
{ name: '监督负责人', id: 'role2' },
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 登录逻辑
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleForgetPassword() {
|
||||
uni.showToast({ title: '忘记密码功能', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(to bottom, #2979ff, #07c160);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 60rpx 40rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #2979ff;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.forget-password {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
color: #2979ff;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
padding: 6px 9px;
|
||||
border: 1rpx solid #e4e7ed;
|
||||
color: #333;
|
||||
|
||||
.u-select {
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div>注册</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div>用户中心</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const theme = ref('light')
|
||||
const pageCache = ref([])
|
||||
const systemInfo = ref(null)
|
||||
|
||||
function setTheme(value) {
|
||||
theme.value = value
|
||||
}
|
||||
|
||||
function addCachePage(pageName) {
|
||||
if (!pageCache.value.includes(pageName)) {
|
||||
pageCache.value.push(pageName)
|
||||
}
|
||||
}
|
||||
|
||||
function removeCachePage(pageName) {
|
||||
const index = pageCache.value.indexOf(pageName)
|
||||
if (index > -1) {
|
||||
pageCache.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
pageCache.value = []
|
||||
}
|
||||
|
||||
function setSystemInfo(info) {
|
||||
systemInfo.value = info
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
pageCache,
|
||||
systemInfo,
|
||||
setTheme,
|
||||
addCachePage,
|
||||
removeCachePage,
|
||||
clearCache,
|
||||
setSystemInfo
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
key: 'app-store',
|
||||
paths: ['theme', 'pageCache']
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { useUserStore } from './user'
|
||||
export { useAppStore } from './app'
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('')
|
||||
const refreshToken = ref('')
|
||||
const userInfo = ref(null)
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
function setToken(value) {
|
||||
token.value = value
|
||||
isLoggedIn.value = !!value
|
||||
}
|
||||
|
||||
function setRefreshToken(value) {
|
||||
refreshToken.value = value
|
||||
}
|
||||
|
||||
function setUserInfo(info) {
|
||||
userInfo.value = info
|
||||
if (info.token) {
|
||||
token.value = info.token
|
||||
isLoggedIn.value = true
|
||||
}
|
||||
if (info.refreshToken) {
|
||||
refreshToken.value = info.refreshToken
|
||||
}
|
||||
}
|
||||
|
||||
function updateUserInfo(info) {
|
||||
if (userInfo.value) {
|
||||
userInfo.value = { ...userInfo.value, ...info }
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
isLoggedIn.value = false
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
isLoggedIn.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
setToken,
|
||||
setRefreshToken,
|
||||
setUserInfo,
|
||||
updateUserInfo,
|
||||
logout,
|
||||
clearUser
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
key: 'user-store',
|
||||
paths: ['token', 'refreshToken', 'userInfo', 'isLoggedIn']
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
@charset "utf-8";
|
||||
|
||||
@import '@/styles/variable.scss';
|
||||
|
||||
*,
|
||||
:before,
|
||||
:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
color: $u-text-color;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
|
||||
body,
|
||||
page {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
image {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
$u-border-color: #e4e7ed;
|
||||
$u-border-color-light: #ebeef5;
|
||||
$u-primary: #2979ff;
|
||||
$u-warning: #ffb21a;
|
||||
$u-success: #19c160;
|
||||
$u-error: #fa3534;
|
||||
$u-info: #909399;
|
||||
$u-text-color: #333333;
|
||||
$u-text-color-grey: #999999;
|
||||
$u-bg-color: #f8f8f8;
|
||||
$u-content-color: #606266;
|
||||
$u-primary-light: #e6f2ff;
|
||||
$u-warning-light: #fff8e6;
|
||||
$u-success-light: #e6f9e6;
|
||||
$u-error-light: #fff2f0;
|
||||
$u-info-light: #f4f7fc;
|
||||
$u-primary-dark: #1976d2;
|
||||
$u-warning-dark: #ff9800;
|
||||
$u-success-dark: #159854;
|
||||
$u-error-dark: #d32f2f;
|
||||
$u-info-dark: #606266;
|
||||
$u-primary-disabled: #c5d8f6;
|
||||
$u-warning-disabled: #fff8e6;
|
||||
$u-success-disabled: #e6f9e6;
|
||||
$u-error-disabled: #fff2f0;
|
||||
$u-info-disabled: #f4f7fc;
|
||||
$u-main-color: #2979ff;
|
||||
$u-tips-color: #909399;
|
||||
$u-light-color: #f8f8f8;
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
export function formatDateTime(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
export function formatTime(date, format = 'HH:mm') {
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date) {
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return secs > 0 ? `${minutes}分${secs}秒` : `${minutes}分钟`
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return minutes > 0 ? `${hours}小时${minutes}分` : `${hours}小时`
|
||||
} else {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(price, options) {
|
||||
const { prefix = '¥', decimals = 2 } = options || {}
|
||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||
if (isNaN(num)) return `${prefix}0.00`
|
||||
return `${prefix}${num.toFixed(decimals)}`
|
||||
}
|
||||
|
||||
export function formatNumber(num, decimals = 0) {
|
||||
const value = typeof num === 'string' ? parseFloat(num) : num
|
||||
if (isNaN(value)) return '0'
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))}${sizes[i]}`
|
||||
}
|
||||
|
||||
export function formatPhone(phone) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
export function formatIdCard(idCard) {
|
||||
return idCard.replace(/(\d{3})\d{10}(\d{3}[xX]?)/, '$1**********$2')
|
||||
}
|
||||
|
||||
export function formatThousands(num, separator = ',') {
|
||||
const value = typeof num === 'string' ? parseFloat(num) : num
|
||||
if (isNaN(value)) return '0'
|
||||
const [integer, decimal] = String(value).split('.')
|
||||
const reg = new RegExp(`\\B(?=(\\d{3})+(?!\\d))`, 'g')
|
||||
return `${integer.replace(reg, separator)}${decimal ? '.' + decimal : ''}`
|
||||
}
|
||||
|
||||
export function getCurrentDate() {
|
||||
const date = new Date()
|
||||
const weekMap = [0, 1, 2, 3, 4, 5, 6]
|
||||
const weekDay = weekMap[date.getDay()]
|
||||
return {
|
||||
year: date.getFullYear(),
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
week: weekDay
|
||||
}
|
||||
}
|
||||
|
||||
export function isToday(date) {
|
||||
return dayjs(date).isSame(dayjs(), 'day')
|
||||
}
|
||||
|
||||
export function isYesterday(date) {
|
||||
return dayjs(date).isSame(dayjs().subtract(1, 'day'), 'day')
|
||||
}
|
||||
|
||||
export function getWeekStartAndEnd(date = Date.now()) {
|
||||
const d = dayjs(date)
|
||||
return {
|
||||
start: d.startOf('week'),
|
||||
end: d.endOf('week')
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthStartAndEnd(date = Date.now()) {
|
||||
const d = dayjs(date)
|
||||
return {
|
||||
start: d.startOf('month'),
|
||||
end: d.endOf('month')
|
||||
}
|
||||
}
|
||||
|
||||
export function getYearStartAndEnd(date = Date.now()) {
|
||||
const d = dayjs(date)
|
||||
return {
|
||||
start: d.startOf('year'),
|
||||
end: d.endOf('year')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './storage'
|
||||
export * from './validate'
|
||||
export * from './format'
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
const STORAGE_PREFIX = 'UNIAPP_'
|
||||
|
||||
function getKey(key, usePrefix = true) {
|
||||
return usePrefix ? `${STORAGE_PREFIX}${key}` : key
|
||||
}
|
||||
|
||||
export function setStorage(key, value, options = {}) {
|
||||
const { prefix = true, expires } = options
|
||||
const storageData = {
|
||||
value,
|
||||
expires: expires ? Date.now() + expires : null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
try {
|
||||
uni.setStorageSync(getKey(key, prefix), storageData)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('setStorage error:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getStorage(key, options = {}) {
|
||||
const { prefix = true } = options
|
||||
try {
|
||||
const data = uni.getStorageSync(getKey(key, prefix))
|
||||
if (!data) return null
|
||||
|
||||
if (data.expires && Date.now() > data.expires) {
|
||||
removeStorage(key, { prefix })
|
||||
return null
|
||||
}
|
||||
|
||||
return data.value
|
||||
} catch (e) {
|
||||
console.error('getStorage error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStorage(key, options = {}) {
|
||||
const { prefix = true } = options
|
||||
try {
|
||||
uni.removeStorageSync(getKey(key, prefix))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('removeStorage error:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStorage(prefixOnly = false) {
|
||||
try {
|
||||
if (prefixOnly) {
|
||||
const keys = uni.getStorageInfoSync().keys
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(STORAGE_PREFIX)) {
|
||||
uni.removeStorageSync(key)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.clearStorageSync()
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('clearStorage error:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getStorageInfo() {
|
||||
try {
|
||||
const info = uni.getStorageInfoSync()
|
||||
return {
|
||||
currentSize: info.currentSize,
|
||||
limitSize: info.limitSize,
|
||||
keys: info.keys
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('getStorageInfo error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
set: setStorage,
|
||||
get: getStorage,
|
||||
remove: removeStorage,
|
||||
clear: clearStorage,
|
||||
info: getStorageInfo
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
export function isEmpty(value) {
|
||||
if (value === null || value === undefined) return true
|
||||
if (typeof value === 'string') return value.trim() === ''
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (typeof value === 'object') return Object.keys(value).length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
export function validate(value, rules) {
|
||||
for (const rule of rules) {
|
||||
if (rule.required && isEmpty(value)) {
|
||||
return { isValid: false, message: rule.message || '该项为必填项' }
|
||||
}
|
||||
|
||||
if (!isEmpty(value)) {
|
||||
if (rule.pattern && !rule.pattern.test(String(value))) {
|
||||
return { isValid: false, message: rule.message || '格式不正确' }
|
||||
}
|
||||
|
||||
if (rule.min !== undefined && Number(value) < rule.min) {
|
||||
return { isValid: false, message: rule.message || `不能小于${rule.min}` }
|
||||
}
|
||||
|
||||
if (rule.max !== undefined && Number(value) > rule.max) {
|
||||
return { isValid: false, message: rule.message || `不能大于${rule.max}` }
|
||||
}
|
||||
|
||||
if (rule.minLength !== undefined && String(value).length < rule.minLength) {
|
||||
return { isValid: false, message: rule.message || `长度不能小于${rule.minLength}` }
|
||||
}
|
||||
|
||||
if (rule.maxLength !== undefined && String(value).length > rule.maxLength) {
|
||||
return { isValid: false, message: rule.message || `长度不能大于${rule.maxLength}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, message: '' }
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
required: (message = '该项为必填项') => ({
|
||||
required: true,
|
||||
message
|
||||
}),
|
||||
|
||||
mobile: (message = '请输入正确的手机号') => ({
|
||||
required: true,
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message
|
||||
}),
|
||||
|
||||
email: (message = '请输入正确的邮箱地址') => ({
|
||||
required: true,
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message
|
||||
}),
|
||||
|
||||
idCard: (message = '请输入正确的身份证号') => ({
|
||||
required: true,
|
||||
pattern: /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/,
|
||||
message
|
||||
}),
|
||||
|
||||
url: (message = '请输入正确的网址') => ({
|
||||
required: true,
|
||||
pattern: /^https?:\/\/[^\s]+$/,
|
||||
message
|
||||
}),
|
||||
|
||||
number: (message = '请输入数字') => ({
|
||||
required: true,
|
||||
pattern: /^\d+$/,
|
||||
message
|
||||
}),
|
||||
|
||||
minLength: (length, message) => ({
|
||||
minLength: length,
|
||||
message: message || `长度不能小于${length}`
|
||||
}),
|
||||
|
||||
maxLength: (length, message) => ({
|
||||
maxLength: length,
|
||||
message: message || `长度不能大于${length}`
|
||||
}),
|
||||
|
||||
min: (value, message) => ({
|
||||
min: value,
|
||||
message: message || `不能小于${value}`
|
||||
}),
|
||||
|
||||
max: (value, message) => ({
|
||||
max: value,
|
||||
message: message || `不能大于${value}`
|
||||
})
|
||||
}
|
||||
|
||||
export function validateForm(formData, formRules) {
|
||||
const errors = {}
|
||||
let isValid = true
|
||||
|
||||
for (const key in formRules) {
|
||||
const value = formData[key]
|
||||
const rules = formRules[key]
|
||||
const result = validate(value, rules)
|
||||
|
||||
if (!result.isValid) {
|
||||
errors[key] = result.message
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid, errors }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@import "uview-plus/theme.scss";
|
||||
@import "@/styles/variable.scss";
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['uview-plus']
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue