feat: 代码更新

master
xjs 2025-05-09 18:04:15 +08:00
commit 24220d8f27
66 changed files with 8452 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# 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?
.env
.env.local
.env.development.local

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict = true

1
.nvmdrc Normal file
View File

@ -0,0 +1 @@
23.8.0

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
/dist/*
.local
.output.js
.github
.vscode
/node_modules/**
*.html
**/*.svg
**/*.sh
/public/**

10
.prettierrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"printWidth": 160,
"arrowParens": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"semi": true,
"singleAttributePerLine": false,
"vueIndentScriptAndStyle": true,
"htmlWhitespaceSensitivity": "ignore"
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

27
ALIYUN.pem Normal file
View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA2SOM6Heb+BNGha/ucoV+GopddM7ckyGWALhGPJp/Z7P5jgw6
NSJ1G/E7CFEukaLC50eupji9mA4o8Emtrgn8y7uMIc5lafHe0IPy+WA90PZyien4
0u7dD0NrEbKH41SIEuZFbGev0CgQJsxmkS8CmOytmglyJ0JDBpJD9tQLwSuG9kad
DrYPArQdQu+ZALt4gyG7m14c4mZ2hAcIUwNYqFY9g/HIp0q9al8SW3WqzkB1U7GS
kh3i/MwtUvZWPI16aKW4/tdUX7y8PPguHQ1MWLd6DI0iGo9tqTQt35o9ax41O+0S
EY4MgU1Q3XJaMrmrYUpZ2/Y4xJbHOHgyi6afzQIDAQABAoIBAGms4ovUgkSmZOD1
MU/s5eVWx4rsje7RHqa1CAHAkxbOQTq/eqiXX3U83qT6lXZtRvu2KCpfXO4eng/r
W6pi0/P3D4j4YOTBwNWsEdkJ3KvQ9QdnpiBJ/a3K+tW/FGEvp5XDGbBbefYNOWcY
fSZVQadZMFfSFwtCNUqCbq82nY3hkoFVGiQ9EHUlvNCQM4y5VeJuCPzsl8rzAsyo
gpkHyKxU/CNg+f5UuPwostR5eTXgkp6nlpa65yDK0szsww78keE/J1tOB0d4r+Oe
12ZVzYLQrzQwt2CwIGT9KkAUv7eO7ZTMDsG8MYNnRPGXKgjSZqBSW0MCq7ksz1/P
dHTJmSECgYEA9Q8VbnLkdk5NlRCNTONfjLhNUGmAqt/qLPi7tnJa0wfSpr3tg1Aj
AxnV455fT81vbDP8V8tGDNx+/d5jBkIsdsCYOMa6dqZr1HOfSgbH8wETtU9mbKdB
Gt/frdM3rkJvyd5RjoqMex4U7x4f1OWfThHVzvi8TzqfRSXNpbXBMdUCgYEA4tVa
TBQhwgnjMHl9OYrW1urtf2fKEGOZA/uGJI55O7IL2SoYnVjFUHjlE4D94K/hdijp
l2oFydD0GrWVkWULPvTdUFzMTRPH+SUfXbVqXBMJfHJYrRuZHsJBH2jIwIzu4LaA
hliletIdJUtK8mUvwEb+4TlVUwfCYqkveu1EuhkCgYAHjK5pV6rIJkNnmznvK3YP
HMJs/sMTAJDzT7pgtYcsxynrLyC5EefyOYKIX6GqELclCzjz73Q6AzT6VzaPw8wg
4HAQF7c43oml4uX+XtUcHGViCY8rO7/atxjp/v7RJITTIEE89fG7/UJB15i9c1GE
EzKWDL2oZzLu62o5d676/QKBgQDgiCBhvmvMDs18ZkW2d+BBzTpaKvqxTmVgs9EM
zpripFNmG21SE1T9Wy4mKEEl7/NVaxoObzxbkSKQbb4ntcV0BB4uNi1k/ner/zsV
H0aw7YcuUGHGuNLQx6h+1tIhB2BNv1lposXq1aFUETuWxOKHib8yYfY7wiqATshY
/hRRwQKBgCkT2Wt/XjbXLCpX1viEJOLzYYQkrNyPvruXknCnQQ+0/cq4LoRqWZZR
4RjS0RkoOF4vM38u/hgICiUUCR4bYUy/4O7uhNu5tDle6lIeB+ZxVpRRedE0OfQT
tCpQd0yN9iONBlGyfAtpq7oNzuXgsl1OR4usZ8wCinfImChAl6wv
-----END RSA PRIVATE KEY-----

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Vue 3 + TypeScript + Vite 6.x + VueRouter 4.x + Pinia 模板
pnpm作为包管理主要可以解决幽灵依赖包的问题
1. Pinia 状态管理库带加密功能
本地建立.env
文件中`VITE_SECRET_KEY=xxx`
2. 自定义Axios 加密参数 错误日志记录
3. VueRouter 4.x 路由 权限控制 动态缓存
import { removeKeepAliveCache, resetKeepAliveCache } from '@/router/keepAlive'
const instance = getCurrentInstance()!
onBeforeRouteLeave((to) => {
if (to.path === '/C') {
removeKeepAliveCache(instance)
} else {
resetKeepAliveCache(instance)
}
})
通过 removeKeepAliveCache, resetKeepAliveCache 来维护exclude数组里面的内容达到 a 进入C后返回到A页面时a页面的缓存被清楚进入其他页面返回则不被清除缓存
权限控制
有身份权限的写在privateRoutes.ts中
没有身份权限的写在publicRoutes.ts中
4. 项目版本检测确保启动顺利。检测node版本和工具包的版本是否符合要求。
可以根据项目需求修改package.json中的engines字段。
``` shell
pnpm run check-env
```
5. prettier 统一的代码格式这样保存格式化的时候就不会因为不同的格式化工具导致其他的内容也出现修改。vscode配合prettier插件
.prettierrc.json的内容就是指定代码的格式的配置文件
6. SvgIcon组件更加优雅的使用svg图片
将图片放入到 /public/icons 下面name就是图片的名字例如certificate.svg
``` html
<SvgIcon name="certificate" class="w-10 h-10"></SvgIcon>
```
推荐使用较少的svg代码图片不然html的体积会进一步增大
如果是大的背景图推荐使用`vite`的动态导入
```typescript
const getPath = async () => {
const { default: svg } = await import("/src/assets/svg-img/certificate.svg?raw");
test.value = svg;
};
getPath();
```
7. unocss 即时原子 CSS 引擎。主要想解决遍地css文件的麻烦。预设了tailwind css的预算值。所以可以直接对着tailwind css的官方文档编写
8. composables 这个文件夹推荐将业务逻辑写在此处的x.ts文件内然后搭配 vue3的组合式语法。解决视图层和业务层的代码分离。
9. 异步加载动态组件, 针对大的页面要加载很多的JS可以异步错开或者等待条件允许再加载。看个人业务需求
```html
<component :is="currentComponent"></component>
```
```typescript
let currentComponent = ref(defineAsyncComponent(() => import('@/components/TestComponent.vue')))
let flag = false
const handleClick = () => {
if (flag) {
currentComponent.value = defineAsyncComponent(() => import('@/components/TestComponent2.vue'));
} else {
currentComponent.value = defineAsyncComponent(() => import('@/components/TestComponent.vue'));
}
flag = !flag
}
```

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- WX JS SDK 微信小程序一定要引入这个文件 可以自己去官方下载最新的-->
<script type="text/javascript" src="/js/jweixin-1.6.0.js"></script>
<script type="text/javascript" src="/js/uni.webview.1.5.6.js" defer></script>
<title></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "vue-app-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"upload": "bash ./upload.sh",
"build-and-upload": "pnpm run build && pnpm run upload",
"preview": "vite preview",
"check-env": "node ./scripts/checkVersions.cjs"
},
"dependencies": {
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.10.1",
"@unocss/reset": "^66.1.1",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"echarts": "^5.6.0",
"echarts-gl": "^2.0.9",
"pinia": "^2.3.0",
"pinia-plugin-persistedstate": "^4.1.3",
"semver": "^7.6.3",
"unocss-preset-px-to-vw-or-vh": "^1.0.6",
"unocss-preset-scrollbar-hide": "^1.0.1",
"vue": "^3.5.13",
"vue-router": "4",
"vue-virtual-draglist": "^3.3.8"
},
"devDependencies": {
"@iconify-json/carbon": "^1.2.8",
"@types/qs": "^6.9.17",
"@unocss/preset-wind": "^0.65.2",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"prettier": "3.4.2",
"sass-embedded": "^1.86.0",
"svg-sprite-loader": "^6.0.11",
"typescript": "~5.6.2",
"unocss": "^0.65.2",
"unplugin-auto-import": "^0.19.0",
"vite": "^6.0.1",
"vue-tsc": "^2.1.10"
},
"engines": {
"node": ">=22.11.0",
"pnpm": ">=9.13.2"
},
"preinstall": "npx only-allow pnpm"
}

70
plugins/svgBuilder.ts Normal file
View File

@ -0,0 +1,70 @@
// /plugins/svgBuilder.ts
import {readFileSync, readdirSync} from 'fs'
import {join as pathJoin} from 'path'
import {Plugin} from 'vite'
let idPrefix = ''
const svgTitle = /<svg([^>+].*?)>/
const clearHeightWidth = /(width|height)="([^>+].*?)"/g
const clearFill = /fill="[^>+].*?"/g;
const hasViewBox = /(viewBox="[^>+].*?")/g
const clearReturn = /(\r)|(\n)/g
const findSvgFile = (dir: string) => {
const svgRes: string[] = []
const directory = readdirSync(dir, {withFileTypes: true})
for (const dirent of directory) {
if (dirent?.isDirectory()) {
svgRes.push(...findSvgFile(pathJoin(dir, dirent.name, '/')))
} else {
const svg = readFileSync(pathJoin(dir, dirent.name))
.toString()
.replace(clearReturn, '')
.replace(clearFill,'')
.replace(svgTitle, (_$1: string, $2: string) => {
let width = '0'
let height = '0'
let content = $2.replace(
clearHeightWidth,
(_s1, s2:string, s3:string) => {
if (s2 === 'width') {
width = s3
} else if (s2 === 'height') {
height = s3
}
return ''
}
)
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`
}
return `<symbol id="${idPrefix}-${dirent.name.replace(
'.svg',
''
)}" ${content}>`
})
.replace('</svg>', '</symbol>')
svgRes.push(svg)
}
}
return svgRes
}
const svgBuilder = (path: string, prefix = 'icon'): Plugin => {
idPrefix = prefix
const res = findSvgFile(path)
return {
name: 'svg-transform',
transformIndexHtml(html) {
return html.replace(
'<body>',
`<body><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">${res.join('')}</svg>`
)
}
}
}
export default svgBuilder

5309
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
public/icons/circle.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/images/main-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
scripts/checkVersions.cjs Normal file
View File

@ -0,0 +1,18 @@
const semver = require('semver')
const { engines } = require('../package')// 这里的package是你的package.json文件路径
const version = engines.node
if (!semver.satisfies(process.version, version)) {
console.log(
[
'node环境变量版本错误',
'你的node环境启动位置' + process.execPath + '.',
'要求环境版本' + version,
'你的环境版本' + process.version
].join('\n')
)
process.exit(1)
}else{
console.log(`echo check environment successfully`)
}

36
src/App.vue Normal file
View File

@ -0,0 +1,36 @@
<template>
<router-view v-slot="{ Component,route }">
<keep-alive :include="include" :exclude="excludes">
<component :is="Component" :key="route.path"/>
</keep-alive>
</router-view>
</template>
<script setup lang="ts">
import { excludes } from '@/router/keepAlive'
const include:string[] = []
</script>
<style scoped>
/* 过渡动画:左右滑动并且淡入淡出 */
.fade-slide-enter-active, .fade-slide-leave-active {
transition: transform 1s ease, opacity 1s ease;
}
/* 进入动画:从右侧进入 */
.fade-slide-enter {
transform: translateX(100%);
opacity: 0;
}
/* 离开动画:从左侧离开 */
.fade-slide-leave-to {
transform: translateX(-100%);
opacity: 0;
}
</style>

