hookehuyr

feat: 初始化三坛大戒H5应用项目

- 完成启动页面设计和动画效果
- 实现首页布局和功能模块
- 添加三师七证、义工、戒子等核心页面
- 集成Vue3 + Vite + Vant UI框架
- 实现页面路由和导航功能
- 添加响应式设计和交互动画
# 开发环境配置
# API 基础地址
VITE_API_BASE_URL=http://localhost:3000/api
# 是否开启 Mock
VITE_USE_MOCK=true
# 开发服务器端口
VITE_PORT=5173
# 是否自动打开浏览器
VITE_OPEN=true
\ No newline at end of file
# 生产环境配置
# API 基础地址
VITE_API_BASE_URL=https://api.yourdomain.com
# 是否开启 Mock
VITE_USE_MOCK=false
# 构建输出目录
VITE_OUTPUT_DIR=dist
# 静态资源目录
VITE_ASSETS_DIR=assets
\ No newline at end of file
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useId": true,
"useLink": true,
"useModel": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"useTemplateRef": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build outputs
build/
coverage/
# Cache
.cache/
.parcel-cache/
.eslintcache
# History
.history/
\ No newline at end of file
# H5 Vite Template
基于 Vue 3 + Vite + Vant 4 的移动端 H5 项目模板
## 特性
- ⚡️ **Vite** - 极速的开发体验
- 🖖 **Vue 3** - 渐进式 JavaScript 框架
- 📱 **Vant 4** - 轻量、可靠的移动端组件库
- 🎨 **Tailwind CSS** - 原子化 CSS 框架
- 📦 **Pinia** - 符合直觉的 Vue.js 状态管理库
- 🛣️ **Vue Router** - Vue.js 官方路由
- 📡 **Axios** - 基于 Promise 的 HTTP 客户端
- 🔧 **ESLint** - 代码质量检查
- 📐 **PostCSS** - CSS 后处理器
- 📱 **移动端适配** - 基于 postcss-px-to-viewport 的移动端适配方案
## 目录结构
```
h5_vite_template/
├── public/ # 静态资源
├── src/
│ ├── api/ # API 接口
│ ├── assets/ # 资源文件
│ ├── components/ # 通用组件
│ ├── router/ # 路由配置
│ ├── stores/ # 状态管理
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ └── style.css # 全局样式
├── .env # 环境变量
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .gitignore # Git 忽略文件
├── index.html # HTML 模板
├── package.json # 项目配置
├── postcss.config.js # PostCSS 配置
├── tailwind.config.js # Tailwind CSS 配置
└── vite.config.js # Vite 配置
```
## 快速开始
### 安装依赖
```bash
npm install
# 或
yarn install
# 或
pnpm install
```
### 开发
```bash
npm run dev
# 或
yarn dev
# 或
pnpm dev
```
### 构建
```bash
npm run build
# 或
yarn build
# 或
pnpm build
```
### 预览
```bash
npm run preview
# 或
yarn preview
# 或
pnpm preview
```
## 配置说明
### 环境变量
项目支持多环境配置,通过 `.env` 文件进行管理:
- `.env` - 所有环境的默认配置
- `.env.development` - 开发环境配置
- `.env.production` - 生产环境配置
### 移动端适配
项目使用 `postcss-px-to-viewport` 进行移动端适配,设计稿基准为 375px。
### 路由配置
路由配置位于 `src/router/index.js`,支持:
- 路由懒加载
- 路由守卫
- 页面标题设置
- 滚动行为控制
### 状态管理
使用 Pinia 进行状态管理,store 文件位于 `src/stores/` 目录。
### API 请求
API 请求基于 Axios 封装,配置文件位于 `src/utils/request.js`,支持:
- 请求/响应拦截器
- 自动 Loading
- 错误处理
- Token 自动携带
## 组件说明
### 页面组件
- **Home** - 首页,展示轮播图、菜单网格、通知栏等
- **About** - 关于页面,展示项目信息和功能特性
- **Profile** - 个人中心,展示用户信息和功能菜单
- **Demo** - 组件演示页面,展示 Vant 组件使用示例
- **NotFound** - 404 页面
### 通用组件
- **LoadingSpinner** - 加载动画组件
- **EmptyState** - 空状态组件
## 开发规范
### 代码风格
项目使用 ESLint 进行代码质量检查,请遵循以下规范:
- 使用 2 空格缩进
- 使用单引号
- 行末不加分号
- 组件名使用 PascalCase
- 文件名使用 kebab-case
### Git 提交规范
建议使用以下格式进行 Git 提交:
```
<type>(<scope>): <subject>
<body>
<footer>
```
类型说明:
- `feat`: 新功能
- `fix`: 修复 bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 代码重构
- `test`: 测试相关
- `chore`: 构建过程或辅助工具的变动
## 部署
### 构建产物
执行 `npm run build` 后,构建产物将输出到 `dist` 目录。
### 静态部署
构建产物可以部署到任何静态文件服务器,如:
- Nginx
- Apache
- Vercel
- Netlify
- GitHub Pages
### 注意事项
1. 如果部署到子路径,需要在 `vite.config.js` 中配置 `base` 选项
2. 确保服务器支持 History 模式的路由
3. 生产环境需要配置正确的 API 地址
## 浏览器支持
- Chrome >= 87
- Firefox >= 78
- Safari >= 14
- iOS Safari >= 14.4
- Android Browser >= 87
## 许可证
MIT License
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<title>H5 Vite Template</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
\ No newline at end of file
This diff could not be displayed because it is too large.
{
"name": "stdj-h5",
"description": "三坛大戒",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"start": "vite --host 0.0.0.0",
"build": "vite build",
"build-watch": "vite build --watch",
"serve": "vite preview",
"lint": "eslint . --ext vue,js,jsx,cjs,mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"@vant/area-data": "^1.3.1",
"@vant/touch-emulator": "^1.4.0",
"@vueuse/core": "^10.7.2",
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"vant": "^4.9.1",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"postcss-px-to-viewport": "^1.1.1",
"tailwindcss": "^3.4.1",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.0"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-px-to-viewport': {
unitToConvert: 'px',
viewportWidth: 375,
unitPrecision: 6,
propList: ['*'],
viewportUnit: 'vw',
fontViewportUnit: 'vw',
selectorBlackList: ['ignore-'],
minPixelValue: 1,
mediaQuery: true,
replace: true,
exclude: [],
landscape: false
}
}
}
\ No newline at end of file
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
// 这里可以添加全局逻辑
</script>
<style>
/* 全局样式已在 style.css 中定义 */
</style>
\ No newline at end of file
import request from '@/utils/request'
// 用户相关 API
export const userApi = {
// 获取用户信息
getUserInfo: () => request.get('/user/info'),
// 更新用户信息
updateUserInfo: (data) => request.put('/user/info', data),
// 用户登录
login: (data) => request.post('/user/login', data),
// 用户注册
register: (data) => request.post('/user/register', data),
// 用户登出
logout: () => request.post('/user/logout')
}
// 通用 API
export const commonApi = {
// 上传文件
upload: (file) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 获取配置信息
getConfig: () => request.get('/config'),
// 发送验证码
sendSms: (phone) => request.post('/sms/send', { phone })
}
// 示例 API
export const demoApi = {
// 获取列表数据
getList: (params) => request.get('/demo/list', { params }),
// 获取详情
getDetail: (id) => request.get(`/demo/${id}`),
// 创建数据
create: (data) => request.post('/demo', data),
// 更新数据
update: (id, data) => request.put(`/demo/${id}`, data),
// 删除数据
delete: (id) => request.delete(`/demo/${id}`)
}
// 导出所有 API
export default {
userApi,
commonApi,
demoApi
}
\ No newline at end of file
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
EmptyState: typeof import('./components/EmptyState.vue')['default']
LoadingSpinner: typeof import('./components/LoadingSpinner.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanBadge: typeof import('vant/es')['Badge']
VanButton: typeof import('vant/es')['Button']
VanCard: typeof import('vant/es')['Card']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox']
VanCollapse: typeof import('vant/es')['Collapse']
VanCollapseItem: typeof import('vant/es')['CollapseItem']
VanField: typeof import('vant/es')['Field']
VanForm: typeof import('vant/es')['Form']
VanGrid: typeof import('vant/es')['Grid']
VanGridItem: typeof import('vant/es')['GridItem']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanNavBar: typeof import('vant/es')['NavBar']
VanNoticeBar: typeof import('vant/es')['NoticeBar']
VanProgress: typeof import('vant/es')['Progress']
VanRate: typeof import('vant/es')['Rate']
VanSidebar: typeof import('vant/es')['Sidebar']
VanSidebarItem: typeof import('vant/es')['SidebarItem']
VanStep: typeof import('vant/es')['Step']
VanSteps: typeof import('vant/es')['Steps']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanSwitch: typeof import('vant/es')['Switch']
VanTab: typeof import('vant/es')['Tab']
VanTabbar: typeof import('vant/es')['Tabbar']
VanTabbarItem: typeof import('vant/es')['TabbarItem']
VanTabs: typeof import('vant/es')['Tabs']
VanTag: typeof import('vant/es')['Tag']
}
}
<template>
<div class="empty-state">
<div class="empty-icon">
<van-empty
:image="image"
:image-size="imageSize"
:description="description"
>
<template v-if="$slots.image" #image>
<slot name="image" />
</template>
<template v-if="$slots.description" #description>
<slot name="description" />
</template>
<template v-if="showAction" #default>
<van-button
:type="actionType"
:size="actionSize"
round
@click="handleAction"
>
{{ actionText }}
</van-button>
</template>
</van-empty>
</div>
</div>
</template>
<script setup>
import { defineEmits } from 'vue'
const props = defineProps({
// 图片类型
image: {
type: String,
default: 'default'
},
// 图片大小
imageSize: {
type: [Number, String],
default: 160
},
// 描述文字
description: {
type: String,
default: '暂无数据'
},
// 是否显示操作按钮
showAction: {
type: Boolean,
default: false
},
// 按钮文字
actionText: {
type: String,
default: '重新加载'
},
// 按钮类型
actionType: {
type: String,
default: 'primary'
},
// 按钮大小
actionSize: {
type: String,
default: 'normal'
}
})
const emit = defineEmits(['action'])
const handleAction = () => {
emit('action')
}
</script>
<style scoped>
.empty-state {
padding: 40px 20px;
text-align: center;
}
</style>
\ No newline at end of file
<template>
<div class="loading-spinner" :class="{ 'loading-overlay': overlay }">
<div class="spinner-container">
<div class="spinner" :style="{ width: size + 'px', height: size + 'px' }">
<div class="spinner-inner" :style="{ borderColor: color }"></div>
</div>
<p v-if="text" class="loading-text" :style="{ color: textColor }">{{ text }}</p>
</div>
</div>
</template>
<script setup>
defineProps({
// 是否显示遮罩层
overlay: {
type: Boolean,
default: false
},
// 加载器大小
size: {
type: Number,
default: 40
},
// 加载器颜色
color: {
type: String,
default: '#1989fa'
},
// 加载文本
text: {
type: String,
default: ''
},
// 文本颜色
textColor: {
type: String,
default: '#969799'
}
})
</script>
<style scoped>
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
z-index: 9999;
}
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.spinner {
position: relative;
}
.spinner-inner {
width: 100%;
height: 100%;
border: 3px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
margin: 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
\ No newline at end of file
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
// 引入样式
import './style.css'
import 'vant/lib/index.css'
// 引入 Vant 组件(按需引入会通过 unplugin-vue-components 自动处理)
// 这里只需要引入一些全局配置的组件
import { ConfigProvider, Toast, Dialog, Notify, ImagePreview } from 'vant'
const app = createApp(App)
const pinia = createPinia()
// 全局配置
app.config.globalProperties.$toast = Toast
app.config.globalProperties.$dialog = Dialog
app.config.globalProperties.$notify = Notify
app.config.globalProperties.$imagePreview = ImagePreview
// 使用插件
app.use(pinia)
app.use(router)
app.use(ConfigProvider)
// 挂载应用
app.mount('#app')
\ No newline at end of file
/*
* @Date: 2025-10-30 10:29:15
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-30 10:29:24
* @FilePath: /itomix/h5_vite_template/src/router/index.js
* @Description: 文件描述
*/
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{
path: '/',
name: 'Splash',
component: () => import('../views/Splash.vue')
},
{
path: '/home',
name: 'Home',
component: Home
},
{
path: '/teachers',
name: 'Teachers',
component: () => import('../views/Teachers.vue')
},
{
path: '/teachers/:id',
name: 'TeacherDetail',
component: () => import('../views/TeacherDetail.vue')
},
{
path: '/volunteers',
name: 'Volunteers',
component: () => import('../views/Volunteers.vue')
},
{
path: '/students',
name: 'Students',
component: () => import('../views/Students.vue')
},
{
path: '/students/:id',
name: 'StudentDetail',
component: () => import('../views/StudentDetail.vue')
},
{
path: '/news/:id',
name: 'NewsDetail',
component: () => import('../views/NewsDetail.vue')
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title
}
// 这里可以添加权限验证逻辑
next()
})
router.afterEach(() => {
// 路由跳转后的逻辑
})
export default router
\ No newline at end of file
/*
* @Date: 2025-10-30 10:30:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-30 10:30:26
* @FilePath: /itomix/h5_vite_template/src/stores/user.js
* @Description: 文件描述
*/
import { defineStore } from 'pinia'
import { userApi } from '@/api'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
token: localStorage.getItem('token') || '',
isLogin: false
}),
getters: {
// 获取用户名
username: (state) => state.userInfo?.username || '',
// 获取用户头像
avatar: (state) => state.userInfo?.avatar || '',
// 是否已登录
hasLogin: (state) => !!state.token && !!state.userInfo
},
actions: {
// 设置 token
setToken(token) {
this.token = token
localStorage.setItem('token', token)
},
// 清除 token
clearToken() {
this.token = ''
localStorage.removeItem('token')
},
// 设置用户信息
setUserInfo(userInfo) {
this.userInfo = userInfo
this.isLogin = true
},
// 清除用户信息
clearUserInfo() {
this.userInfo = null
this.isLogin = false
},
// 登录
async login(loginData) {
const response = await userApi.login(loginData)
const { token, userInfo } = response.data
this.setToken(token)
this.setUserInfo(userInfo)
return response
},
// 获取用户信息
async getUserInfo() {
try {
const response = await userApi.getUserInfo()
this.setUserInfo(response.data)
return response
} catch (error) {
// 如果获取用户信息失败,清除本地存储
this.logout()
throw error
}
},
// 登出
async logout() {
try {
await userApi.logout()
} catch (error) {
console.error('登出失败:', error)
} finally {
this.clearToken()
this.clearUserInfo()
}
}
}
})
\ No newline at end of file
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 全局样式 */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f7f8fa;
}
#app {
min-height: 100vh;
}
/* 自定义工具类 */
.safe-area-inset-top {
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}
.safe-area-inset-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
/* 覆盖 Vant 样式 */
.van-nav-bar {
background-color: #fff;
}
.van-nav-bar__title {
color: #323233;
}
/* 页面容器 */
.page-container {
min-height: 100vh;
background-color: #f7f8fa;
}
.content-container {
padding: 16px;
}
\ No newline at end of file
import axios from 'axios'
import { Toast } from 'vant'
// 创建 axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 添加 token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 显示加载提示
Toast.loading({
message: '加载中...',
forbidClick: true,
duration: 0
})
return config
},
(error) => {
// 对请求错误做些什么
Toast.clear()
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
// 对响应数据做点什么
Toast.clear()
const { data } = response
// 根据后端接口规范处理响应
if (data.code === 200 || data.success) {
return data
} else {
// 业务错误
Toast.fail(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
}
},
(error) => {
// 对响应错误做点什么
Toast.clear()
let message = '网络错误'
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
message = '未授权,请重新登录'
// 清除 token 并跳转到登录页
localStorage.removeItem('token')
// router.push('/login')
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址出错'
break
case 408:
message = '请求超时'
break
case 500:
message = '服务器内部错误'
break
case 501:
message = '服务未实现'
break
case 502:
message = '网关错误'
break
case 503:
message = '服务不可用'
break
case 504:
message = '网关超时'
break
case 505:
message = 'HTTP版本不受支持'
break
default:
message = data?.message || `连接错误${status}`
}
} else if (error.code === 'ECONNABORTED') {
message = '请求超时'
} else if (error.message) {
message = error.message
}
Toast.fail(message)
return Promise.reject(error)
}
)
export default request
\ No newline at end of file
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
<template>
<div class="not-found-container">
<div class="not-found-content">
<!-- 404 图标 -->
<div class="not-found-icon">
<svg viewBox="0 0 200 200" class="w-32 h-32 text-gray-300">
<circle cx="100" cy="100" r="90" fill="none" stroke="currentColor" stroke-width="4"/>
<text x="100" y="120" text-anchor="middle" font-size="48" font-weight="bold" fill="currentColor">404</text>
</svg>
</div>
<!-- 错误信息 -->
<div class="not-found-text">
<h1 class="text-2xl font-bold text-gray-800 mb-2">页面不存在</h1>
<p class="text-gray-600 mb-8">抱歉,您访问的页面不存在或已被删除</p>
</div>
<!-- 操作按钮 -->
<div class="not-found-actions space-y-4">
<van-button
type="primary"
round
block
@click="goHome"
class="mb-4"
>
返回首页
</van-button>
<van-button
plain
round
block
@click="goBack"
>
返回上页
</van-button>
</div>
<!-- 建议链接 -->
<div class="suggested-links mt-8">
<p class="text-sm text-gray-500 mb-4">您可能想要访问:</p>
<div class="space-y-2">
<van-cell
title="首页"
is-link
@click="$router.push('/')"
icon="home-o"
/>
<van-cell
title="组件演示"
is-link
@click="$router.push('/demo')"
icon="apps-o"
/>
<van-cell
title="关于我们"
is-link
@click="$router.push('/about')"
icon="info-o"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// 返回首页
const goHome = () => {
router.push('/')
}
// 返回上一页
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<style scoped>
.not-found-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.not-found-content {
text-align: center;
max-width: 400px;
width: 100%;
}
.not-found-icon {
margin-bottom: 2rem;
}
.suggested-links {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
</style>
\ No newline at end of file
<template>
<div class="splash-container" :class="{ 'fade-out': isLeaving }">
<div class="splash-content">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="lotus-pattern animate-float"></div>
<div class="cloud-pattern animate-drift"></div>
</div>
<!-- 主要内容 -->
<div class="main-content">
<!-- Logo区域 -->
<div class="logo-section animate-fade-in-up">
<div class="logo-circle">
<div class="dharma-wheel animate-rotate">
<div class="wheel-center"></div>
<div class="wheel-spokes"></div>
</div>
</div>
<h1 class="app-title">三坛大戒</h1>
<p class="app-subtitle">传承千年佛法 弘扬戒律精神</p>
</div>
<!-- 加载动画 -->
<div class="loading-section animate-fade-in-up-delay">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
<p class="loading-text">正在加载...</p>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-info animate-fade-in">
<p class="version">版本 1.0.0</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const isLeaving = ref(false)
onMounted(() => {
// 3秒后开始离开动画,然后跳转到首页
setTimeout(() => {
isLeaving.value = true
// 等待淡出动画完成后跳转
setTimeout(() => {
router.push('/home')
}, 500)
}, 2500)
})
</script>
<style scoped>
.splash-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, #fffbeb, #fed7aa);
background-image:
radial-gradient(circle at 20% 20%, rgba(251, 191, 36, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(245, 158, 11, 0.1) 0%, transparent 50%);
transition: opacity 0.5s ease-out;
}
.splash-container.fade-out {
opacity: 0;
}
.splash-content {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 3rem 2rem;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.lotus-pattern {
position: absolute;
top: 2.5rem;
right: 2.5rem;
width: 8rem;
height: 8rem;
opacity: 0.1;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath d='M50 20c-5 0-10 5-10 15s5 15 10 15 10-5 10-15-5-15-10-15z' fill='%23f59e0b'/%3E%3Cpath d='M35 35c-5-3-12-1-15 5s-1 12 5 15 12 1 15-5 1-12-5-15z' fill='%23f59e0b'/%3E%3Cpath d='M65 35c5-3 12-1 15 5s1 12-5 15-12 1-15-5-1-12 5-15z' fill='%23f59e0b'/%3E%3C/svg%3E");
}
.cloud-pattern {
position: absolute;
bottom: 5rem;
left: 2.5rem;
width: 6rem;
height: 4rem;
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 60'%3E%3Cpath d='M20 40c-8 0-15-7-15-15s7-15 15-15c2 0 4 0 6 1 3-6 9-10 16-10s13 4 16 10c2-1 4-1 6-1 8 0 15 7 15 15s-7 15-15 15H20z' fill='%23f59e0b'/%3E%3C/svg%3E");
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.logo-section {
text-align: center;
margin-bottom: 4rem;
}
.logo-circle {
position: relative;
width: 8rem;
height: 8rem;
margin: 0 auto 2rem;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f97316);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: float 3s ease-in-out infinite;
}
.dharma-wheel {
position: absolute;
top: 1rem;
left: 1rem;
right: 1rem;
bottom: 1rem;
border-radius: 50%;
background: white;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.wheel-center {
width: 1rem;
height: 1rem;
border-radius: 50%;
background: linear-gradient(135deg, #f59e0b, #ea580c);
}
.wheel-spokes {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.wheel-spokes::before,
.wheel-spokes::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 4rem;
height: 0.125rem;
background: linear-gradient(to right, #f59e0b, #ea580c);
transform: translate(-50%, -50%);
}
.wheel-spokes::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.wheel-spokes::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.app-title {
font-size: 2.25rem;
font-weight: 700;
color: #92400e;
margin-bottom: 0.5rem;
font-family: 'PingFang SC', 'Hiragino Sans GB', sans-serif;
}
.app-subtitle {
font-size: 1.125rem;
color: #b45309;
opacity: 0.8;
}
.loading-section {
text-align: center;
}
.loading-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.loading-dots span {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background-color: #f59e0b;
animation: bounce 1.4s ease-in-out infinite both;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.loading-text {
color: #b45309;
font-size: 0.875rem;
}
.footer-info {
text-align: center;
}
.version {
color: #d97706;
font-size: 0.75rem;
opacity: 0.6;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes drift {
0%, 100% {
transform: translateX(0px);
}
50% {
transform: translateX(20px);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out;
}
.animate-fade-in-up-delay {
animation: fadeInUp 0.8s ease-out 0.3s both;
}
.animate-fade-in {
animation: fadeInUp 0.8s ease-out 0.6s both;
}
.animate-rotate {
animation: rotate 8s linear infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-drift {
animation: drift 4s ease-in-out infinite;
}
</style>
\ No newline at end of file
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
<template>
<div class="page-container">
<!-- 导航栏 -->
<van-nav-bar title="三师七证" left-arrow @click-left="$router.back()" class="custom-nav">
<template #right>
<van-icon name="search" size="18" />
</template>
</van-nav-bar>
<!-- 内容区域 -->
<div class="content-container">
<!-- 顶部说明 -->
<div class="intro-section">
<div class="intro-card">
<div class="intro-icon">📜</div>
<div class="intro-content">
<h3>三师七证</h3>
<p>三师:得戒和尚、羯磨阿阇梨、教授阿阇梨<br>七证:七位证明师</p>
</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-section">
<van-tabs v-model:active="activeTab" @change="handleTabChange" class="custom-tabs">
<van-tab title="全部" name="all"></van-tab>
<van-tab title="三师" name="teachers"></van-tab>
<van-tab title="七证" name="witnesses"></van-tab>
</van-tabs>
</div>
<!-- 法师列表 -->
<div class="teachers-list">
<div
v-for="teacher in filteredTeachers"
:key="teacher.id"
class="teacher-card"
@click="handleTeacherClick(teacher)"
>
<div class="teacher-avatar">
<img v-if="teacher.avatar" :src="teacher.avatar" :alt="teacher.name" />
<div v-else class="avatar-placeholder">
<span>{{ teacher.name.charAt(0) }}</span>
</div>
</div>
<div class="teacher-info">
<div class="teacher-header">
<h4 class="teacher-name">{{ teacher.name }}</h4>
<div class="teacher-role" :class="getRoleClass(teacher.role)">
{{ teacher.role }}
</div>
</div>
<div class="teacher-details">
<p class="teacher-title">{{ teacher.title }}</p>
<p class="teacher-temple">{{ teacher.temple }}</p>
<div class="teacher-meta">
<span class="ordination-year">{{ teacher.ordinationYear }}年受戒</span>
<span class="experience">{{ teacher.experience }}年戒腊</span>
</div>
</div>
</div>
<div class="teacher-actions">
<van-icon name="arrow" />
</div>
</div>
</div>
<!-- 空状态 -->
<van-empty v-if="filteredTeachers.length === 0" description="暂无相关法师信息" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const activeTab = ref('all')
// 法师数据
const teachers = ref([
{
id: 1,
name: '慧明法师',
title: '方丈',
role: '得戒和尚',
temple: '大觉寺',
ordinationYear: 1985,
experience: 39,
avatar: null,
type: 'teacher'
},
{
id: 2,
name: '智慧法师',
title: '首座',
role: '羯磨阿阇梨',
temple: '大觉寺',
ordinationYear: 1990,
experience: 34,
avatar: null,
type: 'teacher'
},
{
id: 3,
name: '觉悟法师',
title: '监院',
role: '教授阿阇梨',
temple: '大觉寺',
ordinationYear: 1992,
experience: 32,
avatar: null,
type: 'teacher'
},
{
id: 4,
name: '慈悲法师',
title: '知客',
role: '证明师',
temple: '大觉寺',
ordinationYear: 1995,
experience: 29,
avatar: null,
type: 'witness'
},
{
id: 5,
name: '般若法师',
title: '维那',
role: '证明师',
temple: '大觉寺',
ordinationYear: 1998,
experience: 26,
avatar: null,
type: 'witness'
},
{
id: 6,
name: '禅定法师',
title: '典座',
role: '证明师',
temple: '大觉寺',
ordinationYear: 2000,
experience: 24,
avatar: null,
type: 'witness'
},
{
id: 7,
name: '精进法师',
title: '书记',
role: '证明师',
temple: '大觉寺',
ordinationYear: 2002,
experience: 22,
avatar: null,
type: 'witness'
},
{
id: 8,
name: '持戒法师',
title: '库头',
role: '证明师',
temple: '大觉寺',
ordinationYear: 2005,
experience: 19,
avatar: null,
type: 'witness'
},
{
id: 9,
name: '忍辱法师',
title: '僧值',
role: '证明师',
temple: '大觉寺',
ordinationYear: 2008,
experience: 16,
avatar: null,
type: 'witness'
},
{
id: 10,
name: '布施法师',
title: '衣钵',
role: '证明师',
temple: '大觉寺',
ordinationYear: 2010,
experience: 14,
avatar: null,
type: 'witness'
}
])
// 过滤后的法师列表
const filteredTeachers = computed(() => {
if (activeTab.value === 'all') {
return teachers.value
} else if (activeTab.value === 'teachers') {
return teachers.value.filter(teacher => teacher.type === 'teacher')
} else if (activeTab.value === 'witnesses') {
return teachers.value.filter(teacher => teacher.type === 'witness')
}
return teachers.value
})
// 获取角色样式类
const getRoleClass = (role) => {
if (role.includes('和尚') || role.includes('阿阇梨')) {
return 'role-teacher'
}
return 'role-witness'
}
// 处理标签切换
const handleTabChange = (name) => {
activeTab.value = name
}
// 处理法师点击
const handleTeacherClick = (teacher) => {
router.push(`/teachers/${teacher.id}`)
}
</script>
<style scoped>
.page-container {
min-height: 100vh;
background: #fafafa;
}
.custom-nav {
background: linear-gradient(135deg, #fbbf24, #f97316);
color: white;
}
.custom-nav :deep(.van-nav-bar__title) {
color: white;
font-weight: 600;
}
.custom-nav :deep(.van-icon) {
color: white;
}
.content-container {
padding-top: 46px;
}
.intro-section {
padding: 16px;
}
.intro-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.intro-icon {
font-size: 32px;
margin-right: 16px;
}
.intro-content h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.intro-content p {
font-size: 14px;
color: #666;
margin: 0;
line-height: 1.5;
}
.filter-section {
background: white;
border-bottom: 1px solid #eee;
}
.custom-tabs :deep(.van-tab) {
font-weight: 500;
}
.custom-tabs :deep(.van-tab--active) {
color: #f59e0b;
}
.custom-tabs :deep(.van-tabs__line) {
background: #f59e0b;
}
.teachers-list {
padding: 16px;
}
.teacher-card {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
}
.teacher-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.teacher-card:active {
transform: translateY(-1px);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.12);
}
.teacher-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
margin-right: 16px;
flex-shrink: 0;
}
.teacher-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #fbbf24, #f97316);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: 600;
}
.teacher-info {
flex: 1;
}
.teacher-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.teacher-name {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.teacher-role {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.role-teacher {
background: linear-gradient(135deg, #fbbf24, #f97316);
color: white;
}
.role-witness {
background: #f0f9ff;
color: #0369a1;
border: 1px solid #bae6fd;
}
.teacher-details {
space-y: 4px;
}
.teacher-title {
font-size: 16px;
color: #666;
margin: 0 0 4px 0;
}
.teacher-temple {
font-size: 14px;
color: #999;
margin: 0 0 8px 0;
}
.teacher-meta {
display: flex;
gap: 16px;
}
.ordination-year,
.experience {
font-size: 12px;
color: #999;
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
}
.teacher-actions {
margin-left: 12px;
color: #ccc;
}
</style>
\ No newline at end of file
This diff is collapsed. Click to expand it.
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#1989fa',
success: '#07c160',
warning: '#ff976a',
danger: '#ee0a24',
},
fontSize: {
'xs': '10px',
'sm': '12px',
'base': '14px',
'lg': '16px',
'xl': '18px',
'2xl': '20px',
'3xl': '24px',
}
},
},
plugins: [],
corePlugins: {
preflight: false, // 禁用 Tailwind 的基础样式重置,避免与 Vant 冲突
}
}
\ No newline at end of file
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
import path from 'path'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
import postcsspxtoviewport from 'postcss-px-to-viewport'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router'],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true
}
}),
Components({
resolvers: [VantResolver()],
dts: 'src/components.d.ts'
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@api': path.resolve(__dirname, 'src/api'),
'@assets': path.resolve(__dirname, 'src/assets'),
'@views': path.resolve(__dirname, 'src/views'),
'@stores': path.resolve(__dirname, 'src/stores')
}
},
css: {
postcss: {
plugins: [
tailwindcss,
autoprefixer,
postcsspxtoviewport({
unitToConvert: 'px',
viewportWidth: 375,
unitPrecision: 6,
propList: ['*'],
viewportUnit: 'vw',
fontViewportUnit: 'vw',
selectorBlackList: ['ignore-'],
minPixelValue: 1,
mediaQuery: true,
replace: true,
exclude: [],
landscape: false
})
]
}
},
server: {
host: '0.0.0.0',
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
assetsDir: 'static',
rollupOptions: {
output: {
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]'
}
}
}
})
\ No newline at end of file