├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode └── extensions.json ├── README.md ├── deploy.sh ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── favicon.ico ├── scripts ├── tsconfig.json └── verifyCommit.ts ├── src ├── App.vue ├── assets │ └── logo.png ├── auto-imports.d.ts ├── components.d.ts ├── components │ ├── Header │ │ └── index.vue │ ├── Navbar │ │ └── index.vue │ └── VirtualList │ │ ├── index.vue │ │ ├── props.ts │ │ ├── type.ts │ │ └── utils.ts ├── env.d.ts ├── hooks │ ├── usePhysicalBtnRetrun.ts │ ├── useRouterAnimation.ts │ └── useScrollPosition.ts ├── main.css ├── main.ts ├── pages │ ├── fifth │ │ └── index.vue │ ├── first │ │ ├── detail │ │ │ └── index.vue │ │ └── index.vue │ ├── fourth │ │ └── index.vue │ ├── second │ │ └── index.vue │ └── third │ │ └── index.vue ├── router │ ├── index.ts │ └── type.d.ts └── stores │ ├── head.ts │ └── navbar.ts ├── tsconfig.json ├── tsconfig.node.json ├── unocss.config.ts └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | index.html 4 | docs/ 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | node: true, 7 | }, 8 | parser: 'vue-eslint-parser', 9 | parserOptions: { 10 | parser: '@typescript-eslint/parser', 11 | ecmaVersion: 2022, 12 | sourceType: 'module', 13 | }, 14 | extends: [ 15 | 'plugin:vue/vue3-recommended', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'prettier', 18 | 'plugin:prettier/recommended', 19 | ], 20 | 21 | rules: { 22 | 'prettier/prettier': 'warn', 23 | '@typescript-eslint/no-non-null-assertion': 'off', 24 | '@typescript-eslint/no-explicit-any': ['error', { ignoreRestArgs: true }], 25 | 'vue/v-on-event-hyphenation': 'error', 26 | 'vue/multi-word-component-names': [ 27 | 'warn', 28 | { 29 | ignores: ['index'], 30 | }, 31 | ], 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .prettierignore 2 | .gitignore 3 | .eslintignore 4 | pnpm-lock.yaml 5 | docs/ 6 | dist/ 7 | .vscode 8 | node_modules 9 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 一行最多 80 字符 3 | printWidth: 80, 4 | // 不使用 tab 缩进,而使用空格 5 | useTabs: false, 6 | // 使用 2 个空格缩进 7 | tabWidth: 2, 8 | // 行尾需要有分号 9 | semi: false, 10 | // 使用单引号代替双引号 11 | singleQuote: true, 12 | // 对象的 key 仅在必要时用引号 13 | quoteProps: 'as-needed', 14 | // jsx 不使用单引号,而使用双引号 15 | jsxSingleQuote: false, 16 | // 末尾使用逗号 17 | trailingComma: 'all', 18 | // 大括号内的首尾需要空格 { foo: bar } 19 | bracketSpacing: true, 20 | // 箭头函数,只有一个参数的时候,也需要括号 21 | arrowParens: 'always', 22 | // 每个文件格式化的范围是文件的全部内容 23 | rangeStart: 0, 24 | rangeEnd: Infinity, 25 | // 不需要写文件开头的 @prettier 26 | requirePragma: false, 27 | // 不需要自动在文件开头插入 @prettier 28 | insertPragma: false, 29 | // 使用默认的折行标准 30 | proseWrap: 'preserve', 31 | // 根据显示样式决定 html 要不要折行 32 | htmlWhitespaceSensitivity: 'css', 33 | // 换行符使用 lf 34 | endOfLine: 'lf', 35 | overrides: [ 36 | { 37 | files: ['*.json5'], 38 | options: { 39 | singleQuote: false, 40 | quoteProps: 'preserve', 41 | }, 42 | }, 43 | { 44 | files: ['*.yml'], 45 | options: { 46 | singleQuote: false, 47 | }, 48 | }, 49 | ], 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["johnsoncodehk.volar", "antfu.unocss"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | unocss 是什么,不清楚的可以看这边[ 重新构想原子化 CSS](https://antfu.me/posts/reimagine-atomic-css-zh), 3 | 4 | # 整体的架构 5 | 1. vue3 + setup + ts, vw + rem等来搭建的移动端项目 6 | 2. tslint, prettier来控制代码的格式 7 | 3. simple-git-hook来控制代码提交的规范 8 | 4. deploy.sh 来实现自动部署 9 | 5. unocss 及其生态来实现css和icon图标的按需加载,不需要使用js就能引入图标 10 | 6. 记录滚动条位置,监听物理键返回,路由动画等都是hooks的形式存在 11 | 12 | # 项目效果图 13 | 代码地址: [https://github.com/cll123456/template-varlet-v3-ts](https://github.com/cll123456/template-varlet-v3-ts) 14 | 15 | 演示环境: [https://cll123456.github.io/template-varlet-v3-ts](https://cll123456.github.io/template-varlet-v3-ts) 16 | 17 | > 在断断续续的几天中,把项目实现了,做的效果还是让自己满意的。从开发体验来说是真的香,一直从js, css, icon图标等都遵从按需加载的原理。 18 | ## 外围效果 19 | 20 | ![total-pages.gif](https://img-blog.csdnimg.cn/img_convert/f3e3fbf948c22405c2b9eef91a2abedf.gif) 21 | 22 | > 标配的移动端项目,移动端的适配也是做好了,vm + rem的形式来的。上中下的布局,黑暗模式等 23 | ## 第一个页面 24 | 25 | 26 | ![first-pages.gif](https://img-blog.csdnimg.cn/img_convert/24ea4ff185a73d5f11e0b55e73a22705.gif) 27 | > 进入时候的动画是渐变,进入详情是右边切入动画,离开详情是左边切入的动画,看起来还挺好看的。🎈🎈🎈 28 | ## 第二个页面 29 | 30 | ![second-pages.gif](https://img-blog.csdnimg.cn/img_convert/b87b7de1aec89df5fdf1c599000c9456.gif) 31 | > 第二个特点是实现移动端物理键的控制,换句话说是这里实现了监听物理按钮的返回来做一点你想要的事情。 32 | 33 | ## 第三个页面 34 | 35 | ![third-pages.gif](https://img-blog.csdnimg.cn/img_convert/24559109d3dd25cd7298f4e85276e385.gif) 36 | 37 | > 由于varlet提供了无限滚动的组件,如果无限滚动的数据太多,那么dom数量达到一定的量级就会卡顿,所以我在此基础上加上了虚拟dom的形式来节约性能。注意看,每一个列表的高度可是不固定的哦! 38 | 39 | ## 第五个页面 40 | 41 | ![fifth-pages.gif](https://img-blog.csdnimg.cn/img_convert/4391ff754df71f2c96be9d14299445e5.gif) 42 | > 在移动端上面,经常会有需要返回到滚动条指定的位置,也就是说记录滚动条的位置,这里也是实现了哦 43 | 44 | 45 | # 🎈🎈彩蛋 46 | 想要这一系列文章吗?那就请督促我更新吧!😄😄😄让我看到大家的热情,请评论转发告诉我,最后能不能给个小星星呢😁 47 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 确保脚本抛出遇到的错误 4 | set -e 5 | 6 | # 生成静态文件 7 | pnpm run build 8 | 9 | # 进入生成的文件夹 10 | cd dist/ 11 | 12 | # 如果是发布到自定义域名 13 | # echo 'www.example.com' > CNAME 14 | 15 | git init 16 | git add -A 17 | git commit -m 'deploy: 自动部署' 18 | 19 | # 如果发布到 https://.github.io 20 | # git push -f git@github.com:/.github.io.git master 21 | 22 | # 如果发布到 https://.github.io/ 23 | git push -f git@github.com:cll123456/template-varlet-v3-ts.git master:gh-pages 24 | 25 | cd - 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite App 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template-varlet-v3-ts", 3 | "private": false, 4 | "homepage": "./", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vue-tsc --noEmit && vite build --base=/template-varlet-v3-ts/", 9 | "preview": "vite preview", 10 | "deploy": "deploy.sh", 11 | "eslint": "eslint --ext .js,ts,.vue --fix src", 12 | "prettier": "prettier . --write" 13 | }, 14 | "dependencies": { 15 | "@varlet/ui": "^1.27.20", 16 | "pinia": "^2.0.22", 17 | "vue": "^3.2.39", 18 | "vue-router": "^4.1.5" 19 | }, 20 | "devDependencies": { 21 | "@iconify-json/mdi": "^1.1.33", 22 | "@types/node": "^17.0.45", 23 | "@typescript-eslint/eslint-plugin": "^5.38.0", 24 | "@typescript-eslint/parser": "^5.38.0", 25 | "@unocss/preset-attributify": "^0.34.1", 26 | "@unocss/preset-icons": "^0.34.1", 27 | "@unocss/preset-typography": "^0.34.1", 28 | "@unocss/preset-web-fonts": "^0.34.1", 29 | "@unocss/reset": "^0.34.1", 30 | "@vitejs/plugin-vue": "^2.3.4", 31 | "eslint": "^8.24.0", 32 | "eslint-config-prettier": "^8.5.0", 33 | "eslint-plugin-prettier": "^4.2.1", 34 | "eslint-plugin-vue": "^9.5.1", 35 | "lint-staged": "^12.5.0", 36 | "picocolors": "^1.0.0", 37 | "postcss-px-to-viewport": "^1.1.1", 38 | "prettier": "^2.7.1", 39 | "simple-git-hooks": "^2.8.0", 40 | "ts-node": "^10.9.1", 41 | "typescript": "^4.8.3", 42 | "unocss": "^0.34.1", 43 | "unplugin-auto-import": "^0.7.2", 44 | "unplugin-vue-components": "^0.19.9", 45 | "vite": "^2.9.15", 46 | "vite-plugin-pages": "^0.23.0", 47 | "vue-tsc": "^0.34.17" 48 | }, 49 | "husky": { 50 | "hooks": { 51 | "pre-commit": "lint-staged" 52 | } 53 | }, 54 | "simple-git-hooks": { 55 | "pre-commit": "pnpm exec lint-staged --concurrent false", 56 | "commit-msg": "pnpm exec ts-node scripts/verifyCommit.ts $1" 57 | }, 58 | "lint-staged": { 59 | "*.{js,ts,vue}": [ 60 | "pnpm run eslint", 61 | "pnpm run prettier" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | module.exports = { 3 | plugins: { 4 | 'postcss-px-to-viewport': { 5 | viewportWidth: 375, 6 | unitPrecision: 6, 7 | unitToConvert: 'px', 8 | propList: ['*'], 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cll123456/template-varlet-v3-ts/dbbce201e8eeec16d2c21621d6f9cef27fd1dc07/public/favicon.ico -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "target": "es2019", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "types": ["node"] 11 | }, 12 | "include": ["./*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /scripts/verifyCommit.ts: -------------------------------------------------------------------------------- 1 | // Invoked on the commit-msg git hook by simple-git-hooks. 2 | 3 | import colors from 'picocolors' 4 | import { readFileSync } from 'fs' 5 | 6 | // get $1 from commit-msg script 7 | const msgPath = process.argv[2] 8 | const msg = readFileSync(msgPath, 'utf-8').trim() 9 | 10 | const releaseRE = /^v\d/ 11 | const commitRE = 12 | /^(revert: )?(feat|bug|fix|ui|docs|style|perf|release|deploy|refactor|test|chore|revert|merge|build)(\(.+\))?: .{1,50}/ 13 | const msgObj: any = { 14 | feat: '新功能(feature)', 15 | bug: '此项特别针对bug号,用于向测试反馈bug列表的bug修改情况', 16 | ui: '更新 ui', 17 | fix: '修复bug(fix)', 18 | docs: '文档(documentation)', 19 | style: '格式,不影响代码运行的变动(style)', 20 | perf: '性能性能优化(performance)', 21 | release: '发布(release)', 22 | deploy: '部署(deploy)', 23 | refactor: '重构,即不是新增功能,也不是修改bug的代码变动(refactor)', 24 | test: '单元测试(test)', 25 | chore: '构建过程或辅助工具的变动(chore)', 26 | revert: '回滚(revert)', 27 | merge: '合并分支(merge)', 28 | build: '构建(build)', 29 | } 30 | 31 | let msgStr = '' 32 | for (const key in msgObj) { 33 | if (Object.prototype.hasOwnProperty.call(msgObj, key)) { 34 | const element = msgObj[key] 35 | msgStr += ` ${key}: ` + element + '\n' 36 | } 37 | } 38 | 39 | if (!releaseRE.test(msg) && !commitRE.test(msg)) { 40 | console.log() 41 | console.error( 42 | ` ${colors.bgRed(colors.white(' ERROR '))} ${colors.red( 43 | `invalid commit message format.`, 44 | )}\n\n` + 45 | colors.red( 46 | ` Proper commit message format is required for automated changelog generation. Examples:\n\n`, 47 | ) + 48 | `${colors.green(`${msgStr}`)}\n\n`, 49 | ) 50 | process.exit(1) 51 | } 52 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 72 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cll123456/template-varlet-v3-ts/dbbce201e8eeec16d2c21621d6f9cef27fd1dc07/src/assets/logo.png -------------------------------------------------------------------------------- /src/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const computed: typeof import('vue')['computed'] 5 | const createApp: typeof import('vue')['createApp'] 6 | const customRef: typeof import('vue')['customRef'] 7 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 8 | const defineComponent: typeof import('vue')['defineComponent'] 9 | const effectScope: typeof import('vue')['effectScope'] 10 | const EffectScope: typeof import('vue')['EffectScope'] 11 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 12 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 13 | const h: typeof import('vue')['h'] 14 | const inject: typeof import('vue')['inject'] 15 | const isReadonly: typeof import('vue')['isReadonly'] 16 | const isRef: typeof import('vue')['isRef'] 17 | const markRaw: typeof import('vue')['markRaw'] 18 | const nextTick: typeof import('vue')['nextTick'] 19 | const onActivated: typeof import('vue')['onActivated'] 20 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 21 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 22 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 23 | const onDeactivated: typeof import('vue')['onDeactivated'] 24 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 25 | const onMounted: typeof import('vue')['onMounted'] 26 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 27 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 28 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 29 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 30 | const onUnmounted: typeof import('vue')['onUnmounted'] 31 | const onUpdated: typeof import('vue')['onUpdated'] 32 | const provide: typeof import('vue')['provide'] 33 | const reactive: typeof import('vue')['reactive'] 34 | const readonly: typeof import('vue')['readonly'] 35 | const ref: typeof import('vue')['ref'] 36 | const resolveComponent: typeof import('vue')['resolveComponent'] 37 | const shallowReactive: typeof import('vue')['shallowReactive'] 38 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 39 | const shallowRef: typeof import('vue')['shallowRef'] 40 | const toRaw: typeof import('vue')['toRaw'] 41 | const toRef: typeof import('vue')['toRef'] 42 | const toRefs: typeof import('vue')['toRefs'] 43 | const triggerRef: typeof import('vue')['triggerRef'] 44 | const unref: typeof import('vue')['unref'] 45 | const useAttrs: typeof import('vue')['useAttrs'] 46 | const useCssModule: typeof import('vue')['useCssModule'] 47 | const useCssVars: typeof import('vue')['useCssVars'] 48 | const useRoute: typeof import('vue-router')['useRoute'] 49 | const useRouter: typeof import('vue-router')['useRouter'] 50 | const useSlots: typeof import('vue')['useSlots'] 51 | const watch: typeof import('vue')['watch'] 52 | const watchEffect: typeof import('vue')['watchEffect'] 53 | } 54 | export {} 55 | -------------------------------------------------------------------------------- /src/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | declare module '@vue/runtime-core' { 7 | export interface GlobalComponents { 8 | Header: typeof import('./components/Header/index.vue')['default'] 9 | Navbar: typeof import('./components/Navbar/index.vue')['default'] 10 | RouterLink: typeof import('vue-router')['RouterLink'] 11 | RouterView: typeof import('vue-router')['RouterView'] 12 | VarAppBar: typeof import('@varlet/ui')['_AppBarComponent'] 13 | VarBadge: typeof import('@varlet/ui')['_BadgeComponent'] 14 | VarButton: typeof import('@varlet/ui')['_ButtonComponent'] 15 | VarCard: typeof import('@varlet/ui')['_CardComponent'] 16 | VarIcon: typeof import('@varlet/ui')['_IconComponent'] 17 | VirtualList: typeof import('./components/VirtualList/index.vue')['default'] 18 | } 19 | } 20 | 21 | export {} 22 | -------------------------------------------------------------------------------- /src/components/Header/index.vue: -------------------------------------------------------------------------------- 1 | 67 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/components/Navbar/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/components/VirtualList/index.vue: -------------------------------------------------------------------------------- 1 | 212 | 271 | 272 | 273 | -------------------------------------------------------------------------------- /src/components/VirtualList/props.ts: -------------------------------------------------------------------------------- 1 | import { PropType } from 'vue' 2 | import { IVirtualObject } from './type' 3 | 4 | export default { 5 | /** 6 | * 无限滚动的loading 默认false 7 | */ 8 | loading: { 9 | type: Boolean, 10 | default: false, 11 | }, 12 | /** 13 | * 是否立即运行 默认true 14 | */ 15 | immediateCheck: { 16 | type: Boolean, 17 | default: true, 18 | }, 19 | /** 20 | * 无限循环是否完成 默认false 21 | */ 22 | finished: { 23 | type: Boolean, 24 | default: false, 25 | }, 26 | /** 27 | * 是否运行出错 默认false 28 | */ 29 | error: { 30 | type: Boolean, 31 | default: false, 32 | }, 33 | /** 34 | * 触底的高度 默认0 35 | */ 36 | offset: { 37 | type: [String, Number], 38 | default: 0, 39 | }, 40 | loadingText: { 41 | type: String, 42 | }, 43 | /** 44 | * 完成时候的文字 45 | */ 46 | finishedText: { 47 | type: String, 48 | }, 49 | /** 50 | * 错误的文字 51 | */ 52 | errorText: { 53 | type: String, 54 | }, 55 | /** 56 | * load方法 57 | */ 58 | onLoad: { 59 | type: Function as PropType<() => void>, 60 | }, 61 | /** 62 | * 更改loading的方法 63 | */ 64 | 'onUpdate:loading': { 65 | type: Function as PropType<(loading: boolean) => void>, 66 | }, 67 | /** 68 | * 更改错误的方法 69 | */ 70 | 'onUpdate:error': { 71 | type: Function as PropType<(error: boolean) => void>, 72 | }, 73 | /** 74 | * 是否使用虚拟dom 默认false, 75 | */ 76 | useVirtual: { 77 | type: Boolean, 78 | required: true, 79 | default: false, 80 | }, 81 | /** 82 | * 传入的数据 83 | */ 84 | dataList: { 85 | type: Array as PropType, 86 | default: () => [], 87 | }, 88 | /** 89 | * 每一个子项的高度,方便用于一开始计算,默认是50,会自动计算 90 | */ 91 | itemDefaultHeight: { 92 | type: Number, 93 | default: 50, 94 | }, 95 | /** 96 | * 虚拟dom的情况显示多少条数据,默认是20 97 | */ 98 | showCounts: { 99 | type: Number, 100 | default: 20, 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /src/components/VirtualList/type.ts: -------------------------------------------------------------------------------- 1 | import { ListProps } from '@varlet/ui' 2 | 3 | export interface IVirtualObject { 4 | /** 5 | * id 6 | */ 7 | id: string 8 | /** 9 | * 其他属性 10 | */ 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | [key: string]: any 13 | } 14 | 15 | export interface IVirtualList extends ListProps { 16 | /** 17 | * 是否开启虚拟dom加载 18 | */ 19 | useVirtual?: boolean 20 | /** 21 | * 传入的数据 22 | */ 23 | dataList: Array 24 | /** 25 | * key 26 | */ 27 | dataKey?: string | 'id' 28 | } 29 | 30 | export interface IListAll extends IVirtualList { 31 | /** 32 | * 当前行的id 33 | */ 34 | curRowId: number | string 35 | /** 36 | * 是否渲染过 37 | */ 38 | hasRenderDom: boolean 39 | /** 40 | * 当前行key 41 | */ 42 | curRowKey: number | string 43 | /** 44 | * 当前行距离顶部的距离 45 | */ 46 | curRowMarginTop: number 47 | /** 48 | * 当前行的高度 49 | */ 50 | curRowHeight: number 51 | } 52 | -------------------------------------------------------------------------------- /src/components/VirtualList/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export const isString = (val: unknown): val is string => typeof val === 'string' 3 | 4 | export const isBool = (val: unknown): val is boolean => typeof val === 'boolean' 5 | 6 | export const isNumber = (val: unknown): val is number => typeof val === 'number' 7 | 8 | export const isPlainObject = (val: unknown): val is Record => 9 | Object.prototype.toString.call(val) === '[object Object]' 10 | 11 | export const isObject = (val: unknown): val is Record => 12 | typeof val === 'object' && val !== null 13 | 14 | export const isArray = (val: unknown): val is Array => Array.isArray(val) 15 | // example 1rem 16 | export const isRem = (value: unknown): value is string => 17 | isString(value) && value.endsWith('rem') 18 | 19 | // example 1 || 1px 20 | export const isPx = (value: unknown): value is string | number => 21 | (isString(value) && value.endsWith('px')) || isNumber(value) 22 | 23 | // example 1% 24 | export const isPercent = (value: unknown): value is string => 25 | isString(value) && value.endsWith('%') 26 | 27 | // example 1vw 28 | export const isVw = (value: unknown): value is string => 29 | isString(value) && value.endsWith('vw') 30 | 31 | // example 1vh 32 | export const isVh = (value: unknown): value is string => 33 | isString(value) && value.endsWith('vh') 34 | 35 | // example return 1 36 | export const toPxNum = (value: unknown): number => { 37 | if (isNumber(value)) { 38 | return value 39 | } 40 | 41 | if (isPx(value)) { 42 | return +(value as string).replace('px', '') 43 | } 44 | 45 | if (isVw(value)) { 46 | return (+(value as string).replace('vw', '') * window.innerWidth) / 100 47 | } 48 | 49 | if (isVh(value)) { 50 | return (+(value as string).replace('vh', '') * window.innerHeight) / 100 51 | } 52 | 53 | if (isRem(value)) { 54 | const num = +(value as string).replace('rem', '') 55 | const rootFontSize = window.getComputedStyle( 56 | document.documentElement, 57 | ).fontSize 58 | 59 | return num * parseFloat(rootFontSize) 60 | } 61 | 62 | if (isString(value)) { 63 | return toNumber(value) 64 | } 65 | 66 | // % and other 67 | return 0 68 | } 69 | 70 | export const toNumber = ( 71 | val: number | string | boolean | undefined | null, 72 | ): number => { 73 | if (val == null) return 0 74 | 75 | if (isString(val)) { 76 | val = parseFloat(val) 77 | val = Number.isNaN(val) ? 0 : val 78 | return val 79 | } 80 | 81 | if (isBool(val)) return Number(val) 82 | 83 | return val 84 | } 85 | 86 | export const dt = (value: unknown, defaultText: string | undefined) => 87 | value == null ? defaultText : value 88 | 89 | export function createNamespace(name: string) { 90 | const namespace = `var-${name}` 91 | 92 | const createBEM = (suffix?: string): string => { 93 | if (!suffix) return namespace 94 | 95 | return suffix.startsWith('--') 96 | ? `${namespace}${suffix}` 97 | : `${namespace}__${suffix}` 98 | } 99 | 100 | type Classes = (string | [any, string, string?])[] 101 | 102 | const classes = (...classes: Classes): any[] => { 103 | return classes.map((className) => { 104 | if (isArray(className)) { 105 | const [condition, truthy, falsy = null] = className 106 | return condition ? truthy : falsy 107 | } 108 | 109 | return className 110 | }) 111 | } 112 | 113 | return { 114 | n: createBEM, 115 | classes, 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/usePhysicalBtnRetrun.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onBeforeUnmount } from 'vue' 2 | /** 3 | * 移动端物理按钮f返回事件 4 | * @param doSomeThing {Function} 需要做的事情 5 | * attention: 如果没有传回调函数,自动router.go(-1) 6 | */ 7 | export function usePhysicalBtnRetrun(doSomeThing?: () => void) { 8 | const router = useRouter() 9 | onMounted(() => { 10 | if (window.history) { 11 | history.pushState(history.state, '', document.URL) 12 | window.addEventListener('popstate', cusFunc, false) //false阻止默认事件 13 | } 14 | }) 15 | 16 | onBeforeUnmount(() => { 17 | window.removeEventListener('popstate', cusFunc, false) //false阻止默认事件 18 | }) 19 | const cusFunc = () => { 20 | if (doSomeThing && typeof doSomeThing === 'function') { 21 | doSomeThing() 22 | } else { 23 | router.go(-1) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useRouterAnimation.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeRouteLeave } from 'vue-router' 2 | /** 3 | * 页面路由离开的动画hook 4 | * @param routerName {String} 进入的路由名称,就是需要进入那个页面的开场动画 5 | * @param beforeAnimationName {String} 进入动画的名称 'slide_right' 'slide_left'} 6 | * @param afterAnimationName {String} 离开动画的名称,默认('fade')需要恢复原来的动画 7 | */ 8 | export function useRouterAnimation( 9 | routerName: string, 10 | beforeAnimationName: string, 11 | afterAnimationName: string, 12 | ) { 13 | onBeforeRouteLeave((to, from, next) => { 14 | // 离开前,改成想要的进入动画 15 | if (to.name === routerName) { 16 | to.meta = { 17 | ...(to.meta || {}), 18 | transitionName: beforeAnimationName, 19 | } 20 | } 21 | // 离开后,改成想要的进入动画 22 | setTimeout(() => { 23 | to.meta = { 24 | ...(to.meta || {}), 25 | transitionName: afterAnimationName, 26 | } 27 | }, 300) 28 | next() 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useScrollPosition.ts: -------------------------------------------------------------------------------- 1 | import routes from '~pages' 2 | export function useScrollPosition(scrollContainerSelector: string | Window) { 3 | const route = useRoute() 4 | let scrollContainer: Element | Window | null 5 | // 监听滚动事件 6 | onMounted(() => { 7 | // window 8 | if (scrollContainerSelector instanceof Window) { 9 | scrollContainer = window 10 | } else { 11 | scrollContainer = document.querySelector(scrollContainerSelector) 12 | } 13 | // not exit 14 | if (!scrollContainer) { 15 | return 16 | } 17 | // 一开始就滚动到对应位置 18 | routes.forEach((item) => { 19 | if (item.name === route.name) { 20 | // scrollContainer 21 | scrollContainer?.scrollTo({ 22 | left: item.meta?.scrollPosition?.left, 23 | top: item.meta?.scrollPosition?.top, 24 | }) 25 | } 26 | }) 27 | // 添加时间监听 28 | scrollContainer?.addEventListener('scroll', handleScroll, { 29 | passive: false, 30 | }) 31 | }) 32 | 33 | /** 34 | * 滚动函数 35 | * @param e {Event} 事件函数 36 | */ 37 | const handleScroll = (e: Event) => { 38 | const scrollTop = 39 | e.target === window 40 | ? window.scrollY 41 | : (e.target as HTMLDivElement).scrollTop 42 | const scrollLeft = 43 | e.target === window 44 | ? window.scrollX 45 | : (e.target as HTMLDivElement).scrollLeft 46 | routes.forEach((item) => { 47 | if (item.name === route.name) { 48 | item.meta = { 49 | ...(item.meta || {}), 50 | scrollPosition: { 51 | top: scrollTop, 52 | left: scrollLeft, 53 | }, 54 | } 55 | } 56 | }) 57 | } 58 | 59 | // 销毁监听 60 | onBeforeUnmount(() => { 61 | scrollContainer?.removeEventListener('scroll', handleScroll) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | html { 8 | font-size: 16px; 9 | } 10 | 11 | html body { 12 | min-width: 320px; 13 | margin-right: auto; 14 | margin-left: auto; 15 | } 16 | 17 | /* 18 | 1 19 | 100vw/x = 750px/100rem 20 | 21 | */ 22 | 23 | /** 24 | 以375为基础稿 25 | 100vw 320px 26 | --------- = -------- 27 | x vw 14px(默认375的大小是16px) 28 | 29 | === x = 4.375 30 | 31 | **/ 32 | @media screen and (min-width: 0px) and (max-width: 320px) { 33 | html { 34 | font-size: 4.375vw; 35 | min-width: 320px; 36 | } 37 | } 38 | 39 | /** 40 | 以375为基础稿 41 | 100vw 375px 42 | --------- = ------------ 43 | x vw 16px 44 | 45 | === x = 4.26vw 46 | **/ 47 | @media screen and (min-width: 375px) { 48 | html { 49 | font-size: 16px; 50 | font-size: 4.26vw; 51 | min-width: 375px; 52 | } 53 | } 54 | 55 | /** 56 | 以375为基础稿 57 | 100vw 400px 58 | --------- = -------- 59 | x vw 18px 60 | 61 | === x = 4.5vw 62 | **/ 63 | @media screen and (min-width: 400px) { 64 | html { 65 | font-size: 18px; 66 | font-size: 4.5vw; 67 | min-width: 400px; 68 | } 69 | } 70 | 71 | /** 72 | 以375为基础稿 73 | 100vw 500px 74 | --------- = -------- 75 | x vw 20px 76 | 77 | === x = 4vw 78 | **/ 79 | @media screen and (min-width: 500px) { 80 | html { 81 | font-size: 20px; 82 | font-size: 4vw; 83 | min-width: 500px; 84 | } 85 | } 86 | 87 | /* 公共组件库的样式, 主要的颜色值需要与unocss.config.ts中的保持一致 */ 88 | :root { 89 | --font-size-xs: 10px; 90 | --font-size-sm: 12px; 91 | --font-size-md: 14px; 92 | --font-size-lg: 16px; 93 | --icon-size-xs: 16px; 94 | --icon-size-sm: 18px; 95 | --icon-size-md: 20px; 96 | --icon-size-lg: 22px; 97 | --color-body: #fff; 98 | --color-text: #333; 99 | --color-primary: #3a7afe; 100 | --color-info: #00afef; 101 | --color-success: #00c48f; 102 | --color-warning: #ff9f00; 103 | --color-danger: #f44336; 104 | --color-disabled: #e0e0e0; 105 | --color-text-disabled: #aaa; 106 | --cubic-bezier: cubic-bezier(0.25, 0.8, 0.5, 1); 107 | } 108 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import '@unocss/reset/tailwind.css' 4 | import './main.css' 5 | import 'uno.css' 6 | import router from './router' 7 | import { createPinia } from 'pinia' 8 | 9 | const pinia = createPinia() 10 | 11 | const app = createApp(App) 12 | 13 | app.use(router) 14 | app.use(pinia) 15 | 16 | app.mount('#app') 17 | -------------------------------------------------------------------------------- /src/pages/fifth/index.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | meta: { 4 | keepAlive:false, 5 | title: 'first', 6 | transitionName: 'fade', 7 | scrollPosition: { 8 | left: 0, 9 | top: 1000 10 | } 11 | } 12 | } 13 | 14 | 20 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/pages/first/detail/index.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | meta: { 4 | keepAlive:true, 5 | title: 'first detail', 6 | transitionName: 'slide_left' 7 | } 8 | } 9 | 10 | 14 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/pages/first/index.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | meta: { 4 | keepAlive:true, 5 | title: 'first', 6 | transitionName: 'fade' 7 | } 8 | } 9 | 10 | 16 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/pages/fourth/index.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | meta: { 4 | keepAlive:true, 5 | title: 'fourth' 6 | } 7 | } 8 | 9 | 34 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/pages/second/index.vue: -------------------------------------------------------------------------------- 1 | 33 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/pages/third/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import routes from '~pages' 2 | import { createRouter, createWebHashHistory } from 'vue-router' 3 | 4 | const router = createRouter({ 5 | history: createWebHashHistory(), 6 | routes: [...routes, { path: '/', redirect: '/first' }], 7 | }) 8 | // eslint-disable-next-line no-console 9 | console.log(routes) 10 | export default router 11 | -------------------------------------------------------------------------------- /src/router/type.d.ts: -------------------------------------------------------------------------------- 1 | import 'vue-router' 2 | 3 | declare module 'vue-router' { 4 | interface RouteMeta { 5 | /** 6 | * 每个页面的动画名称 7 | */ 8 | transitionName?: string 9 | /** 10 | * 是否缓存当前页面 11 | */ 12 | keepAlive?: boolean 13 | /** 14 | * 当前页面的标题 15 | */ 16 | title?: string 17 | /** 18 | * 当前页面滚动的位置 19 | */ 20 | scrollPosition?: { 21 | /** 22 | * 顶部距离 23 | */ 24 | top: number 25 | /** 26 | * 左侧距离 27 | */ 28 | left: number 29 | /** 30 | * 滚动的行为 31 | */ 32 | behavior?: ScrollBehavior 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/stores/head.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useHeadStore = defineStore< 4 | 'head', 5 | { isDark: boolean }, 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | any, 8 | { changeTheme: (isDark: boolean) => void } 9 | >('head', { 10 | state: () => ({ 11 | /** 12 | * 激活的navbar的颜色 13 | */ 14 | isDark: false, 15 | }), 16 | actions: { 17 | /** 18 | * 改变主题 19 | * @param isDark {boolean} 是否是黑暗 20 | */ 21 | changeTheme(isDark: boolean) { 22 | this.isDark = isDark 23 | }, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/stores/navbar.ts: -------------------------------------------------------------------------------- 1 | import { BadgeProps } from '@varlet/ui' 2 | import { defineStore } from 'pinia' 3 | /** 4 | * navbar的类型 5 | */ 6 | export interface INavbar { 7 | /** 8 | * navbar title 9 | */ 10 | title: string 11 | /** 12 | * navbar logo 13 | */ 14 | logo: string 15 | /** 16 | * navbar router link object 17 | */ 18 | links: { 19 | /** 20 | * router link name 21 | */ 22 | name: string 23 | /** 24 | * router link path 25 | */ 26 | url: string 27 | } 28 | /** 29 | * varlet本身组件的badge所有属性 30 | */ 31 | badge?: BadgeProps 32 | } 33 | 34 | export const useNavbarStore = defineStore< 35 | 'navbar', 36 | { navBarList: INavbar[]; activeColorClass: string }, 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | any, 39 | { changeNavBarList: (title: string, content: INavbar) => void } 40 | >('navbar', { 41 | state: () => ({ 42 | /** 43 | * navbar的数据 44 | */ 45 | navBarList: [ 46 | { 47 | title: 'First', 48 | logo: 'i-mdi-numeric-1-box-outline', 49 | links: { name: 'Home', url: '/first' }, 50 | badge: { 51 | value: 0, 52 | maxValue: 10, 53 | }, 54 | }, 55 | { 56 | title: 'Second', 57 | logo: 'i-mdi-numeric-2-box-outline', 58 | links: { name: 'Home', url: '/second' }, 59 | badge: { 60 | value: 0, 61 | maxValue: 10, 62 | }, 63 | }, 64 | { 65 | title: 'Third-long-long-long-name', 66 | logo: 'i-mdi-numeric-3-box-outline', 67 | links: { name: 'Home', url: '/third' }, 68 | badge: { 69 | value: 0, 70 | maxValue: 10, 71 | }, 72 | }, 73 | { 74 | title: 'Fourth', 75 | logo: 'i-mdi-numeric-4-box-outline', 76 | links: { name: 'Home', url: '/fourth' }, 77 | badge: { 78 | value: 0, 79 | maxValue: 10, 80 | }, 81 | }, 82 | { 83 | title: 'Fifth', 84 | logo: 'i-mdi-numeric-5-box-outline', 85 | links: { name: 'Home', url: '/fifth' }, 86 | badge: { 87 | value: 2, 88 | maxValue: 10, 89 | }, 90 | }, 91 | ], 92 | /** 93 | * 激活的navbar的颜色 94 | */ 95 | activeColorClass: '!text-primary-500', 96 | }), 97 | actions: { 98 | /** 99 | * 改变navbar的数据,主要用于更新badge的值 100 | * @param title {string} navbar title 101 | * @param content {INavbar} navbar content 需要改变的内容 102 | */ 103 | changeNavBarList(title: string, content: Partial) { 104 | this.navBarList = this.navBarList.map((item) => { 105 | if (item.title === title) { 106 | item = { ...item, ...content } 107 | } 108 | return item 109 | }) 110 | }, 111 | }, 112 | }) 113 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "sourceMap": true, 9 | "jsx": "preserve", 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext", "dom"], 14 | "baseUrl": ".", 15 | "types": ["vite/client", "vite-plugin-pages/client"], 16 | "paths": { 17 | "@": ["src"], 18 | "@/*": ["src/*"], 19 | "~/*": ["src/*"] 20 | } 21 | }, 22 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 23 | "references": [ 24 | { "path": "./tsconfig.node.json" }, 25 | { "path": "./scripts/tsconfig.json" } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | } from 'unocss' 9 | 10 | export default defineConfig({ 11 | shortcuts: [ 12 | ['exclude-h-n-s', 'h-[calc(100vh-14.4vw-3rem)] common-content'], 13 | ['exclude-h-s', 'h-[calc(100vh-14.4vw)] common-content'], 14 | [ 15 | 'common-content', 16 | 'w-full overflow-y-auto overflow-x-hidden text-black-100 dark:text-white-100 bg-gray-100 dark:bg-gray-500 p-2 hyphens-auto', 17 | ], 18 | ], 19 | presets: [ 20 | presetUno(), 21 | presetAttributify(), 22 | presetIcons({ 23 | collections: { 24 | mdi: () => 25 | import('@iconify-json/mdi/icons.json').then((i) => i.default as any), 26 | }, 27 | scale: 1.2, 28 | warn: true, 29 | extraProperties: { 30 | display: 'inline-block', 31 | 'vertical-align': 'middle', 32 | }, 33 | }), 34 | presetTypography(), 35 | presetWebFonts({ 36 | fonts: { 37 | sans: 'DM Sans', 38 | serif: 'DM Serif Display', 39 | mono: 'DM Mono', 40 | }, 41 | }), 42 | ], 43 | theme: { 44 | extend: {}, 45 | /**这里的颜色可以自己定义,然后在代码中使用windi.css的预设,主要的颜色值需要与main.css中的一致 */ 46 | colors: { 47 | // 主要的颜色值 48 | success: '#00c48f', 49 | danger: '#f44336', 50 | primary: '#3a7afe', 51 | info: '#00afef', 52 | warning: '#ff9f00', 53 | disabled: '#e0e0e0', 54 | text: '#000', 55 | textDisabled: '#aaa', 56 | light: '#fff', 57 | dark: `#272727`, 58 | }, 59 | fontSize: { 60 | xs: '.6rem', 61 | sm: '.65rem', 62 | base: '.7rem', 63 | lg: '.8rem', 64 | xl: '1rem', 65 | '2xl': '2rem', 66 | '3xl': '3rem', 67 | '4xl': '4rem', 68 | '5xl': '5rem', 69 | '6xl': '6rem', 70 | '7xl': '7rem', 71 | }, 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import Unocss from 'unocss/vite' 4 | import components from 'unplugin-vue-components/vite' 5 | import { VarletUIResolver } from 'unplugin-vue-components/resolvers' 6 | import AutoImport from 'unplugin-auto-import/vite' 7 | import Pages from 'vite-plugin-pages' 8 | import { resolve } from 'path' 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | vue({}), 13 | Unocss(), 14 | // 自动引入组件 15 | components({ 16 | dirs: ['src/components'], 17 | resolvers: [VarletUIResolver()], 18 | dts: './src/components.d.ts', 19 | }), 20 | // 自动引入库的api 21 | AutoImport({ 22 | imports: ['vue', 'vue-router'], 23 | dts: './src/auto-imports.d.ts', 24 | }), 25 | // 基于文件系统的路由 26 | Pages({ 27 | dirs: [{ dir: resolve(__dirname, './src/pages'), baseRoute: '' }], 28 | extensions: ['vue'], 29 | }), 30 | ], 31 | resolve: { 32 | alias: { 33 | '@': resolve(__dirname, './src'), //设置别名 34 | }, 35 | dedupe: ['vue'], 36 | }, 37 | }) 38 | --------------------------------------------------------------------------------