205
src/api/customAxios.ts Normal file
View File

@ -0,0 +1,205 @@
import axios, { AxiosRequestConfig } from 'axios'
import { handleUrl } from './encryptUrl'
// 自定义判断元素类型JS
function toType(obj: any): string {
return {}.toString
.call(obj)
.match(/\s([a-zA-Z]+)/)![1]
.toLowerCase()
}
// 参数过滤函数
function filterNull(o: any) {
for (var key in o) {
if (o[key] === null) {
delete o[key]
}
if (toType(o[key]) === 'string') {
o[key] = o[key].trim()
} else if (toType(o[key]) === 'object') {
o[key] = filterNull(o[key])
} else if (toType(o[key]) === 'array') {
o[key] = filterNull(o[key])
}
}
return o
}
/*
https://cnodejs.org/api/v1 的接口,如果是其他接口
https://cnodejs.org/topic/5378720ed6e2d16149fa16bd
alert
*/
function apiAxios(
method: string,
url: string,
params: null | string | object,
success: any,
failure: any,
unEncrypt: boolean = false // 是否不加密
) {
let contentTypeIsJson = false
if (params && typeof params != 'string') {
params = filterNull(params)
} else contentTypeIsJson = true
axios({
method: method,
url: url,
data: method === 'POST' || method === 'PUT' ? params : null,
params: method === 'GET' || method === 'DELETE' ? params : null,
withCredentials: true,
crossDomain: true,
unEncrypt,
transformRequest: [
function (data) {
if (contentTypeIsJson) return data
let ret = ''
for (let it in data) {
ret +=
encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&'
}
return ret
},
],
headers: {
'Content-Type': contentTypeIsJson
? 'application/json'
: 'application/x-www-form-urlencoded',
},
} as AxiosRequestConfig<any>)
.then(function (res) {
let response = res.data
if (response.code == 200) {
if (success) {
success(response)
}
} else {
if (failure) {
failure(response)
} else {
if (response.code == 2) {
//错误处理
setTimeout(() => {
location.reload()
}, 1000)
} else {
//错误处理
}
}
}
})
.catch(function (err) {
let res = err.response
console.error(res || err)
if (res) {
// 清楚所有的错误提示
clearTimeout(timeObj)
if (res.data.msg) {
//错误处理
} else {
//错误处理
}
return
}
})
}
let requestCount = 0
let timeObj: NodeJS.Timeout
// http request 拦截器
axios.interceptors.request.use((config) => {
requestCount++
if (requestCount == 1) {
timeObj = setTimeout(() => {
//加载中提示
}, 800)
}
if (
config.data &&
Object.prototype.toString.call(config.data) == '[object FormData]'
) {
config.headers!!['Content-Type'] = 'multipart/form-data;charset=utf-8'
config.transformRequest = [
function (data) {
return data
},
]
}
// 拦截配置有新的配置在这里新增函数处理然后合并config
let _config = handleUrl(config)
config = Object.assign(config, _config)
return config
})
// http response 拦截器
axios.interceptors.response.use((response) => {
requestCount--
if (requestCount === 0) {
setTimeout(() => {
// 关闭所有提示
}, 1500)
clearTimeout(timeObj)
}
return response
},error =>{
// let xhrErrL = { type: "XHRERR", data: error.response };
if (error.response) {
const { status, data } = error.response;
if (status === 422) {
alert(data)
}
}
})
// 返回在vue模板中的调用接口
export default {
get: function (
url: string,
params: string | object | null,
success: any,
failure: any
) {
return apiAxios('GET', url, params, success, failure)
},
post: function (
url: string,
params: string | object,
success: any,
failure: any
) {
return apiAxios('POST', url, params, success, failure)
},
put: function (
url: string,
params: string | object,
success: any,
failure: any
) {
return apiAxios('PUT', url, params, success, failure)
},
delete: function (
url: string,
params: string | object,
success: any,
failure: any
) {
return apiAxios('DELETE', url, params, success, failure)
},
// 不加密的post请求
unEncryptPost: function (
url: string,
params: string | object,
success: any,
failure: any,
) {
return apiAxios('POST', url, params, success, failure,true)
}
}

12
src/api/encryptUrl.ts Normal file
View File

@ -0,0 +1,12 @@
// 对指定请求链接进行加密
import { AxiosRequestConfig } from "axios";
import { useUserStore } from "@/store/user";
export const handleUrl = async (config: (AxiosRequestConfig & {unEncrypt?: boolean})) => {
const userStore = useUserStore()
if(userStore.getToken){
config.headers!!["Authorization"] = `Bearer ${userStore.getToken}`
}
return config;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><defs><linearGradient x1="0.5" y1="0" x2="0.5" y2="1" id="master_svg0_95_7905"><stop offset="0%" stop-color="#23518C" stop-opacity="0.20000000298023224"/><stop offset="100%" stop-color="#17439D" stop-opacity="0.10000000149011612"/></linearGradient><radialGradient cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" id="master_svg1_95_7906" gradientTransform="translate(272.25 210.25) rotate(90) scale(33.016276359558105 293.6848504078262)"><stop offset="0%" stop-color="#2773FF" stop-opacity="0.10000000149011612"/><stop offset="100%" stop-color="#2773FF" stop-opacity="0"/></radialGradient><radialGradient cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" id="master_svg2_95_7907" gradientTransform="translate(272.25 210.25) rotate(90) scale(7.3813605308532715 170.33950726831247)"><stop offset="0%" stop-color="#2773FF" stop-opacity="0.20000000298023224"/><stop offset="100%" stop-color="#2773FF" stop-opacity="0"/></radialGradient><radialGradient cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" id="master_svg3_95_7908" gradientTransform="translate(272.25 210.25) rotate(90) scale(3.5998570919036865 121.80235547062695)"><stop offset="0%" stop-color="#2773FF" stop-opacity="0.5"/><stop offset="100%" stop-color="#2773FF" stop-opacity="0"/></radialGradient><linearGradient x1="0.5" y1="0.05106060206890106" x2="0.5" y2="1" id="master_svg4_81_5443"><stop offset="0%" stop-color="#525DFF" stop-opacity="0"/><stop offset="100%" stop-color="#525DFF" stop-opacity="0.47999998927116394"/></linearGradient><radialGradient cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" id="master_svg5_13_2169" gradientTransform="translate(248.49790859222412 1.25) rotate(0) scale(5.699835777282715 Infinity)"><stop offset="0%" stop-color="#4FE4FF" stop-opacity="1"/><stop offset="100%" stop-color="#2773FF" stop-opacity="0"/></radialGradient><linearGradient x1="1" y1="0.5" x2="0" y2="0.5" id="master_svg6_13_2170"><stop offset="0%" stop-color="#36C8FF" stop-opacity="0"/><stop offset="53.635478019714355%" stop-color="#27AEFF" stop-opacity="1"/><stop offset="100%" stop-color="#1997FF" stop-opacity="0.20000000298023224"/></linearGradient></defs><g><g><path d="M0.25,0.25L544.25,0.25L544.25,210.25L0.25,210.25L0.25,0.25Z" fill-rule="evenodd" fill="#000000" fill-opacity="0.25"/><path d="M0.25,0.25L544.25,0.25L544.25,210.25L0.25,210.25L0.25,0.25Z" fill-rule="evenodd" fill="url(#master_svg0_95_7905)" fill-opacity="1"/><path d="M0.25,0.25L544.25,0.25L544.25,210.25L0.25,210.25L0.25,0.25Z" fill-rule="evenodd" fill="url(#master_svg1_95_7906)" fill-opacity="1"/><path d="M0.25,0.25L544.25,0.25L544.25,210.25L0.25,210.25L0.25,0.25Z" fill-rule="evenodd" fill="url(#master_svg2_95_7907)" fill-opacity="1"/><path d="M0.25,0.25L544.25,0.25L544.25,210.25L0.25,210.25L0.25,0.25Z" fill-rule="evenodd" fill="url(#master_svg3_95_7908)" fill-opacity="1"/><path d="M544.5,0L0,0L0,210.5L544.5,210.5L544.5,0ZM0.5,210L0.5,0.5L544,0.5L544,210L0.5,210Z" fill-rule="evenodd" fill="url(#master_svg4_81_5443)" fill-opacity="1"/></g><g style="opacity:0.44999998807907104;"><path d="M478.25,1.75L287.725,1.75L243.498,1.75L62.25,1.75L62.25,0.75L243.498,0.75L287.725,0.75L478.25,0.75L478.25,1.75Z" fill-rule="evenodd" fill="url(#master_svg5_13_2169)" fill-opacity="1"/><path d="M478.25,1.75L287.725,1.75L243.498,1.75L62.25,1.75L62.25,0.75L243.498,0.75L287.725,0.75L478.25,0.75L478.25,1.75Z" fill-rule="evenodd" fill="url(#master_svg6_13_2170)" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" viewBox="0 0 127 130.5"><defs><radialGradient cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" id="master_svg0_85_02406" gradientTransform="translate(62.63833460211754 260.6206922531128) rotate(90) scale(129.7585763875395 128.02340110725947)"><stop offset="0%" stop-color="#4EAAFF" stop-opacity="1"/><stop offset="36.93000078201294%" stop-color="#0066FF" stop-opacity="0.5"/><stop offset="100%" stop-color="#000000" stop-opacity="0"/></radialGradient></defs><g><g transform="matrix(1,0,0,-1,0,261)" style="opacity:0.10000000149011612;"><path d="M63.5,130.5C28.4311,130.5,0,188.7103,0,260.5C0,260.5,127,260.5,127,260.5C127,188.6801,98.5743,130.5,63.5,130.5C63.5,130.5,63.5,130.5,63.5,130.5Z" fill="url(#master_svg0_85_02406)" fill-opacity="1"/></g><g style="opacity:0.20000000298023224;"><path d="M127,102L1,102L1,101L127,101L127,102Z" fill-rule="evenodd" fill="#0867A2" fill-opacity="1"/></g><g><path d="M126,1L0,1L0,0L126,0L126,1Z" fill-rule="evenodd" fill="#0867A2" fill-opacity="1"/></g><g><path d="M12,1L0,1L0,0L12,0L12,1Z" fill-rule="evenodd" fill="#00AAFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

88
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
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 mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
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 setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
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')
}

