初始化项目创建Uniapp模版

vue3与uview-plus框架搭建
This commit is contained in:
liangbin 2026-01-15 16:50:32 +08:00
commit c6b3791076
37 changed files with 2167 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@ -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/

158
README.md Normal file
View File

@ -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

13
index.html Normal file
View File

@ -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>

35
package.json Normal file
View File

@ -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"
}
}

12
src/App.vue Normal file
View File

@ -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>

2
src/api/index.js Normal file
View File

@ -0,0 +1,2 @@
export * from './auth'
export * from './user'

21
src/api/modules/auth.js Normal file
View File

@ -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)
}

25
src/api/modules/user.js Normal file
View File

@ -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}`)
}

163
src/api/request.js Normal file
View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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>

17
src/main.js Normal file
View File

@ -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')

75
src/manifest.json Normal file
View File

@ -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"
}

86
src/pages.json Normal file
View File

@ -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"
}
}
}

3
src/pages/demo/index.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div>这是一个演示页面</div>
</template>

12
src/pages/index/index.vue Normal file
View File

@ -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>

138
src/pages/login/index.vue Normal file
View File

@ -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>

View File

@ -0,0 +1,3 @@
<template>
<div>注册</div>
</template>

3
src/pages/user/index.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div>用户中心</div>
</template>

49
src/store/app.js Normal file
View File

@ -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']
}
})

2
src/store/index.js Normal file
View File

@ -0,0 +1,2 @@
export { useUserStore } from './user'
export { useAppStore } from './app'

67
src/store/user.js Normal file
View File

@ -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']
}
})

42
src/styles/uni.scss Normal file
View File

@ -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%;
}

29
src/styles/variable.scss Normal file
View File

@ -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;

121
src/utils/format.js Normal file
View File

@ -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')
}
}

3
src/utils/index.js Normal file
View File

@ -0,0 +1,3 @@
export * from './storage'
export * from './validate'
export * from './format'

91
src/utils/storage.js Normal file
View File

@ -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
}

114
src/utils/validate.js Normal file
View File

@ -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 }
}

26
vite.config.js Normal file
View File

@ -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']
}
})