View File

@ -0,0 +1,25 @@
<template>
<div v-html="content">
</div>
</template>
<script setup lang="ts">
defineProps({
content:{
type:String,
default: ""
}
})
</script>
<style scoped>
:deep(svg){
width:100%;
height:100%;
vertical-align: middle;
overflow: hidden;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,5 @@
<template>
</template>
<script lang="ts" setup></script>

View File

@ -0,0 +1,315 @@
<template>
<div class="chart-container" v-echartResize>
<div class="chart" ref="chartRef"></div>
<!-- 底座背景 -->
<div class="bg"></div>
<!-- 中间文字 -->
<div class="center-text">
<div class="value">{{ totalValue }}</div>
<div class="unit"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, reactive, computed } from 'vue'
import { getPie3D, getParametricEquation } from '@/composables/useEchart'
import * as echarts from 'echarts'
import 'echarts-gl' // 3d
import { fitChartSize } from '@/utils/echartData'
import { vEchartResize } from '@/directives/chart-resize'
const color = ['#5AF3B8', '#93DBFF']
const props = defineProps({
optionData: {
type: Array,
default: () => []
}
})
// refs
const chartRef = ref(null)
let statusChart = ref(null)
//
const totalValue = computed(() => {
return props.optionData.reduce((sum, item) => sum + item.value, 0)
})
const option = reactive({})
// label
const setLabel = () => {
props.optionData.forEach((item, index) => {
item.itemStyle = {
color: color[index]
}
item.label = {
show: true,
color: color[index],
formatter: '{b} {d}%', // 使join
rich: {
b: {
color: color[index],
lineHeight: 25,
align: 'left',
fontSize: 12,
},
d: {
color: color[index],
align: 'left',
fontSize: 12
}
}
}
item.labelLine = {
lineStyle: {
width: 1,
color: color[index]
}
}
})
}
//
const initChart = () => {
statusChart.value = echarts.init(chartRef.value)
// option, 3d,
Object.assign(option, getPie3D(props.optionData, 0.8, 240, 36, 13, 0.5))
statusChart.value.setOption(option)
// label线2d使labelLine3dsetOption
option.series.push({
name: '', //
backgroundColor: 'transparent',
type: 'pie',
label: {
opacity: 1,
fontSize: 12,
},
startAngle: -40, // [0, 360]
clockwise: false, // 3d
radius: ['20%', '50%'],
center: ['50%', '50%'],
data: props.optionData,
itemStyle: {
opacity: 0 //02d
}
})
statusChart.value.setOption(option)
bindListen(statusChart.value)
}
//
// optionNameoptionopiton
const bindListen = (myChart) => {
let selectedIndex = ''
let hoveredIndex = ''
//
myChart.on('click', (params) => {
// params.seriesIndex
if (params.seriesIndex === undefined || !option.series[params.seriesIndex] || !option.series[params.seriesIndex].pieStatus) {
return;
}
// option.series
const isSelected = !option.series[params.seriesIndex].pieStatus.selected
const isHovered = option.series[params.seriesIndex].pieStatus.hovered
const k = option.series[params.seriesIndex].pieStatus.k
const startRatio = option.series[params.seriesIndex].pieData.startRatio
const endRatio = option.series[params.seriesIndex].pieData.endRatio
// option
if (selectedIndex !== '' && selectedIndex !== params.seriesIndex &&
option.series[selectedIndex] && option.series[selectedIndex].pieStatus) {
option.series[selectedIndex].parametricEquation = getParametricEquation(
option.series[selectedIndex].pieData.startRatio,
option.series[selectedIndex].pieData.endRatio,
false,
false,
k,
option.series[selectedIndex].pieData.value
)
option.series[selectedIndex].pieStatus.selected = false
}
// / option
option.series[params.seriesIndex].parametricEquation = getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
option.series[params.seriesIndex].pieData.value
)
option.series[params.seriesIndex].pieStatus.selected = isSelected
// seriesIndex
selectedIndex = isSelected ? params.seriesIndex : null
// 使 option
myChart.setOption(option)
})
// mouseover
myChart.on('mouseover', (params) => {
//
let isSelected
let isHovered
let startRatio
let endRatio
let k
// mouseover
if (hoveredIndex === params.seriesIndex) {
//
} else {
// option
if (hoveredIndex !== '' && option.series[hoveredIndex] && option.series[hoveredIndex].pieStatus) {
// option.series false
isSelected = option.series[hoveredIndex].pieStatus.selected
isHovered = false
startRatio = option.series[hoveredIndex].pieData.startRatio
endRatio = option.series[hoveredIndex].pieData.endRatio
k = option.series[hoveredIndex].pieStatus.k
// option
option.series[hoveredIndex].parametricEquation = getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
option.series[hoveredIndex].pieData.value
)
option.series[hoveredIndex].pieStatus.hovered = isHovered
// seriesIndex
hoveredIndex = ''
}
// mouseover option
if (
params.seriesName !== 'mouseoutSeries' &&
params.seriesName !== 'pie2d' &&
params.seriesIndex !== undefined &&
option.series[params.seriesIndex] &&
option.series[params.seriesIndex].pieStatus
) {
// option.series true
isSelected = option.series[params.seriesIndex].pieStatus.selected
isHovered = true
startRatio = option.series[params.seriesIndex].pieData.startRatio
endRatio = option.series[params.seriesIndex].pieData.endRatio
k = option.series[params.seriesIndex].pieStatus.k
// option
option.series[params.seriesIndex].parametricEquation = getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
option.series[params.seriesIndex].pieData.value + 60
)
option.series[params.seriesIndex].pieStatus.hovered = isHovered
// seriesIndex
hoveredIndex = params.seriesIndex
}
// 使 option
myChart.setOption(option)
}
})
// bug
myChart.on('globalout', () => {
//
let isSelected
let isHovered
let startRatio
let endRatio
let k
if (hoveredIndex !== '' && option.series[hoveredIndex] && option.series[hoveredIndex].pieStatus) {
// option.series true
isSelected = option.series[hoveredIndex].pieStatus.selected
isHovered = false
k = option.series[hoveredIndex].pieStatus.k
startRatio = option.series[hoveredIndex].pieData.startRatio
endRatio = option.series[hoveredIndex].pieData.endRatio
// option
option.series[hoveredIndex].parametricEquation = getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
option.series[hoveredIndex].pieData.value
)
option.series[hoveredIndex].pieStatus.hovered = isHovered
// seriesIndex
hoveredIndex = ''
}
// 使 option
myChart.setOption(option)
})
}
//
// const changeSize = () => {
// statusChart.value?.resize()
// }
//
onMounted(() => {
setLabel()
initChart()
//
// window.addEventListener('resize', changeSize)
})
// onBeforeUnmount(() => {
// window.removeEventListener('resize', changeSize)
// })
</script>
<style lang='scss' scoped>
.chart-container {
position: relative;
width: 100%;
height: 100%;
.chart,
.bg {
width: 100%;
height: 100%;
}
.bg {
position: absolute;
bottom: 50px;
left: 50%;
z-index: -1;
width: 180px;
height: 73px;
background: no-repeat center;
background-image: url('/images/particles-bg.png');
background-size: 100% 100%;
transform: translateX(-50%);
}
.center-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
text-align: center;
.value {
display: inline-block;
font-size: 24px;
font-weight: 500;
color: #8FC8FF;
}
.unit {
display: inline-block;
font-size: 18px;
font-weight: 500;
color: #FFFFFF;
}
}
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div
class="progress-container"
:style="{
'--progress-width': `${percentage}%`,
'--progress-start-color': startColor,
'--progress-end-color': endColor || startColor,
'--progress-bg-color': backgroundColor,
'--progress-height': height
}"
>
<div class="progress-bar">
<div class="progress-inner">
<div class="progress-text" v-if="showText">
{{ percentage }}%
</div>
</div>
</div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ProgressProps {
percentage: number
startColor?: string
endColor?: string
backgroundColor?: string
showText?: boolean
striped?: boolean
animated?: boolean
height?: string
}
const props = withDefaults(defineProps<ProgressProps>(), {
percentage: 0,
startColor: 'rgba(41,241,250,0)',
endColor: '#29F1FA',
backgroundColor: '#0e2e65',
showText: false,
striped: false,
animated: false,
height: '30px'
})
// 0-100
const percentage = computed(() => {
if (props.percentage < 0) return 0
if (props.percentage > 100) return 100
return props.percentage
})
</script>
<style lang="scss" scoped>
.progress-container {
--progress-width: 0%;
--progress-start-color: #44F1FF;
--progress-end-color: #44F1FF;
--progress-bg-color: #031743;
width: 100%;
position: relative;
}
.progress-bar {
width: 100%;
height: var(--progress-height);
background-color: var(--progress-bg-color);
border-radius: 15px;
overflow: hidden;
position: relative;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-inner {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: var(--progress-width);
background: linear-gradient(90deg, var(--progress-start-color), var(--progress-end-color));
border-radius: 15px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 10px 1px var(--progress-start-color);
//
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: v-bind('props.striped ? "linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent)" : "none"');
background-size: 40px 40px;
opacity: 0.6;
z-index: 1;
animation: v-bind('props.animated && props.striped ? "progress-animation 2s linear infinite" : "none"');
}
}
.progress-text {
color: #fff;
font-size: 14px;
position: relative;
z-index: 2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
@keyframes progress-animation {
0% {
background-position: 40px 0;
}
100% {
background-position: 0 0;
}
}
</style>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
export interface SvgIconProps {
name: string
}
const props = defineProps<SvgIconProps>()
const styles = useCssModule()
const attrs = useAttrs()
const iconName = computed(() => `#icon-${props.name}`);
const svgClass = computed(() => {
const className = [styles['svg-icon']]
if (props.name) className.push(`icon-${props.name}`)
return className
})
</script>
<template>
<svg :class="svgClass" v-bind="attrs">
<use :xlink:href="iconName"></use>
</svg>
</template>
<style module>
.svg-icon {
width: 1em;
height: 1em;
fill: currentColor;
vertical-align: middle;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,10 @@
import SvgIcon from './SvgIcon.vue'
import type {SvgIconProps} from './SvgIcon.vue'
export {
SvgIcon
}
export type {
SvgIconProps
}

View File

@ -0,0 +1,278 @@
<template>
<div class="digital-watch" :style="{
'--size-scale': sizeScale,
'--color-led': ledColor,
'--color-led-off': ledOffColor,
'--color-background': backgroundColor,
'--color-glow': glowColor
}">
<div class="time-container">
<div class="digit-group">
<div class="seven-segment">
<div :class="getSegmentClass(hours[0], 0)" class="segment-a"></div>
<div :class="getSegmentClass(hours[0], 1)" class="segment-b"></div>
<div :class="getSegmentClass(hours[0], 2)" class="segment-c"></div>
<div :class="getSegmentClass(hours[0], 3)" class="segment-d"></div>
<div :class="getSegmentClass(hours[0], 4)" class="segment-e"></div>
<div :class="getSegmentClass(hours[0], 5)" class="segment-f"></div>
<div :class="getSegmentClass(hours[0], 6)" class="segment-g"></div>
</div>
<div class="seven-segment">
<div :class="getSegmentClass(hours[1], 0)" class="segment-a"></div>
<div :class="getSegmentClass(hours[1], 1)" class="segment-b"></div>
<div :class="getSegmentClass(hours[1], 2)" class="segment-c"></div>
<div :class="getSegmentClass(hours[1], 3)" class="segment-d"></div>
<div :class="getSegmentClass(hours[1], 4)" class="segment-e"></div>
<div :class="getSegmentClass(hours[1], 5)" class="segment-f"></div>
<div :class="getSegmentClass(hours[1], 6)" class="segment-g"></div>
</div>
</div>
<div class="separator">
<div class="dot"></div>
<div class="dot"></div>
</div>
<div class="digit-group">
<div class="seven-segment">
<div :class="getSegmentClass(minutes[0], 0)" class="segment-a"></div>
<div :class="getSegmentClass(minutes[0], 1)" class="segment-b"></div>
<div :class="getSegmentClass(minutes[0], 2)" class="segment-c"></div>
<div :class="getSegmentClass(minutes[0], 3)" class="segment-d"></div>
<div :class="getSegmentClass(minutes[0], 4)" class="segment-e"></div>
<div :class="getSegmentClass(minutes[0], 5)" class="segment-f"></div>
<div :class="getSegmentClass(minutes[0], 6)" class="segment-g"></div>
</div>
<div class="seven-segment">
<div :class="getSegmentClass(minutes[1], 0)" class="segment-a"></div>
<div :class="getSegmentClass(minutes[1], 1)" class="segment-b"></div>
<div :class="getSegmentClass(minutes[1], 2)" class="segment-c"></div>
<div :class="getSegmentClass(minutes[1], 3)" class="segment-d"></div>
<div :class="getSegmentClass(minutes[1], 4)" class="segment-e"></div>
<div :class="getSegmentClass(minutes[1], 5)" class="segment-f"></div>
<div :class="getSegmentClass(minutes[1], 6)" class="segment-g"></div>
</div>
</div>
<div class="separator">
<div class="dot"></div>
<div class="dot"></div>
</div>
<div class="digit-group">
<div class="seven-segment">
<div :class="getSegmentClass(seconds[0], 0)" class="segment-a"></div>
<div :class="getSegmentClass(seconds[0], 1)" class="segment-b"></div>
<div :class="getSegmentClass(seconds[0], 2)" class="segment-c"></div>
<div :class="getSegmentClass(seconds[0], 3)" class="segment-d"></div>
<div :class="getSegmentClass(seconds[0], 4)" class="segment-e"></div>
<div :class="getSegmentClass(seconds[0], 5)" class="segment-f"></div>
<div :class="getSegmentClass(seconds[0], 6)" class="segment-g"></div>
</div>
<div class="seven-segment">
<div :class="getSegmentClass(seconds[1], 0)" class="segment-a"></div>
<div :class="getSegmentClass(seconds[1], 1)" class="segment-b"></div>
<div :class="getSegmentClass(seconds[1], 2)" class="segment-c"></div>
<div :class="getSegmentClass(seconds[1], 3)" class="segment-d"></div>
<div :class="getSegmentClass(seconds[1], 4)" class="segment-e"></div>
<div :class="getSegmentClass(seconds[1], 5)" class="segment-f"></div>
<div :class="getSegmentClass(seconds[1], 6)" class="segment-g"></div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useDate } from '@/composables/useDate';
//
const sizeScale = ref(1);
//
const ledColor = ref('#44F1FF'); // LED
const ledOffColor = ref('#031743'); // LED
const backgroundColor = ref('#031743'); //
const glowColor = ref('#44F1FF'); //
// [a, b, c, d, e, f, g]
const digitSegments: { [key: string]: boolean[] } = {
'0': [true, true, true, true, true, true, false], // 0: a,b,c,d,e,f
'1': [false, true, true, false, false, false, false], // 1: b,c
'2': [true, true, false, true, true, false, true], // 2: a,b,d,e,g
'3': [true, true, true, true, false, false, true], // 3: a,b,c,d,g
'4': [false, true, true, false, false, true, true], // 4: b,c,f,g
'5': [true, false, true, true, false, true, true], // 5: a,c,d,f,g
'6': [true, false, true, true, true, true, true], // 6: a,c,d,e,f,g
'7': [true, true, true, false, false, false, false], // 7: a,b,c
'8': [true, true, true, true, true, true, true], // 8:
'9': [true, true, true, true, false, true, true], // 9: a,b,c,d,f,g
' ': [false, false, false, false, false, false, false], // :
};
const getSegmentClass = (digit: string, index: number) => {
const segments = digitSegments[digit] || digitSegments[' '];
return {
'segment': true,
'segment-on': segments[index]
};
};
let timer: number | null = null;
let { hours, minutes, seconds, updateTime } = useDate()
onMounted(() => {
updateTime();
timer = window.setInterval(updateTime, 500);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<style lang="scss" scoped>
.digital-watch {
// CSS使
--size-scale: 1;
--base-segment-width: 5px;
--base-segment-height: 2px;
--base-segment-vertical-width: 2px;
--base-segment-vertical-height: 5px;
--base-digit-width: 9px;
--base-digit-height: 14px;
--base-digit-gap: 5px;
--base-dot-size: 2px;
--base-separator-margin: 2px;
//
--color-led: #44F1FF; // LED ()
--color-led-off: #031743; // LED
--color-background: #031743; //
--color-glow: #031743; //
//
--segment-width: calc(var(--base-segment-width) * var(--size-scale));
--segment-height: calc(var(--base-segment-height) * var(--size-scale));
--segment-vertical-width: calc(var(--base-segment-vertical-width) * var(--size-scale));
--segment-vertical-height: calc(var(--base-segment-vertical-height) * var(--size-scale));
--segment-spacing: calc(2px * var(--size-scale));
--digit-width: calc(var(--base-digit-width) * var(--size-scale));
--digit-height: calc(var(--base-digit-height) * var(--size-scale));
--digit-gap: calc(var(--base-digit-gap) * var(--size-scale));
--dot-size: calc(var(--base-dot-size) * var(--size-scale));
--separator-margin: calc(var(--base-separator-margin) * var(--size-scale));
--border-radius: calc(3px * var(--size-scale));
--glow-radius: calc(5px * var(--size-scale));
--clock-padding: calc(20px * var(--size-scale));
--clock-border-radius: calc(10px * var(--size-scale));
background-color: var(--color-background);
border-radius: var(--clock-border-radius);
display: flex;
flex-direction: column;
align-items: center;
width: fit-content;
}
.time-container {
display: flex;
align-items: center;
}
.digit-group {
display: flex;
gap: var(--digit-gap);
}
.separator {
display: flex;
flex-direction: column;
justify-content: space-around;
height: var(--digit-height);
margin: 0 var(--separator-margin);
.dot {
width: var(--dot-size);
height: var(--dot-size);
background-color: var(--color-led);
border-radius: 50%;
}
}
.seven-segment {
position: relative;
width: var(--digit-width);
height: var(--digit-height);
}
.segment {
position: absolute;
background-color: var(--color-led-off);
border-radius: var(--segment-spacing);
&-on {
background-color: var(--color-led);
// box-shadow: 0 0 var(--glow-radius) var(--color-glow);
}
}
.segment-a {
width: var(--segment-width);
height: var(--segment-height);
top: 0;
left: calc((var(--digit-width) - var(--segment-width)) / 2);
border-radius: var(--border-radius);
}
.segment-b {
width: var(--segment-vertical-width);
height: var(--segment-vertical-height);
top: var(--segment-spacing);
right: 0;
border-radius: var(--border-radius);
}
.segment-c {
width: var(--segment-vertical-width);
height: var(--segment-vertical-height);
bottom: var(--segment-spacing);
right: 0;
border-radius: var(--border-radius);
}
.segment-d {
width: var(--segment-width);
height: var(--segment-height);
bottom: 0;
left: calc((var(--digit-width) - var(--segment-width)) / 2);
border-radius: var(--border-radius);
}
.segment-e {
width: var(--segment-vertical-width);
height: var(--segment-vertical-height);
bottom: var(--segment-spacing);
left: 0;
border-radius: var(--border-radius);
}
.segment-f {
width: var(--segment-vertical-width);
height: var(--segment-vertical-height);
top: var(--segment-spacing);
left: 0;
border-radius: var(--border-radius);
}
.segment-g {
width: var(--segment-width);
height: var(--segment-height);
top: calc(var(--digit-height) / 2 - var(--segment-height) / 2);
left: calc((var(--digit-width) - var(--segment-width)) / 2);
border-radius: var(--border-radius);
}
</style>

View File

@ -0,0 +1,40 @@
export const useDate = () => {
const hours = ref("00");
const minutes = ref("00");
const seconds = ref("00");
const year = ref("");
const month = ref("");
const day = ref("");
const weekday = ref(""); // 新增星期几变量
let timer: number | null = null;
const updateTime = () => {
const now = new Date();
hours.value = now.getHours().toString().padStart(2, "0");
minutes.value = now.getMinutes().toString().padStart(2, "0");
seconds.value = now.getSeconds().toString().padStart(2, "0");
year.value = now.getFullYear().toString();
month.value = (now.getMonth() + 1).toString().padStart(2, "0");
day.value = now.getDate().toString().padStart(2, "0");
// 获取星期几并转换为中文格式
const weekdayNumber = now.getDay(); // 0是星期日1-6是星期一至星期六
const weekdayNames = ["日", "一", "二", "三", "四", "五", "六"];
weekday.value = "星期" + weekdayNames[weekdayNumber];
};
return {
hours,
minutes,
seconds,
year,
month,
day,
weekday, // 返回星期几
timer,
updateTime,
};
};

View File

@ -0,0 +1,32 @@
// 自定义 Hook
export const useScreenAuto = (designWidth = 1920, designHeight = 1080) => {
const screenEl = useTemplateRef<HTMLDivElement>("screen")
const handleScreenAuto = () => {
const clientWidth = document.documentElement.clientWidth
const clientHeight = document.documentElement.clientHeight
const scale = clientWidth / clientHeight < designWidth / designHeight
? clientWidth / designWidth
: clientHeight / designHeight
if (screenEl.value) {
screenEl.value.style.transform = `scale(${scale}) translate(-50%, -50%)`
screenEl.value.classList.add('screen')
}
}
// 生命周期
onMounted(() => {
handleScreenAuto()
window.addEventListener('resize', handleScreenAuto)
})
onUnmounted(() => {
window.removeEventListener('resize', handleScreenAuto)
})
return {
handleScreenAuto
}
}

View File

@ -0,0 +1,214 @@
// 定义二进制数字类型
type BinaryDigits = Record<string, boolean[]>;
type CanvasRef = HTMLCanvasElement | null;
/**
* Hook
* @returns {Object} - canvas
*/
export const useDigitalWatch = () => {
const canvasRef = ref<CanvasRef>(null)
let animationTimer: number | null = null
// 二进制表示数字的配置
const binary: BinaryDigits = {
"0": [true, true, true, false, true, true, true],
"1": [false, false, true, false, false, true, false],
"2": [true, false, true, true, true, false, true],
"3": [true, false, true, true, false, true, true],
"4": [false, true, true, true, false, true, false],
"5": [true, true, false, true, false, true, true],
"6": [true, true, false, true, true, true, true],
"7": [true, true, true, false, false, true, false],
"8": [true, true, true, true, true, true, true],
"9": [true, true, true, true, false, true, false]
}
// 样式配置
let on = "#44F1FF" // 激活颜色
let off = "#031743" // 未激活颜色
// 缩放比例
let scale = 1
// 工具函数 - 格式化数字为两位(补零)
const formatZero = (n: number): string => {
return n >= 10 ? n.toString() : `0${n}`
}
// 渲染数字的一个区段
const renderDigit = (ctx: CanvasRenderingContext2D, x = 0, y = 0, binaryPattern: boolean[]) => {
const s = scale
// top
ctx.beginPath()
ctx.moveTo(x + 20 * s, y + 5 * s)
ctx.lineTo(x + 35 * s, y)
ctx.lineTo(x + 105 * s, y)
ctx.lineTo(x + 120 * s, y + 5 * s)
ctx.lineTo(x + 105 * s, y + 20 * s)
ctx.lineTo(x + 35 * s, y + 20 * s)
ctx.lineTo(x + 20 * s, y + 5 * s)
ctx.fillStyle = binaryPattern[0] ? on : off
ctx.fill()
// top-left
ctx.beginPath()
ctx.moveTo(x + 15 * s, y + 10 * s)
ctx.lineTo(x + 10 * s, y + 25 * s)
ctx.lineTo(x + 10 * s, y + 95 * s)
ctx.lineTo(x + 15 * s, y + 110 * s)
ctx.lineTo(x + 30 * s, y + 95 * s)
ctx.lineTo(x + 30 * s, y + 25 * s)
ctx.fillStyle = binaryPattern[1] ? on : off
ctx.fill()
// top-right
ctx.beginPath()
ctx.moveTo(x + 125 * s, y + 10 * s)
ctx.lineTo(x + 130 * s, y + 25 * s)
ctx.lineTo(x + 130 * s, y + 95 * s)
ctx.lineTo(x + 125 * s, y + 110 * s)
ctx.lineTo(x + 110 * s, y + 95 * s)
ctx.lineTo(x + 110 * s, y + 25 * s)
ctx.fillStyle = binaryPattern[2] ? on : off
ctx.fill()
// middle
ctx.beginPath()
ctx.moveTo(x + 20 * s, y + 115 * s)
ctx.lineTo(x + 35 * s, y + 100 * s)
ctx.lineTo(x + 105 * s, y + 100 * s)
ctx.lineTo(x + 120 * s, y + 115 * s)
ctx.lineTo(x + 105 * s, y + 130 * s)
ctx.lineTo(x + 35 * s, y + 130 * s)
ctx.fillStyle = binaryPattern[3] ? on : off
ctx.fill()
// bottom-left
ctx.beginPath()
ctx.moveTo(x + 15 * s, y + 120 * s)
ctx.lineTo(x + 10 * s, y + 135 * s)
ctx.lineTo(x + 10 * s, y + 205 * s)
ctx.lineTo(x + 15 * s, y + 220 * s)
ctx.lineTo(x + 30 * s, y + 205 * s)
ctx.lineTo(x + 30 * s, y + 135 * s)
ctx.fillStyle = binaryPattern[4] ? on : off
ctx.fill()
// bottom-right
ctx.beginPath()
ctx.moveTo(x + 125 * s, y + 120 * s)
ctx.lineTo(x + 130 * s, y + 135 * s)
ctx.lineTo(x + 130 * s, y + 205 * s)
ctx.lineTo(x + 125 * s, y + 220 * s)
ctx.lineTo(x + 110 * s, y + 205 * s)
ctx.lineTo(x + 110 * s, y + 135 * s)
ctx.fillStyle = binaryPattern[5] ? on : off
ctx.fill()
// bottom
ctx.beginPath()
ctx.moveTo(x + 20 * s, y + 225 * s)
ctx.lineTo(x + 35 * s, y + 210 * s)
ctx.lineTo(x + 105 * s, y + 210 * s)
ctx.lineTo(x + 120 * s, y + 225 * s)
ctx.lineTo(x + 105 * s, y + 235 * s)
ctx.lineTo(x + 35 * s, y + 235 * s)
ctx.lineTo(x + 20 * s, y + 225 * s)
ctx.fillStyle = binaryPattern[6] ? on : off
ctx.fill()
}
// 计算合适的缩放比例
const calculateScale = (width: number, height: number): number => {
// 原始设计下整个表宽约为940高约为240
const originalWidth = 940
const originalHeight = 240
const scaleX = width / originalWidth
const scaleY = height / originalHeight
// 取较小的缩放比例,确保完全显示
return Math.min(scaleX, scaleY) * 0.9 // 稍微缩小一点,留出边距
}
// 绘制时间
const drawTime = () => {
if (!canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 获取当前时间
const date = new Date()
// 格式化时间数字
const [hour_ten_digit, hour_one_digit] = formatZero(date.getHours()).split("")
const [minute_ten_digit, minute_one_digit] = formatZero(date.getMinutes()).split("")
const [second_ten_digit, second_one_digit] = formatZero(date.getSeconds()).split("")
// 计算中心位置,使表居中
const centerY = (canvas.height - 240 * scale) / 2 + 20 * scale
const startX = (canvas.width - 940 * scale) / 2 + 20 * scale
// 使用指定的间距渲染每个数字
const spacing = 140 * scale
// 渲染每个数字
renderDigit(ctx, startX, centerY, binary[hour_ten_digit])
renderDigit(ctx, startX + spacing, centerY, binary[hour_one_digit])
renderDigit(ctx, startX + spacing * 2.5, centerY, binary[minute_ten_digit])
renderDigit(ctx, startX + spacing * 3.5, centerY, binary[minute_one_digit])
renderDigit(ctx, startX + spacing * 5, centerY, binary[second_ten_digit])
renderDigit(ctx, startX + spacing * 6, centerY, binary[second_one_digit])
}
// 初始化函数
const initWatch = (width = 1000, height = 300, interval = 500) => {
if (!canvasRef.value) return
const canvas = canvasRef.value
canvas.width = width
canvas.height = height
// 计算适合的缩放比例
scale = calculateScale(width, height)
// 启动定时器更新时间显示
drawTime() // 立即首次绘制
animationTimer = window.setInterval(() => {
drawTime()
}, interval)
}
// 停止计时器
const stopWatch = () => {
if (animationTimer) {
clearInterval(animationTimer)
animationTimer = null
}
}
// 自动清理
onBeforeUnmount(() => {
stopWatch()
})
// 返回需要暴露的内容
return {
canvasRef,
initWatch,
stopWatch,
drawTime,
// 自定义选项
setColors: (onColor: string, offColor: string) => {
on = onColor
off = offColor
}
}
}

View File

@ -0,0 +1,365 @@
/**
* 3d
* @param pieData
* @param internalDiameterRatio:
* @param distance
* @param alpha
* @param pieHeight
* @param opacity
*/
// 定义饼图数据项接口
interface PieDataItem {
name: string;
value: number;
itemStyle?: {
color?: string;
opacity?: number;
};
startRatio?: number;
endRatio?: number;
}
// 定义图例数据项接口
interface LegendItem {
name: string;
value: number;
}
// 参数方程接口
interface ParametricEquation {
u: {
min: number;
max: number;
step: number;
};
v: {
min: number;
max: number;
step: number;
};
x: (u: number, v: number) => number;
y: (u: number, v: number) => number;
z: (u: number, v: number) => number;
}
// ECharts配置相关类型
interface EChartsOption {
series: SeriesItem[];
[key: string]: any;
}
// 定义图表系列项接口
interface SeriesItem {
name: string;
type: string;
parametric: boolean;
wireframe: {
show: boolean;
};
pieData: PieDataItem;
pieStatus: {
selected: boolean;
hovered: boolean;
k: number;
};
center: string[];
itemStyle?: {
color?: string | number;
opacity?: number;
};
parametricEquation?: ParametricEquation;
}
const getPie3D = (
pieData: PieDataItem[],
internalDiameterRatio: number,
distance: number,
alpha: number,
pieHeight: number,
opacity: number = 1
): EChartsOption => {
const series: SeriesItem[] = []
let sumValue: number = 0
let startValue: number = 0
let endValue: number = 0
let legendData: LegendItem[] = []
let legendBfb: LegendItem[] = []
const k: number = 1 - internalDiameterRatio
pieData.sort((a: PieDataItem, b: PieDataItem) => {
return b.value - a.value
})
// 为每一个饼图数据,生成一个 series-surface 配置
for (let i = 0; i < pieData.length; i++) {
sumValue += pieData[i].value
const seriesItem: SeriesItem = {
name:
typeof pieData[i].name === 'undefined'
? `series${i}`
: pieData[i].name,
type: 'surface',
parametric: true,
wireframe: {
show: false
},
pieData: pieData[i],
pieStatus: {
selected: false,
hovered: false,
k: k
},
center: ['10%', '50%']
}
if (typeof pieData[i].itemStyle !== 'undefined') {
const itemStyle: { color?: string | number; opacity?: number } = {}
itemStyle.color =
typeof pieData[i].itemStyle?.color !== 'undefined'
? pieData[i].itemStyle?.color
: opacity
itemStyle.opacity =
typeof pieData[i].itemStyle?.opacity !== 'undefined'
? pieData[i].itemStyle?.opacity
: opacity
seriesItem.itemStyle = itemStyle
}
series.push(seriesItem)
}
// 使用上一次遍历时,计算出的数据和 sumValue调用 getParametricEquation 函数,
// 向每个 series-surface 传入不同的参数方程 series-surface.parametricEquation也就是实现每一个扇形。
legendData = []
legendBfb = []
for (let i = 0; i < series.length; i++) {
endValue = startValue + series[i].pieData.value
series[i].pieData.startRatio = startValue / sumValue
series[i].pieData.endRatio = endValue / sumValue
series[i].parametricEquation = getParametricEquation(
series[i].pieData.startRatio || 0,
series[i].pieData.endRatio || 0,
false,
false,
k,
series[i].pieData.value
)
startValue = endValue
const bfb = fomatFloat(series[i].pieData.value / sumValue, 4)
if (typeof bfb === 'string') {
legendData.push({
name: series[i].name,
value: parseFloat(bfb)
})
legendBfb.push({
name: series[i].name,
value: parseFloat(bfb)
})
}
}
const boxHeight = getHeight3D(series, pieHeight) // 通过pieHeight设定3d饼/环的高度单位是px
// 准备待返回的配置项,把准备好的 legendData、series 传入。
const option: EChartsOption = {
// legend: {
// show: false,
// data: legendData,
// orient: 'vertical',
// left: 10,
// top: 10,
// itemGap: 10,
// textStyle: {
// color: '#A1E2FF'
// },
// icon: 'circle',
// formatter: function (param: string) {
// const item = legendBfb.filter(item => item.name === param)[0]
// if (item) {
// const bfs = fomatFloat(item.value * 100, 2)
// if (typeof bfs === 'string') {
// return `${item.name} ${bfs}%`
// }
// }
// return ''
// }
// },
labelLine: {
show: true,
lineStyle: {
color: '#fff'
}
},
label: {
show: true,
position: 'outside',
formatter: '{b} \n{c} {d}%'
},
tooltip: {
backgroundColor: '#033b77',
borderColor: '#21f2c4',
textStyle: {
color: '#fff',
fontSize: 13
},
formatter: (_params: any) => {
// if (
// params.seriesName !== 'mouseoutSeries' &&
// params.seriesName !== 'pie2d'
// ) {
// // 确保params.seriesIndex存在且在option.series范围内
// if (params.seriesIndex !== undefined && option.series && option.series[params.seriesIndex] && option.series[params.seriesIndex].pieData) {
// const bfb = (
// ((option.series[params.seriesIndex].pieData.endRatio || 0) -
// (option.series[params.seriesIndex].pieData.startRatio || 0)) *
// 100
// ).toFixed(2)
// return (
// `${params.seriesName}<br/>` +
// `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${params.color};"></span>` +
// `${bfb}%`
// )
// }
// // 如果索引无效,返回简单的系列名称
// return `${params.seriesName}`
// }
return ''
}
},
xAxis3D: {
min: -1,
max: 1
},
yAxis3D: {
min: -1,
max: 1
},
zAxis3D: {
min: -1,
max: 1
},
grid3D: {
show: false,
boxHeight: boxHeight, // 圆环的高度
viewControl: {
// 3d效果可以放大、旋转等请自己去查看官方配置
alpha, // 角度
distance, // 调整视角到主体的距离类似调整zoom
rotateSensitivity: 0, // 设置为0无法旋转
zoomSensitivity: 0, // 设置为0无法缩放
panSensitivity: 0, // 设置为0无法平移
autoRotate: false // 自动旋转
}
},
series: series
}
return option
}
/**
* series-surface.parametricEquation
*/
const getParametricEquation = (
startRatio: number,
endRatio: number,
isSelected: boolean,
isHovered: boolean,
k: number,
h: number
): ParametricEquation => {
// 计算
const midRatio = (startRatio + endRatio) / 2
const startRadian = startRatio * Math.PI * 2
const endRadian = endRatio * Math.PI * 2
const midRadian = midRatio * Math.PI * 2
// 如果只有一个扇形,则不实现选中效果。
if (startRatio === 0 && endRatio === 1) {
isSelected = false
}
// 通过扇形内径/外径的值,换算出辅助参数 k默认值 1/3
k = typeof k !== 'undefined' ? k : 1 / 3
// 计算选中效果分别在 x 轴、y 轴方向上的位移(未选中,则位移均为 0
const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0
const offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0
// 计算高亮效果的放大比例(未高亮,则比例为 1
const hoverRate = isHovered ? 1.05 : 1
// 返回曲面参数方程
return {
u: {
min: -Math.PI,
max: Math.PI * 3,
step: Math.PI / 32
},
v: {
min: 0,
max: Math.PI * 2,
step: Math.PI / 20
},
x: function (u: number, v: number): number {
if (u < startRadian) {
return (
offsetX +
Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate
)
}
if (u > endRadian) {
return (
offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate
)
}
return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate
},
y: function (u: number, v: number): number {
if (u < startRadian) {
return (
offsetY +
Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate
)
}
if (u > endRadian) {
return (
offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate
)
}
return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate
},
z: function (u: number, v: number): number {
if (u < -Math.PI * 0.5) {
return Math.sin(u)
}
if (u > Math.PI * 2.5) {
return Math.sin(u) * h * 0.1
}
return Math.sin(v) > 0 ? 1 * h * 0.1 : -1
}
}
}
/**
* 3d
*/
const getHeight3D = (series: SeriesItem[], height: number): number => {
series.sort((a: SeriesItem, b: SeriesItem) => {
return b.pieData.value - a.pieData.value
})
return (height * 25) / series[0].pieData.value
}
/**
*
*/
const fomatFloat = (num: number, n: number): string | boolean => {
let f = parseFloat(num.toString())
if (isNaN(f)) {
return false
}
f = Math.round(num * Math.pow(10, n)) / Math.pow(10, n) // n 幂
let s = f.toString()
let rs = s.indexOf('.')
// 判定如果是整数增加小数点再补0
if (rs < 0) {
rs = s.length
s += '.'
}
while (s.length <= rs + n) {
s += '0'
}
return s
}
export { getPie3D, getParametricEquation }

View File

@ -0,0 +1,20 @@
import * as ECharts from "echarts";
const HANDLER = "_vue_resize_handler";
export const vEchartResize = {
mounted(el:any,binding:any){
el[HANDLER] = binding.value
? binding.value
: () => {
let chart = ECharts.getInstanceByDom(el);
if (!chart) {
return;
}
chart.resize();
};
window.addEventListener("resize",el[HANDLER]);
},
beforeUnmount(el:any){
window.removeEventListener("resize",el[HANDLER]);
}
}

18
src/main.ts Normal file
View File

@ -0,0 +1,18 @@
import { createApp } from 'vue'
import '@unocss/reset/tailwind-compat.css'
// import './style.css'
import 'uno.css';
import App from './App.vue'
// Pinia 持久化
import store from '@/store/index'
//导入路由
import router from '@/router/index'
const app = createApp(App)
// 注册已经加上了持久化的pinia
app.use(store)
app.use(router)
app.mount('#app')

49
src/router/index.ts Normal file
View File

@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from "vue-router";
import { publicRoutes } from "./publicRoutes";
import { privateRoutes } from "./privateRoutes";
function getRoutes() {
const routes = [
// 私有路由,请在这里添加
...privateRoutes,
// 公共路由
...publicRoutes,
];
/**
* routes
*/
return routes;
}
const router = createRouter({
history: createWebHistory(),
routes: getRoutes(),
});
// 全局前置守卫,这边可以对身份进行验证
router.beforeEach((to, _from, next) => {
let userRole = "admin";
// 如果目标路由没有角色限制
if (!to.meta.role) {
next();
}
// 判断当前用户角色是否在目标路由的允许角色列表中
if ((to.meta.role as string[]).includes(userRole)) {
// 如果角色匹配,允许进入目标路由
next();
} else {
// 如果角色不匹配,跳转到 unauthorized 页面
next({ path: "/unauthorized" });
}
});
// 监听路由变化,动态设置网页标题
router.afterEach((to) => {
if (to.meta.title) {
document.title = to.meta.title as string;
}
});
export default router;

13
src/router/keepAlive.ts Normal file
View File

@ -0,0 +1,13 @@
import { ComponentInternalInstance, ref } from 'vue'
export const excludes = ref<string[]>([])
export function removeKeepAliveCache(instance: ComponentInternalInstance) {
if (!excludes.value.includes(instance.type.name!)) {
excludes.value.push(instance.type.name!)
}
}
export function resetKeepAliveCache(instance: ComponentInternalInstance) {
excludes.value = excludes.value.filter((item) => item !== instance.type.name)
}

View File

@ -0,0 +1 @@
export const privateRoutes = [];

View File

@ -0,0 +1,19 @@
export const publicRoutes = [
{
path:"/unauthorized",
name:'unauthorized',
meta:{
title:'unauthorized'
},
component:()=>import('../views/unauthorized.vue')
},
{
path:"/",
name:'home',
meta:{
title:'home'
},
component:()=>import('../views/home.vue')
}
]

13
src/store/index.ts Normal file
View File

@ -0,0 +1,13 @@
// pinia数据持久化存储
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import { SelfStorage } from './secureStore'
// 第一个参数是应用程序中 store 的唯一 id
const store = createPinia()
store.use(
createPersistedState({
storage: SelfStorage
})
)
export default store

26
src/store/secureStore.ts Normal file
View File

@ -0,0 +1,26 @@
import { StorageLike } from 'pinia-plugin-persistedstate'
import CryptoJS from 'crypto-js'
const SECRET_KEY = import.meta.env.VITE_SECRET_KEY // 自定义加密密钥
// 自定义加密存储
export const SelfStorage: StorageLike = {
setItem(key: string, value: string) {
// 加密值
const encryptedValue = CryptoJS.AES.encrypt(value, SECRET_KEY).toString()
// 存储加密后的值
localStorage.setItem(key, encryptedValue)
},
getItem(key: string): string | null {
// 获取加密值
const encryptedValue = localStorage.getItem(key)
if (encryptedValue) {
// 解密
const bytes = CryptoJS.AES.decrypt(encryptedValue, SECRET_KEY)
const decryptedValue = bytes.toString(CryptoJS.enc.Utf8)
return decryptedValue || null
}
return null
},
}

17
src/store/user.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineStore } from "pinia";
export const useUserStore = defineStore("user", {
persist:true,
state:()=>({
token:""
}),
getters:{
getToken(state){
return state.token;
}
},
actions:{
setToken(token:string){
this.token = token;
}
}
});

156
src/style.css Normal file
View File

@ -0,0 +1,156 @@
* {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
touch-action: manipulation;
-webkit-touch-callout: none;
box-sizing: border-box;
}
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
[type="button"],
[type="reset"],
[type="submit"],
button {
-webkit-appearance: button;
}
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner,
button::-moz-focus-inner {
border-style: none;
padding: 0;
}
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring,
button:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}

View File

@ -0,0 +1,3 @@
.text-color{
background: linear-gradient(90deg, #8FC8FF 0%, #FFFFFF 100%);
}

17
src/styles/utils.scss Normal file
View File

@ -0,0 +1,17 @@
// 使 scss math https://sass-lang.com/documentation/breaking-changes/slash-div
@use "sass:math";
// 稿
$designWidth: 1920;
// 稿
$designHeight: 1080;
// px vw
@function vw($px) {
@return math.div($px, $designWidth) * 100vw;
}
// px vh
@function vh($px) {
@return math.div($px, $designHeight) * 100vh;
}

11
src/types/uni.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/**
* uni-app API
*/
declare const uni: {
postMessage: (options: { data: Record<string, any> }) => void;
getEnv: (callback: (res: any) => void) => void;
switchTab: (options: { url: string }) => void;
reLaunch: (options: { url: string }) => void;
navigateBack: (options: { delta: number }) => void;
[key: string]: any;
};

9
src/utils/echartData.ts Normal file
View File

@ -0,0 +1,9 @@
// 根据设计稿来放大缩小字体
export const fitChartSize = (size:number,defaultWidth = 1920) => {
let clientWidth = window.innerWidth||document.documentElement.clientWidth||document.body.clientWidth;
if (!clientWidth) return size;
let scale = (clientWidth / defaultWidth);
return Number((size*scale).toFixed(3));
}

View File

@ -0,0 +1,131 @@
<template>
<div class="w-max h-max relative">
<SvgComponent :content="groupSvg" class="w-[544px] h-[210px]" />
<div class="w-full h-full absolute top-0 left-0 pt-[31px] pb-[21px] pl-[14px] pr-[19px] flex items-center">
<div class="w-[216px]">
<div class="relative w-[154px] h-[124px] flex items-center flex-col">
<div class="text-[#44C1EF] italic text-[20px] font-700">总缴费</div>
<div class="bg-gradient-to-b from-[#8FC8FF] to-white bg-clip-text text-transparent mb-[15px] z-10">
<span class="text-[40px] italic">1265</span>
<span class="text-[20px]"></span>
</div>
<SvgComponent :content="lightningBoxSvg" class="box-light absolute" />
</div>
<div>
<div class="flex items-center justify-between text-[12px]">
<div class="text-[#6B89BC]">当前值1265人</div>
<div class="text-[#69D4FF]">
计划:
<span class="text-[15px]">4000</span>
<span class="text-[13px]"></span>
</div>
</div>
</div>
<YProgress :percentage="25" height="12px" class="mt-[7px]" />
</div>
<div class="ml-[22px] relative">
<ProportionCharts :chart-data="chartData" class="z-2 relative" />
<SvgComponent :content="paymentChartSvg" class="w-[143px] h-[143px] absolute top-0 left-0 z-1" />
<div
class="text-[18px] text-[#fff] font-700 text-shadow-[0_0_10px_rgba(12,32,72,0.42)] italic absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-3">
缴费
<br />
占比
</div>
</div>
<ul class="ml-[10px] flex flex-col justify-evenly h-full flex-1">
<li class="flex items-center flex-wrap" v-for="item in chartData" :key="item.name">
<div class="w-[6px] h-[6px] rounded-full" :style="{ backgroundColor: item.color }"></div>
<div class="flex-1 flex items-center text-[#C0EEFF] text-[12px] justify-between">
<span class="ml-[4px] mr-[9px]">{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<div class="border-image w-full mt-[6px]"></div>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import SvgComponent from "@/components/SvgComponent.vue";
import YProgress from "@/components/progress/YProgress.vue";
import ProportionCharts from "@/views/components/ProportionCharts.vue";
const groupSvg = ref("");
const getGroupBackgroundSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/group-background.svg?raw");
groupSvg.value = svg;
};
const chartData = ref([
{ name: "类型A", value: 250, color: "#0783FA" },
{ name: "类型B", value: 274, color: "#07D1FA" },
{ name: "类型C", value: 310, color: "#20E6A4" },
{ name: "类型D", value: 135, color: "#FFD15C" },
]);
const lightningBoxSvg = ref("");
const getLightningBoxSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/box-light.svg?raw");
lightningBoxSvg.value = svg;
};
const paymentChartSvg = ref("");
const getPaymentChartSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/payment-chart.svg?raw");
paymentChartSvg.value = svg;
};
onBeforeMount(() => {
getGroupBackgroundSvg();
getLightningBoxSvg();
getPaymentChartSvg();
});
</script>
<style scoped lang="scss">
@use "@/styles/text-color.scss";
.border-image {
border: 1px solid;
border-image: linear-gradient(
90deg,
#217ac600,
#227cc8,
#217ac600
)
1 1;
opacity: 0.3;
}
.rotate-animation {
animation: rotate-animation 1s infinite;
}
@keyframes rotate-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-180deg);
}
}
:deep(.box-light) {
.lightning-flashing {
animation: lightning-flashing 1s infinite;
}
@keyframes lightning-flashing {
0% {
fill-opacity: 0;
}
50% {
fill-opacity: 1;
}
100% {
fill-opacity: 0;
}
}
}
</style>

View File

@ -0,0 +1,124 @@
<template>
<div class="w-[143px] h-[143px]">
<div class="w-[143px] h-[143px] bg-transparent" ref="chartRef"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
//
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// prop
const props = defineProps({
chartData: {
type: Array,
required: true,
default: () => []
}
})
//
const initChart = () => {
if (chartRef.value) {
//
if (chartInstance) {
chartInstance.dispose()
}
//
chartInstance = echarts.init(chartRef.value)
//
const formattedData = props.chartData.map((item: any) => ({
...item,
// itemStylecolor
itemStyle: {
color: item.color || (item.itemStyle && item.itemStyle.color),
borderWidth: 0 //
}
}))
//
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderColor: '#44F1FF',
textStyle: {
color: '#fff',
fontSize: 10
},
formatter: '{b}: {d}%'
},
series: [
{
name: '数据占比',
type: 'pie',
radius: ['70%', '90%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderWidth: 0 //
},
label: {
show: false
},
emphasis: {
scale: false,
itemStyle: {
shadowBlur: 5,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
labelLine: {
show: false
},
data: formattedData,
//
gap: 0
}
],
animation: true,
animationDuration: 500
}
//
chartInstance.setOption(option)
//
window.addEventListener('resize', handleResize)
}
}
//
const handleResize = () => {
if (chartInstance) {
chartInstance.resize()
}
}
//
onMounted(() => {
initChart()
})
onUnmounted(() => {
//
window.removeEventListener('resize', handleResize)
//
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="w-max h-max relative">
<SvgComponent :content="groupSvg" class="w-[544px] h-[210px]" />
<div class="w-full h-full absolute top-0 left-0 pt-[31px] pb-[21px] pl-[14px] pr-[19px] flex items-center">
<div class="w-[216px]">
<div class="relative w-[144px] h-[113px] flex items-center flex-col">
<div class="text-[#44C1EF] italic text-[20px] font-700">今日缴费</div>
<div class="bg-gradient-to-b from-[#8FC8FF] to-white bg-clip-text text-transparent mb-[15px] z-10">
<span class="text-[34px] italic">125</span>
<span class="text-[18px]"></span>
</div>
<SvgComponent :content="hexagonalBoxSvg" class="box-light absolute" />
</div>
<div>
<div class="flex items-center justify-between text-[12px]">
<div class="text-[#6B89BC]">当前值1265人</div>
<div class="text-[#69D4FF]">
计划:
<span class="text-[15px]">4000</span>
<span class="text-[13px]"></span>
</div>
</div>
</div>
</div>
<div class="ml-[22px] relative">
<SvgComponent :content="paymentBorderSvg" class="w-[127px] h-[130px] top-0 left-0 z-1" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import SvgComponent from "@/components/SvgComponent.vue";
const groupSvg = ref("");
const getGroupBackgroundSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/group-background.svg?raw");
groupSvg.value = svg;
};
const hexagonalBoxSvg = ref("");
const getLightningBoxSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/hexagonal-light.svg?raw");
hexagonalBoxSvg.value = svg;
};
const paymentBorderSvg = ref("");
const getPaymentChartSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/payment-border.svg?raw");
paymentBorderSvg.value = svg;
};
onBeforeMount(() => {
getGroupBackgroundSvg();
getLightningBoxSvg();
getPaymentChartSvg();
});
</script>
<style scoped lang="scss">
@use "@/styles/text-color.scss";
.border-image {
border: 1px solid;
border-image: linear-gradient(
90deg,
#217ac600,
#227cc8,
#217ac600
)
1 1;
opacity: 0.3;
}
.rotate-animation {
animation: rotate-animation 1s infinite;
}
@keyframes rotate-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-180deg);
}
}
:deep(.box-light) {
.lightning-flashing {
animation: lightning-flashing 1s infinite;
}
@keyframes lightning-flashing {
0% {
fill-opacity: 0;
}
50% {
fill-opacity: 1;
}
100% {
fill-opacity: 0;
}
}
}
</style>

74
src/views/home.vue Normal file
View File

@ -0,0 +1,74 @@
<template>
<div class="main-bg aspect-[16/9]">
<header class="relative flex items-center">
<SvgComponent :content="headerSvg" class="w-full h-[98px]" />
<SvgComponent :content="titleSvg" class="w-[50%] h-[69px] absolute top-0 left-50% translate-x-[-50%]" />
<div class="absolute top-[25%] right-[24px] translate-y-[-25%] z-1 flex items-center justify-center w-max">
<div class="text-[#45A2FF] text-[14px]">{{ year }}-{{ month }}-{{ day }}&nbsp;{{ weekday }}</div>
<DigitalWatch class="ml-[10px]" />
</div>
</header>
<div class="flex items-center justify-end pr-[24px] cursor-pointer">
<SvgIcon name="circle" class="text-[14px] text-[#C0EEFF] hover:rotate-90 transition-all duration-300" />
<div class="text-[#C0EEFF] text-[12px] ml-[5px]">数据更新时间:6.12 12:00:00</div>
</div>
<div class="flex items-center px-[24px]">
<PaymentTotal />
<TodayPayment class="ml-[20px]" />
</div>
<!-- <TDCharts :optionData="chartData" /> -->
</div>
</template>
<script lang="ts" setup>
import SvgComponent from "@/components/SvgComponent.vue";
import SvgIcon from "@/components/svg-icon/SvgIcon.vue";
import TDCharts from "@/components/ease-charts/TDCharts.vue";
import DigitalWatch from "@/components/watch/DigitalWatch.vue";
import PaymentTotal from "@/views/components/PaymentTotal.vue";
import TodayPayment from "@/views/components/TodayPayment.vue";
import { useDate } from "@/composables/useDate";
const { year, month, day, weekday, updateTime } = useDate();
const headerSvg = ref("");
const headerBackgroundSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/header-background.svg?raw");
headerSvg.value = svg;
};
const titleSvg = ref("");
const headerTitleSvg = async () => {
const { default: svg } = await import("/src/assets/svg-img/header-title.svg?raw");
titleSvg.value = svg;
};
onBeforeMount(() => {
updateTime();
headerBackgroundSvg();
headerTitleSvg();
});
//
const chartData = ref([
{ name: "线上", value: 335, itemStyle: { color: "#d62728" } },
{ name: "线下", value: 310, itemStyle: { color: "#ffcc5c" } },
]);
</script>
<style scoped lang="scss">
.main-bg {
background-image: url("/images/main-bg.png");
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
</style>

View File

@ -0,0 +1,3 @@
<template>
<div>401 - Unauthorized</div>
</template>

8
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}

3
src/window-env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
interface Window {
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/composables/useDigitalWatch.js"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts","./plugins/*"]
}

27
uno.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig, presetIcons,presetUno } from "unocss";
import presetWind from "@unocss/preset-wind";
import { presetScrollbarHide } from 'unocss-preset-scrollbar-hide'
// import { presetPxToViewport } from "unocss-preset-px-to-vw-or-vh";
export default defineConfig({
presets: [
// presetPxToViewport({
// // 可选:自定义设计尺寸(默认为 1920x1080
// designWidth: 1920,
// designHeight: 1080,
// keyToVw: ["font-size"],
// }),
presetUno(),
presetWind(),
presetScrollbarHide(),
presetIcons({
scale: 1,
warn: true,
extraProperties: {
display: "inline-block",
"vertical-align": "middle",
},
}),
],
rules: [["pb-safe", { "padding-bottom": "calc(env(safe-area-inset-bottom) + 52rpx)" }]],
});

43
upload.sh Normal file
View File

@ -0,0 +1,43 @@
#!/bin/bash
# 服务器信息
SERVER_USER="root"
SERVER_HOST="106.14.30.150"
SERVER_PATH="/opt/1panel/apps/openresty/openresty/www/sites/sort.ycymedu.com/index"
PRIVATE_KEY="ALIYUN.pem"
BACKUP_PATH="${SERVER_PATH}-backup-$(date +%Y%m%d%H%M%S).zip"
DINGDING_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=fca104958fea6273c9c7ef3f08b3d552645c214f929066785e8caf6e1885a5a6"
# 在上传之前备份原来的文件并压缩
ssh -i $PRIVATE_KEY $SERVER_USER@$SERVER_HOST "cd $(dirname $SERVER_PATH) && zip -r $(basename $BACKUP_PATH) $(basename $SERVER_PATH)"
# 使用 scp 上传文件
scp -i $PRIVATE_KEY -r dist/* $SERVER_USER@$SERVER_HOST:$SERVER_PATH
# 提示上传完成
if [ $? -eq 0 ]; then
echo "上传成功!备份存储于 $BACKUP_PATH"
# 发送钉钉通知
curl -X POST "$DINGDING_WEBHOOK" \
-H "Content-Type: application/json" \
-d '{
"msgtype": "text",
"text": {
"content": "single html| upload success!! backup to'"$BACKUP_PATH"'"
}
}'
else
echo "上传失败,请检查错误信息。"
# 发送钉钉通知
curl -X POST "$DINGDING_WEBHOOK" \
-H "Content-Type: application/json" \
-d '{
"msgtype": "text",
"text": {
"content": "single html|upload failplease check error info。"
}
}'
fi

71
vite.config.ts Normal file
View File

@ -0,0 +1,71 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import AutoComplete from "unplugin-auto-import/vite";
import UnoCSS from 'unocss/vite'
import svgBuilder from "./plugins/svgBuilder";
const pathSrc = resolve(__dirname, "src");
// https://vite.dev/config/
export default defineConfig({
assetsInclude: ["**/*.svg"],
base: "./" /* 这个就是webpack里面的publicPath */,
build: {
rollupOptions: {
output: {
// 最小化拆分包
manualChunks: (id) => {
if (id.includes("node_modules")) {
return id.toString().split("node_modules/")[1].split("/")[0].toString();
}
}, // 用于从入口点创建的块的打包输出格式[name]表示文件名,[hash]表示该文件内容hash值
entryFileNames: "js/[name].[hash].js", // 用于命名代码拆分时创建的共享块的输出命名
chunkFileNames: "js/[name].[hash].js", // 用于输出静态资源的命名,[ext]表示文件扩展名
},
},
},
plugins: [
vue(),
UnoCSS(),
svgBuilder(resolve("./public/icons")), // svg存储的名字
AutoComplete({
imports: [
"vue",
"vue-router",
"pinia", // 自动导入 Pinia API如 defineStore
],
dts: resolve(pathSrc, "auto-imports.d.ts"),
}),
],
resolve: {
alias: {
"@/": `${pathSrc}/`,
},
},
server: {
host: "localhost",
port: 3001,
proxy:{
'/api':{
target:'http://192.168.31.149:5006',
changeOrigin:true,
}
}
},
esbuild: {
drop: ["debugger"],
},
optimizeDeps: { // 在开发服务器启动时预构建依赖,将 CommonJS/UMD 模块转换为 ESM 格式,并缓存结果以提高性能。
include: [],
},
css:{
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/utils.scss" as cssUtils;`,
}
}
}
});