项目创建

This commit is contained in:
浅念
2025-07-04 17:16:38 +08:00
parent ce47b39fa5
commit 719911918f
372 changed files with 105035 additions and 32 deletions
+50
View File
@@ -0,0 +1,50 @@
<template>
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
<RouterView></RouterView>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { useUserStore } from './store/modules/user'
import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { systemUpgrade } from './utils/sys'
import { UserService } from './api/usersApi'
import { setThemeTransitionClass } from './utils/theme/animation'
import { checkStorageCompatibility } from './utils/storage'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const locales = {
zh: zh,
en: en
}
onBeforeMount(() => {
setThemeTransitionClass(true)
})
onMounted(() => {
// 检查存储兼容性
checkStorageCompatibility()
// 提升暗黑主题下页面刷新视觉体验
setThemeTransitionClass(false)
// 系统升级
systemUpgrade()
// 获取用户信息
getUserInfo()
})
// 获取用户信息
const getUserInfo = async () => {
if (userStore.isLogin) {
try {
const data = await UserService.getUserInfo()
userStore.setUserInfo(data)
} catch (error) {
console.error('获取用户信息失败', error)
}
}
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/http'
export class BrandService {
// 获取品牌
static getBrandInfo() {
return request.get<Api.Brand.BrandInfo>({
url: '/console/brand/preset'
})
}
}
+25
View File
@@ -0,0 +1,25 @@
import { asyncRoutes } from '@/router/routes/asyncRoutes'
import { menuDataToRouter } from '@/router/utils/menuToRouter'
import { AppRouteRecord } from '@/types/router'
interface MenuResponse {
menuList: AppRouteRecord[]
}
// 菜单接口
export const menuService = {
async getMenuList(delay = 300): Promise<MenuResponse> {
try {
// 模拟接口返回的菜单数据
const menuData = asyncRoutes
// 处理菜单数据
const menuList = menuData.map((route) => menuDataToRouter(route))
// 模拟接口延迟
await new Promise((resolve) => setTimeout(resolve, delay))
return { menuList }
} catch (error) {
throw error instanceof Error ? error : new Error('获取菜单失败')
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import request from '@/utils/http'
export class UserService {
// 登录
static login(params: Api.Auth.LoginParams) {
return request.post<Api.Auth.LoginResponse>({
url: '/api/auth/login',
params
// showErrorMessage: false // 不显示错误消息
})
}
// 获取用户信息
static getUserInfo() {
return request.get<Api.User.UserInfo>({
url: '/api/user/info'
})
}
// 获取用户列表
static getUserList(params: Api.Common.PaginatingParams) {
return request.get<Api.User.UserListData>({
url: '/api/user/list',
params
})
}
}
+93
View File
@@ -0,0 +1,93 @@
import request from '@/utils/http'
export class WorkService {
// 获取工单列表
static getWorkList(params: Api.Common.PaginatingParams) {
return request.get<Api.Work.WorkListData>({
url: '/ticket/ticket/list',
params
})
}
//添加工单
static WorkAdd(params: Api.Work.AddParams) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/ticket/add',
params
// showErrorMessage: false // 不显示错误消息
})
}
//工单修改 ticket/categorie/edit
static Workedit(params:any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/ticket/edit',
params
// showErrorMessage: false // 不显示错误消息
})
}
//修改工单状态
static Workstatus(params: Api.Work.StatusParams) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/ticket/status',
params
// showErrorMessage: false // 不显示错误消息
})
}
//工单状态下拉选择框
static getStatus() {
return request.get<Api.Brand.BrandInfo>({
url: '/ticket/ticket/getStatus'
})
}
//工单沟通记录
static getconversationList(params: any) {
return request.get<Api.Work.WorkListData>({
url: '/ticket/conversation/list',
params
})
}
//添加工单记录 ticket/conversation/add
static conversationAdd(params:any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/conversation/add',
params
// showErrorMessage: false // 不显示错误消息
})
}
//工单分类 添加
static WorkcategorieAdd(params:any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/categorie/add',
params
// showErrorMessage: false // 不显示错误消息
})
}
//工单分类 列表
static getCategorieList(params: Api.Common.PaginatingParams) {
return request.get<Api.Work.CategorieListData>({
url: '/ticket/categorie/list',
params
})
}
//工单分类修改 ticket/categorie/edit
static Workcategorieedit(params:any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/categorie/edit',
params
// showErrorMessage: false // 不显示错误消息
})
}
//工单分类下拉
static getcategorieselect() {
return request.get<Api.Brand.BrandInfo>({
url: '/ticket/categorie/getAll'
})
}
//工单分类删除
static Workcategoriedelete(params:any) {
return request.post<Api.Work.AddResponse>({
url: '/ticket/categorie/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
}
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+171
View File
@@ -0,0 +1,171 @@
// 全局样式
@font-face {
font-family: 'DMSans';
font-style: normal;
font-weight: 400;
src: url(../fonts/DMSans.woff2) format('woff2');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
src: url(../fonts/Montserrat.woff2) format('woff2');
}
.btn-icon {
font-size: 10px;
}
.el-btn-red {
color: #fa6962 !important;
&:hover {
opacity: 0.9;
}
&:active {
opacity: 0.7;
}
}
// 顶部进度条颜色
#nprogress .bar {
background-color: color-mix(in srgb, var(--main-color) 65%, white);
}
// 处理移动端组件兼容性
@media screen and (max-width: $device-phone) {
* {
cursor: default !important;
}
.el-col2 {
margin-top: 15px;
}
}
// 背景滤镜
*,
::before,
::after {
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
// 色弱模式
.color-weak {
filter: invert(80%);
-webkit-filter: invert(80%);
}
#noop {
display: none;
}
// 语言切换选中样式
.langDropDownStyle {
// 选中项背景颜色
.is-selected {
background-color: rgba(var(--art-gray-200-rgb), 0.8) !important;
}
// 语言切换按钮菜单样式优化
.lang-btn-item {
.el-dropdown-menu__item {
padding-left: 13px !important;
padding-right: 6px !important;
margin-bottom: 3px !important;
}
&:last-child {
.el-dropdown-menu__item {
margin-bottom: 0 !important;
}
}
.menu-txt {
min-width: 60px;
display: block;
}
i {
font-size: 10px;
margin-left: 10px;
}
}
}
// 盒子默认边框
.page-content,
.art-custom-card {
border: 1px solid var(--art-card-border) !important;
}
// 盒子边框
[data-box-mode='border-mode'] {
.page-content,
.art-custom-card,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
.page-content,
.art-custom-card,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid rgba(var(--art-gray-300-rgb), 0.3) !important;
}
.layout-sidebar {
border-right: 1px solid rgba(var(--art-gray-300-rgb), 0.4) !important;
}
}
// 元素全屏
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 500;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--art-main-bg-color);
.art-table-full-screen {
height: 100% !important;
}
}
// 表格卡片
.art-table-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 15px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
overflow: hidden;
}
}
+11
View File
@@ -0,0 +1,11 @@
// 主题切换过渡优化,去除不适感
.theme-change {
* {
transition: 0s !important;
}
.el-switch__core,
.el-switch__action {
transition: all 0.3s !important;
}
}
+215
View File
@@ -0,0 +1,215 @@
/*
* 深色主题
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.7);
$background-color: #070707;
/* 覆盖element-plus默认深色背景色 */
html.dark {
// ✅ element-plus
// --el-bg-color: $background-color;
--el-text-color-regular: $font-color;
// ✅ 富文本编辑器
// 工具栏背景颜色
--w-e-toolbar-bg-color: var(--art-main-bg-color);
// 输入区域背景颜色
--w-e-textarea-bg-color: var(--art-main-bg-color);
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-text-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: rgba(var(--art-gray-100-rgb), 0.8);
// 弹窗边框颜色
--w-e-toolbar-border-color: var(--art-border-dashed-color);
// 分割线颜色
--w-e-textarea-border-color: var(--art-border-dashed-color);
// 链接输入框边框颜色
--w-e-modal-button-border-color: var(--art-border-dashed-color);
// 表格头颜色
--w-e-textarea-slight-bg-color: var(--art-color);
// 按钮背景颜色
--w-e-modal-button-bg-color: var(--art-color);
}
.dark {
color: $font-color !important;
background: $background-color !important;
/* 全局文字颜色 */
body {
color: $font-color;
h1,
h2,
h3,
h4,
h5,
h6,
.lang .btn,
.layout-top-bar .user .name,
.dark-text {
color: $font-color !important;
}
}
// 图片降低亮度
img {
filter: brightness(0.92) saturate(1.25);
}
.editor-wrapper {
*:not(pre code *) {
color: inherit !important;
}
}
.img-cutter {
*:not([class^='el-']) {
color: inherit !important;
}
}
// ✅ 左侧菜单样式
.layout-sidebar,
.dual-menu {
.el-menu-dark {
// 选中颜色
.el-menu-item.is-active {
background: transparent;
}
.el-sub-menu__title {
.el-icon {
color: var(--art-gray-800) !important;
}
}
// 鼠标移入背景色
.el-sub-menu__title:hover,
.el-menu-item:not(.is-active):hover {
background: rgba(var(--art-gray-200-rgb), 0.6) !important;
}
[level-item='2'].is-active:not(.el-menu--collapse) {
&.is-active {
&:before {
margin-left: -10px !important;
}
}
}
.el-menu:not(.el-menu--collapse) {
// 选中颜色
.el-menu-item.is-active {
&:before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
background: var(--main-color) !important;
transition: all 0.2s;
margin-left: -18px;
}
}
}
}
}
.page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important;
}
// ✅ 富文本编辑器
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
}
// 下拉选择框
.w-e-select-list {
background-color: var(--art-main-bg-color) !important;
border: 1px solid var(--art-border-dashed-color) !important;
}
/* 弹出框 */
.w-e-drop-panel {
border: 1px solid var(--art-border-dashed-color) !important;
}
/* 工具栏菜单 */
.w-e-bar-item-group .w-e-bar-item-menus-container {
background-color: var(--art-main-bg-color) !important;
border: 1px solid var(--art-border-dashed-color) !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: var(--art-color) !important;
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: var(--art-gray-100) !important;
border: 1px solid var(--art-border-dashed-color) !important;
text-shadow: none !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--art-gray-200) !important;
background-color: var(--art-color);
}
.editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--art-gray-200) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
// 工作台标签文字颜色
.worktab .scroll-view .tabs li {
color: var(--art-text-gray-800) !important;
}
// 顶部按钮文字颜色
.layout-top-bar .btn-box .btn i,
.fast-enter-trigger .btn i {
color: var(--art-text-gray-700) !important;
}
}
// 移动端文字颜色
@media screen and (max-width: $device-phone) {
.dark {
$font-color: rgba(#ffffff, 0.8);
--el-text-color-regular: $font-color !important;
color: $font-color !important;
body {
color: $font-color !important;
h1,
h2,
h3,
h4,
h5,
h6,
.lang .btn,
.layout-top-bar .user .name {
color: $font-color !important;
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
// 自定义Element 暗黑主题
@forward 'element-plus/theme-chalk/src/dark/var.scss' //
with (
$colors: (
//
'white': #ffffff,
'black': #000000,
'success': ('base': #13deb9),
'warning': ('base': #ffae1f),
'danger': ('base': #ff4d4f),
'error': ('base': #fa896b)
)
);
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
+34
View File
@@ -0,0 +1,34 @@
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
// 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' //
with (
//
$colors: (
//
'white': #ffffff,
'black': #000000,
'success': ('base': #13deb9),
'warning': ('base': #ffae1f),
'danger': ('base': #ff4d4f),
'error': ('base': #fa896b)
),
$button: (
//
'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary)
),
$messagebox: (
//
'border-radius': '12px'
),
$popover: (
//
'padding': '14px',
'border-radius': '10px'
)
);
@use 'element-plus/theme-chalk/src/index.scss' as *;
+402
View File
@@ -0,0 +1,402 @@
// 优化 Element Plus 组件库默认样式
:root {
// 系统主色
--main-color: var(--el-color-primary);
--el-color-white: white !important;
--el-color-black: white !important;
// 输入框边框颜色
// --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度
--el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important;
// 边框、按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: 10px !important;
--el-messagebox-border-radius: 10px !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--main-color);
}
}
// 优化菜单折叠展开动画(提升动画流畅度)
.el-menu.el-menu--inline {
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
// 优化菜单 item hover 动画(提升鼠标跟手感)
.el-sub-menu__title,
.el-menu-item {
transition: background-color 0s !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
// 修改 el-button 高度
.el-button--default {
height: var(--el-component-custom-height) !important;
}
// 修改 el-select 高度
.el-select--default {
.el-select__wrapper {
min-height: var(--el-component-custom-height) !important;
}
}
// 修改 el-checkbox-button 高度
.el-checkbox-button--default .el-checkbox-button__inner,
// 修改 el-radio-button 高度
.el-radio-button--default .el-radio-button__inner {
padding: 10px 15px !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
.el-pagination.is-background .btn-next,
.el-pagination.is-background .btn-prev,
.el-pagination.is-background .el-pager li {
border-radius: 6px;
}
.el-popover {
min-width: 80px;
}
.el-dialog {
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden;
}
.el-dialog__header {
.el-dialog__title {
font-size: 16px;
}
}
.el-dialog__body {
padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
}
.el-dialog.el-dialog-border {
.el-dialog__body {
// 上边框
&::before,
// 下边框
&::after {
content: '';
position: absolute;
left: -16px;
width: calc(100% + 32px);
height: 1px;
background-color: rgba(var(--art-gray-300-rgb), 0.56);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
}
// ✅ el-message 样式优化
.el-message {
background-color: var(--art-main-bg-color) !important;
border: 0 !important;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
p {
color: #515a6e !important;
font-size: 13px;
}
}
// 修改 el-dropdown 样式
.el-dropdown-menu {
padding: 6px !important;
border-radius: 10px !important;
border: none !important;
.el-dropdown-menu__item {
padding: 6px 16px !important;
border-radius: 6px !important;
&:hover:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important;
}
}
}
// 隐藏 select、dropdown 的三角
.el-select__popper,
.el-dropdown__popper {
margin-top: -6px !important;
.el-popper__arrow {
display: none;
}
}
.el-dropdown-selfdefine:focus {
outline: none !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: $device-phone) {
.el-message-box,
.el-message,
.el-dialog {
width: calc(100% - 24px) !important;
}
.el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px);
left: 12px !important;
}
.el-picker-panel *[slot='sidebar'],
.el-picker-panel__sidebar {
display: none;
}
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0;
}
}
// 修改el-button样式
.el-button {
&.el-button--text {
background-color: transparent !important;
padding: 0 !important;
span {
margin-left: 0 !important;
}
}
}
// 修改el-tag样式
.el-tag {
height: 26px !important;
line-height: 26px !important;
border: 0 !important;
border-radius: 6px !important;
font-weight: bold;
transition: all 0s !important;
}
.el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important;
.el-checkbox__label {
font-weight: 400 !important;
}
}
}
.el-checkbox {
.el-checkbox__inner {
width: 18px !important;
height: 18px !important;
border-radius: 4px !important;
&::before {
content: '';
height: 3px !important;
top: 6px !important;
background-color: #fff !important;
transform: scale(0.6) !important;
}
&::after {
width: 4px;
height: 8px;
left: 0;
right: 0;
top: 0;
bottom: 4px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
}
}
}
.el-notification .el-notification__icon {
font-size: 22px !important;
}
// 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close {
color: var(--art-gray-500) !important;
top: 7px !important;
right: 7px !important;
padding: 7px !important;
border-radius: 5px !important;
transition: all 0.3s !important;
&:hover {
background-color: var(--art-gray-200) !important;
color: var(--art-gray-800) !important;
}
}
.el-message-box {
padding: 25px 20px !important;
}
.el-message-box__title {
font-weight: 500 !important;
}
.el-table__column-filter-trigger i {
color: var(--main-color) !important;
margin: -3px 0 0 2px;
}
// 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible {
outline: unset;
}
// ipad 表单右侧按钮优化
@media screen and (max-width: $device-ipad-pro) {
.el-table-fixed-column--right {
padding-right: 0 !important;
.el-button {
margin: 5px 10px 5px 0 !important;
}
}
}
.login-out-dialog {
padding: 30px 20px !important;
border-radius: 10px !important;
}
// 修改 dialog 动画
.dialog-fade-enter-active {
.el-dialog:not(.is-draggable) {
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
// 修复 el-dialog 动画后宽度不自适应问题
.el-select__selected-item {
display: inline-block;
}
}
}
.dialog-fade-leave-active {
animation: fade-out 0.2s linear;
.el-dialog:not(.is-draggable) {
animation: dialog-close 0.5s;
}
}
@keyframes dialog-open {
0% {
opacity: 0;
transform: scale(0.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.2);
}
}
// 遮罩层动画
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
// 修改 el-select 样式
.el-select__popper:not(.el-tree-select__popper) {
.el-select-dropdown__list {
padding: 5px !important;
.el-select-dropdown__item {
height: 34px !important;
line-height: 34px !important;
border-radius: 6px !important;
&.is-selected {
color: var(--art-gray-900) !important;
font-weight: 400 !important;
background-color: var(--art-gray-200) !important;
margin-bottom: 4px !important;
}
&:hover {
background-color: var(--art-gray-200) !important;
}
}
.el-select-dropdown__item:hover ~ .is-selected,
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
background-color: transparent !important;
}
}
}
// 修改 el-tree-select 样式
.el-tree-select__popper {
.el-select-dropdown__list {
padding: 5px !important;
.el-tree-node {
.el-tree-node__content {
height: 36px !important;
border-radius: 6px !important;
&:hover {
background-color: var(--art-gray-200) !important;
}
}
}
}
}
// 实现水波纹在文字下面效果
.el-button > span {
position: relative;
z-index: 10;
}
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
// sass 混合宏(函数)
/**
* 溢出省略号
* @param {Number} 行数
*/
@mixin ellipsis($rowCount: 1) {
@if $rowCount <=1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $rowCount;
-webkit-box-orient: vertical;
}
}
/**
* 控制用户能否选中文本
* @param {String} 类型
*/
@mixin userSelect($value: none) {
user-select: $value;
-moz-user-select: $value;
-ms-user-select: $value;
-webkit-user-select: $value;
}
// 绝对定位居中
@mixin absoluteCenter() {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
}
/**
* css3动画
*
*/
@mixin animation(
$from: (
width: 0px
),
$to: (
width: 100px
),
$name: mymove,
$animate: mymove 2s 1 linear infinite
) {
-webkit-animation: $animate;
-o-animation: $animate;
animation: $animate;
@keyframes #{$name} {
from {
@each $key, $value in $from {
#{$key}: #{$value};
}
}
to {
@each $key, $value in $to {
#{$key}: #{$value};
}
}
}
@-webkit-keyframes #{$name} {
from {
@each $key, $value in $from {
$key: $value;
}
}
to {
@each $key, $value in $to {
$key: $value;
}
}
}
}
// 圆形盒子
@mixin circle($size: 11px, $bg: #fff) {
border-radius: 50%;
width: $size;
height: $size;
line-height: $size;
text-align: center;
background: $bg;
}
// placeholder
@mixin placeholder($color: #bbb) {
// Firefox
&::-moz-placeholder {
color: $color;
opacity: 1;
}
// Internet Explorer 10+
&:-ms-input-placeholder {
color: $color;
}
// Safari and Chrome
&::-webkit-input-placeholder {
color: $color;
}
&:placeholder-shown {
text-overflow: ellipsis;
}
}
//背景透明,文字不透明。兼容IE8
@mixin betterTransparentize($color, $alpha) {
$c: rgba($color, $alpha);
$ie_c: ie_hex_str($c);
background: rgba($color, 1);
background: $c;
background: transparent \9;
zoom: 1;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
}
//添加浏览器前缀
@mixin browserPrefix($propertyName, $value) {
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
#{$prefix}#{$propertyName}: $value;
}
}
// 边框
@mixin border($color: red) {
border: 1px solid $color;
}
// 背景滤镜
@mixin backdropBlur() {
--tw-backdrop-blur: blur(30px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast)
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert)
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
+8
View File
@@ -0,0 +1,8 @@
// 移动端样式处理
// 去除移动端点击背景色
@media screen and (max-width: $device-ipad-pro) {
* {
-webkit-tap-highlight-color: transparent;
}
}
+117
View File
@@ -0,0 +1,117 @@
/*
Atom One Dark by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
base: #282c34
mono-1: #abb2bf
mono-2: #818896
mono-3: #5c6370
hue-1: #56b6c2
hue-2: #61aeee
hue-3: #c678dd
hue-4: #98c379
hue-5: #e06c75
hue-5-2: #be5046
hue-6: #d19a66
hue-6-2: #e6c07b
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
// color: #abb2bf;
// background: #282c34;
color: #a6accd;
}
.hljs-string,
.hljs-section,
.hljs-selector-class,
.hljs-template-variable,
.hljs-deletion {
color: #aed07e !important;
}
.hljs-comment,
.hljs-quote {
color: #6f747d;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c792ea;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #c86068;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta-string {
color: #abb2bf;
}
.hljs-attribute {
color: #c792ea;
}
.hljs-function {
color: #c792ea;
}
.hljs-type {
color: #f07178;
}
.hljs-title {
color: #82aaff !important;
}
.hljs-built_in,
.hljs-class {
color: #82aaff;
}
// 括号
.hljs-params {
color: #a6accd;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #de7e61;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id {
color: #61aeee;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}
+149
View File
@@ -0,0 +1,149 @@
@charset "UTF-8";
body,
dl,
dt,
dd,
ul,
ol,
li,
pre,
form,
fieldset,
input,
p,
blockquote,
th,
td {
font-weight: 400;
margin: 0;
padding: 0;
}
h1,
h2,
h3,
h4,
h4,
h5 {
margin: 0;
padding: 0;
color: var(--art-text-gray-800);
}
body {
color: var(--art-text-gray-700);
text-align: left;
font-family: Inter, 'Helvetica Neue', Helvetica, 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
select {
font-size: 12px;
}
table {
border-collapse: collapse;
}
fieldset,
img {
border: 0none;
}
fieldset {
margin: 0;
padding: 0;
}
fieldset p {
margin: 0;
padding: 0008px;
}
legend {
display: none;
}
address,
caption,
em,
strong,
th,
i {
font-style: normal;
font-weight: 400;
}
table caption {
margin-left: -1px;
}
hr {
border-bottom: 1pxsolid #ffffff;
border-top: 1pxsolid #e4e4e4;
border-width: 1px0;
clear: both;
height: 2px;
margin: 5px0;
overflow: hidden;
}
ol,
ul {
list-style-image: none;
list-style-position: outside;
list-style-type: none;
}
caption,
th {
text-align: left;
}
q:before,
q:after,
blockquote:before,
blockquote:after {
content: ””;
}
/*滚动条*/
/*滚动条整体部分,必须要设置*/
::-webkit-scrollbar {
width: 8px !important;
height: 0 !important;
}
/*滚动条的轨道*/
::-webkit-scrollbar-track {
background-color: var(--art-text-gray-100);
}
/*滚动条的滑块按钮*/
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #cccccc !important;
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: #b0abab !important;
}
/*滚动条的上下两端的按钮*/
::-webkit-scrollbar-button {
height: 0px;
width: 0;
}
.dark {
::-webkit-scrollbar-track {
background-color: var(--art-bg-color);
}
::-webkit-scrollbar-thumb {
background-color: rgba(var(--art-gray-300-rgb), 0.8) !important;
}
}
+63
View File
@@ -0,0 +1,63 @@
// 定义基础变量
$bg-animation-color-light: #000;
$bg-animation-color-dark: #fff;
$bg-animation-duration: 0.5s;
html {
--bg-animation-color: $bg-animation-color-light;
&.dark {
--bg-animation-color: $bg-animation-color-dark;
}
// View transition styles
&::view-transition-old(*) {
animation: none;
}
&::view-transition-new(*) {
animation: clip $bg-animation-duration ease-in;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 9999;
}
&.dark {
&::view-transition-old(*) {
animation: clip $bg-animation-duration ease-in reverse;
}
&::view-transition-new(*) {
animation: none;
}
&::view-transition-old(root) {
z-index: 9999;
}
&::view-transition-new(root) {
z-index: 1;
}
}
}
// 定义动画
@keyframes clip {
from {
clip-path: circle(0% at var(--x) var(--y));
}
to {
clip-path: circle(var(--r) at var(--x) var(--y));
}
}
// body 相关样式
body {
background-color: var(--bg-animation-color);
}
+97
View File
@@ -0,0 +1,97 @@
@use 'sass:map';
// === 变量区域 ===
$transition: (
duration: 0.26s,
// 动画持续时间
distance: 20px,
// 滑动动画的移动距离
easing: cubic-bezier(0.4, 0, 0.2, 1),
// 默认缓动函数
fade-easing: ease // 淡入淡出专用的缓动函数
);
// 抽取配置值函数,提高可复用性
@function transition-config($key) {
@return map.get($transition, $key);
}
// 变量简写
$duration: transition-config('duration');
$distance: transition-config('distance');
$easing: transition-config('easing');
$fade-easing: transition-config('fade-easing');
// === 动画类 ===
// 淡入淡出动画
.fade {
&-enter-active,
&-leave-active {
transition: opacity $duration $fade-easing;
will-change: opacity;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
&-enter-to,
&-leave-from {
opacity: 1;
}
}
// 滑动动画通用样式
@mixin slide-transition($direction) {
$distance-x: 0;
$distance-y: 0;
@if $direction == 'left' {
$distance-x: -$distance;
} @else if $direction == 'right' {
$distance-x: $distance;
} @else if $direction == 'top' {
$distance-y: -$distance;
} @else if $direction == 'bottom' {
$distance-y: $distance;
}
&-enter-active,
&-leave-active {
transition:
opacity $duration $easing,
transform $duration $easing;
will-change: opacity, transform;
}
&-enter-from {
opacity: 0;
transform: translate3d($distance-x, $distance-y, 0);
}
&-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
&-leave-to {
opacity: 0;
transform: translate3d(-$distance-x, -$distance-y, 0);
}
}
// 滑动动画方向类
.slide-left {
@include slide-transition('left');
}
.slide-right {
@include slide-transition('right');
}
.slide-top {
@include slide-transition('top');
}
.slide-bottom {
@include slide-transition('bottom');
}
+150
View File
@@ -0,0 +1,150 @@
// 自定义Element树形结构组件样式
.tree .custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.tree .tree .el-tree-node__content {
height: 38px;
line-height: 38px;
}
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: #409eff;
color: #fff;
}
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content i {
color: #fff;
}
.tree .custom-tree-node .icon {
font-size: 13px;
color: #409eff;
}
.tree .custom-tree-node .btn {
font-size: 13px;
display: none;
padding: 6px;
position: relative;
}
.tree .custom-tree-node:hover .icon {
color: #409eff;
}
.tree .el-tree-node__content:hover {
color: #606060;
// background: #409EFF;
background: #f0f7ff;
}
.tree .custom-tree-node:hover .btn {
display: inline;
}
.tree .custom-tree-node .btn:hover ul {
display: inline;
}
.tree .custom-tree-node .btn ul {
width: 120px;
background: #fff;
position: absolute;
top: 26px;
right: 0;
display: none;
z-index: 999;
border: 1px solid #f0f0f0;
// box-shadow: 0 4px 4px 2px #f2f2f2;
}
.tree .custom-tree-node .btn ul li {
padding: 10px 15px;
color: #666666;
box-sizing: border-box;
}
.tree .custom-tree-node .btn ul li:hover {
color: #333;
background: #f5f5f5;
}
.tree .el-tree-node.is-expanded > .el-tree-node__children {
overflow: inherit;
}
.tree .el-tree > .el-tree-node:after {
border-top: none;
}
.tree .el-tree-node {
position: relative;
}
.tree .el-tree-node__expand-icon.is-leaf {
display: none;
}
.tree .el-tree-node__children {
padding-left: 16px;
}
.tree .el-tree-node :last-child:before {
height: 38px;
}
.tree .el-tree-node :last-child:before {
height: 17px;
}
.tree .el-tree > .el-tree-node:before {
border-left: none;
}
.tree .el-tree > .el-tree-node:after {
border-top: none;
}
.tree .el-tree-node:before {
content: '';
position: absolute;
left: -4px;
right: auto;
border-width: 1px;
}
.tree .el-tree-node:after {
content: '';
left: -4px;
position: absolute;
right: auto;
border-width: 1px;
}
.tree .el-tree-node:before {
border-left: 1px dashed #dcdfe6;
bottom: 0px;
height: 100%;
top: -3px;
width: 1px;
left: 14px;
}
.tree .el-tree-node:after {
border-top: 1px dashed #dcdfe6;
height: 20px;
top: 13px;
left: 15px;
width: 12px;
}
.tree-color {
background: #f7fafe;
}
+251
View File
@@ -0,0 +1,251 @@
// Light 主题变量 | Dark 主题变量
:root {
// Theme color
--art-primary: 93, 135, 255;
--art-secondary: 73, 190, 255;
--art-error: 250, 137, 107;
--art-info: 83, 155, 255;
--art-success: 19, 222, 185;
--art-warning: 255, 174, 31;
--art-danger: 255, 77, 79;
// Theme background color
--art-bg-primary: 236, 242, 255;
--art-bg-secondary: 232, 247, 255;
--art-bg-success: 230, 255, 250;
--art-bg-error: 253, 237, 232;
--art-bg-info: 235, 243, 254;
--art-bg-warning: 254, 245, 229;
--art-bg-danger: 253, 237, 232;
--art-hoverColor: 246, 249, 252;
--art-grey100: 242, 246, 250;
--art-grey200: 234, 239, 244;
--art-color: #ffffff;
--art-light: #f9f9f9;
--art-dark: #1e2129;
// Background color | Hover color
--art-text-muted: #99a1b7;
--art-gray-100: #f9f9f9;
--art-gray-100-rgb: 249, 249, 249;
--art-gray-200: #f1f1f4;
--art-gray-200-rgb: 241, 241, 244;
--art-gray-300: #dbdfe9;
--art-gray-300-rgb: 219, 223, 233;
--art-gray-400: #c4cada;
--art-gray-400-rgb: 196, 202, 218;
--art-gray-500: #99a1b7;
--art-gray-500-rgb: 153, 161, 183;
--art-gray-600: #78829d;
--art-gray-600-rgb: 120, 130, 157;
--art-gray-700: #4b5675;
--art-gray-700-rgb: 75, 86, 117;
--art-gray-800: #252f4a;
--art-gray-800-rgb: 37, 47, 74;
--art-gray-900: #071437;
--art-gray-900-rgb: 7, 20, 55;
// Text color
--art-text-muted: #99a1b7;
--art-text-gray-100: #f9f9f9;
--art-text-gray-200: #f1f1f4;
--art-text-gray-300: #dbdfe9;
--art-text-gray-400: #c4cada;
--art-text-gray-500: #99a1b7;
--art-text-gray-600: #78829d;
--art-text-gray-700: #4b5675;
--art-text-gray-800: #252f4a;
--art-text-gray-900: #071437;
// Border
--art-border-color: #eaebf1;
--art-border-dashed-color: #dbdfe9;
--art-root-card-border-color: #f1f1f4;
// Shadow
--art-box-shadow-xs: 0 0.1rem 0.75rem 0.25rem rgba(0, 0, 0, 0.05);
--art-box-shadow-sm: 0 0.1rem 1rem 0.25rem rgba(0, 0, 0, 0.05);
--art-box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
--art-box-shadow-lg: 0 1rem 2rem 1rem rgba(0, 0, 0, 0.1);
// Root card box、shadow
--art-root-card-box-shadow: 0px 3px 4px 0px rgba(0, 0, 0, 0.03);
--art-root-card-border-color: #f1f1f4;
// Theme background color
--art-bg-color: #fafbfc; // 最底部背景颜色
--art-main-bg-color: #ffffff;
}
// Dark 主题变量
html.dark {
// Theme color
--art-primary: 93, 135, 255;
--art-secondary: 73, 190, 255;
--art-error: 250, 137, 107;
--art-info: 83, 155, 255;
--art-success: 19, 222, 185;
--art-warning: 255, 174, 31;
--art-danger: 255, 77, 79;
// Theme background color
--art-bg-primary: 37, 54, 98;
--art-bg-secondary: 28, 69, 93;
--art-bg-success: 27, 60, 72;
--art-bg-error: 75, 49, 61;
--art-bg-info: 34, 54, 98;
--art-bg-warning: 77, 58, 42;
--art-bg-danger: 100, 49, 61;
--art-hoverColor: 51, 63, 85;
--art-grey100: 51, 63, 85;
--art-grey200: 70, 86, 112;
--art-color: #000000;
--art-light: #1b1c22;
--art-dark: #272a34;
// Background color | Hover color
--art-text-muted: #636674;
--art-gray-100: #1b1c22;
--art-gray-100-rgb: 27, 28, 34;
--art-gray-200: #26272f;
--art-gray-200-rgb: 38, 39, 47;
--art-gray-300: #363843;
--art-gray-300-rgb: 54, 56, 67;
--art-gray-400: #464852;
--art-gray-400-rgb: 70, 72, 82;
--art-gray-500: #636674;
--art-gray-500-rgb: 99, 102, 116;
--art-gray-600: #808290;
--art-gray-600-rgb: 128, 130, 144;
--art-gray-700: #9a9cae;
--art-gray-700-rgb: 154, 156, 174;
--art-gray-800: #b5b7c8;
--art-gray-800-rgb: 181, 183, 200;
--art-gray-900: #f5f5f5;
--art-gray-900-rgb: 245, 245, 245;
// Text color
--art-text-muted: #636674;
--art-text-gray-100: #1b1c22;
--art-text-gray-200: #26272f;
--art-text-gray-300: #363843;
--art-text-gray-400: #464852;
--art-text-gray-500: #636674;
--art-text-gray-600: #808290;
--art-text-gray-700: #9a9cae;
--art-text-gray-800: #b5b7c8;
--art-text-gray-900: #f5f5f5;
// Border
--art-border-color: #26272f;
--art-border-dashed-color: #363843;
--art-root-card-border-color: #1e2027;
// Shadow
--art-box-shadow-xs: 0 0.1rem 0.75rem 0.25rem rgba(0, 0, 0, 0.05);
--art-box-shadow-sm: 0 0.1rem 1rem 0.25rem rgba(0, 0, 0, 0.05);
--art-box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
--art-box-shadow-lg: 0 1rem 2rem 1rem rgba(0, 0, 0, 0.1);
// Root card box、shadow
--art-root-card-box-shadow: none;
--art-root-card-border-color: #1e2027;
// Theme background color
--art-bg-color: #070707;
--art-main-bg-color: #161618;
}
// CSS 全局变量
:root {
--art-card-border: rgba(var(--art-gray-300-rgb), 0.6); // 卡片边框颜色
--art-card-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04); // 卡片阴影
}
// 媒体查询-设备尺寸
// notebook
$device-notebook: 1600px;
// ipad pro
$device-ipad-pro: 1180px;
// ipad
$device-ipad: 800px;
// ipad-竖屏
$device-ipad-vertical: 900px;
// mobile
$device-phone: 500px;
.bg-primary {
background-color: rgb(var(--art-bg-primary)) !important;
color: rgb(var(--art-primary)) !important;
}
.bg-secondary {
background-color: rgb(var(--art-bg-secondary)) !important;
color: rgb(var(--art-secondary)) !important;
border: 1px solid var(--art-secondary);
}
.bg-warning {
background-color: rgb(var(--art-bg-warning)) !important;
color: rgb(var(--art-warning)) !important;
}
.bg-error {
background-color: rgb(var(--art-bg-error)) !important;
color: rgb(var(--art-error)) !important;
}
.bg-success {
background-color: rgb(var(--art-bg-success)) !important;
color: rgb(var(--art-success)) !important;
}
.bg-danger {
background-color: rgb(var(--art-bg-danger)) !important;
color: rgb(var(--art-danger)) !important;
}
.bg-grey100 {
background-color: rgb(var(--art-grey100)) !important;
}
.bg-grey200 {
background-color: rgb(var(--art-grey200)) !important;
}
.bg-hoverColor {
background-color: rgb(var(--art-hoverColor)) !important;
}
.text-primary {
color: rgb(var(--art-primary)) !important;
}
.text-secondary {
color: rgb(var(--art-secondary)) !important;
}
.text-error {
color: rgb(var(--art-error)) !important;
}
.text-danger {
color: rgb(var(--art-danger)) !important;
}
.text-info {
color: rgb(var(--art-info)) !important;
}
.text-success {
color: rgb(var(--art-success)) !important;
}
.text-warning {
color: rgb(var(--art-warning)) !important;
}
+32
View File
@@ -0,0 +1,32 @@
// 自定义四点旋转SVG
export const fourDotsSpinnerSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<style>
.spinner {
transform-origin: center;
animation: rotate 1.6s linear infinite;
}
.dot {
fill: var(--main-color);
animation: fade 1.6s infinite;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.5s; }
.dot:nth-child(3) { animation-delay: 1s; }
.dot:nth-child(4) { animation-delay: 1.5s; }
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
<g class="spinner">
<circle class="dot" cx="20" cy="8" r="4"/>
<circle class="dot" cx="32" cy="20" r="4"/>
<circle class="dot" cx="20" cy="32" r="4"/>
<circle class="dot" cx="8" cy="20" r="4"/>
</g>
</svg>
`
@@ -0,0 +1,343 @@
<!-- 基础横幅组件 -->
<template>
<div
class="basic-banner art-custom-card"
:class="{ 'has-decoration': decoration }"
:style="{ backgroundColor: backgroundColor, height: height }"
@click="emit('click')"
>
<!-- 流星效果 -->
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
<span
v-for="(meteor, index) in meteors"
:key="index"
class="meteor"
:style="{
top: '-60px',
left: `${meteor.x}%`,
animationDuration: `${meteor.speed}s`,
animationDelay: `${meteor.delay}s`
}"
></span>
</div>
<div class="basic-banner__content">
<!-- title slot -->
<slot name="title">
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
</slot>
<!-- subtitle slot -->
<slot name="subtitle">
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
subtitle
}}</p>
</slot>
<!-- button slot -->
<slot name="button">
<div
v-if="buttonConfig?.show"
class="basic-banner__button"
:style="{
backgroundColor: buttonColor,
color: buttonTextColor,
borderRadius: buttonRadius
}"
@click.stop="emit('buttonClick')"
>
{{ buttonConfig?.text }}
</div>
</slot>
<!-- default slot -->
<slot></slot>
<!-- background image -->
<img
v-if="imageConfig.src"
class="basic-banner__background-image"
:src="imageConfig.src"
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
loading="lazy"
alt="背景图片"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
defineOptions({ name: 'ArtBasicBanner' })
// 流星对象接口定义
interface Meteor {
/** 流星的水平位置(百分比) */
x: number
/** 流星划过的速度 */
speed: number
/** 流星出现的延迟时间 */
delay: number
}
// 按钮配置接口定义
interface ButtonConfig {
/** 是否启用按钮 */
show: boolean
/** 按钮文本 */
text: string
/** 按钮背景色 */
color?: string
/** 按钮文字颜色 */
textColor?: string
/** 按钮圆角大小 */
radius?: string
}
// 流星效果配置接口定义
interface MeteorConfig {
/** 是否启用流星效果 */
enabled: boolean
/** 流星数量 */
count?: number
}
// 背景图片配置接口定义
interface ImageConfig {
/** 图片源地址 */
src: string
/** 图片宽度 */
width?: string
/** 距底部距离 */
bottom?: string
/** 距右侧距离 */
right?: string // 距右侧距离
}
// 组件属性接口定义
interface Props {
/** 横幅高度 */
height?: string
/** 标题文本 */
title?: string
/** 副标题文本 */
subtitle?: string
/** 背景颜色 */
backgroundColor?: string
/** 是否显示装饰效果 */
decoration?: boolean
/** 按钮配置 */
buttonConfig?: ButtonConfig
/** 流星配置 */
meteorConfig?: MeteorConfig
/** 图片配置 */
imageConfig?: ImageConfig
/** 标题颜色 */
titleColor?: string
/** 副标题颜色 */
subtitleColor?: string
}
// 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), {
height: '11rem',
titleColor: 'white',
subtitleColor: 'white',
backgroundColor: 'var(--el-color-primary-light-3)',
decoration: true,
buttonConfig: () => ({
show: true,
text: '查看',
color: '#fff',
textColor: '#333',
radius: '6px'
}),
meteorConfig: () => ({ enabled: false, count: 10 }),
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 整体点击事件
(e: 'buttonClick'): void // 按钮点击事件
}>()
// 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
// 流星数据初始化
const meteors = ref<Meteor[]>([])
onMounted(() => {
if (props.meteorConfig?.enabled) {
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
}
})
/**
* 生成流星数据数组
* @param count 流星数量
* @returns 流星数据数组
*/
function generateMeteors(count: number): Meteor[] {
// 计算每个流星的区域宽度
const segmentWidth = 100 / count
return Array.from({ length: count }, (_, index) => {
// 计算流星起始位置
const segmentStart = index * segmentWidth
// 在区域内随机生成x坐标
const x = segmentStart + Math.random() * segmentWidth
// 随机决定流星速度快慢
const isSlow = Math.random() > 0.5
return {
x,
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
delay: Math.random() * 5
}
})
}
</script>
<style lang="scss" scoped>
.basic-banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
overflow: hidden;
color: white;
border-radius: calc(var(--custom-radius) + 2px) !important;
&__content {
position: relative;
z-index: 1;
}
&__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
&__subtitle {
position: relative;
z-index: 10;
margin: 0 0 1.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
&__button {
box-sizing: border-box;
display: inline-block;
min-width: 80px;
height: var(--el-component-custom-height);
padding: 0 12px;
font-size: 14px;
line-height: var(--el-component-custom-height);
text-align: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
&__background-image {
position: absolute;
right: 0;
bottom: -3rem;
z-index: 0;
width: 12rem;
}
&.has-decoration::after {
position: absolute;
right: -10%;
bottom: -20%;
width: 60%;
height: 140%;
content: '';
background: rgb(255 255 255 / 10%);
border-radius: 30%;
transform: rotate(-20deg);
}
&__meteors {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
.meteor {
position: absolute;
width: 2px;
height: 60px;
background: linear-gradient(
to top,
rgb(255 255 255 / 40%),
rgb(255 255 255 / 10%),
transparent
);
opacity: 0;
transform-origin: top left;
animation-name: meteor-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
&::before {
position: absolute;
right: 0;
bottom: 0;
width: 2px;
height: 2px;
content: '';
background: rgb(255 255 255 / 50%);
}
}
}
}
@keyframes meteor-fall {
0% {
opacity: 1;
transform: translate(0, -60px) rotate(-45deg);
}
100% {
opacity: 0;
transform: translate(400px, 340px) rotate(-45deg);
}
}
@media (max-width: $device-phone) {
.basic-banner {
box-sizing: border-box;
justify-content: flex-start;
padding: 16px;
&__title {
font-size: 1.4rem;
}
&__background-image {
display: none;
}
&.has-decoration::after {
display: none;
}
}
}
</style>
@@ -0,0 +1,187 @@
<!-- 卡片横幅组件 -->
<template>
<div class="card-banner art-custom-card" :style="{ height: props.height }">
<div class="banner-content">
<div class="banner-icon">
<img :src="props.image" :alt="props.title" />
</div>
<div class="banner-text">
<p class="banner-title">{{ props.title }}</p>
<p class="banner-description">{{ props.description }}</p>
</div>
<div class="banner-buttons">
<div
v-if="props.cancelButton?.show"
class="banner-button cancel-button"
:style="{
backgroundColor: props.cancelButton?.color,
color: props.cancelButton?.textColor
}"
@click="handleCancel"
>
{{ props.cancelButton?.text }}
</div>
<div
v-if="props.button?.show"
class="banner-button"
:style="{ backgroundColor: props.button?.color, color: props.button?.textColor }"
@click="handleClick"
>
{{ props.button?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 导入默认图标
import defaultIcon from '@imgs/3d/icon1.webp'
defineOptions({ name: 'ArtCardBanner' })
// 定义卡片横幅组件的属性接口
interface CardBannerProps {
/** 高度 */
height?: string
/** 图片路径 */
image?: string
/** 标题文本 */
title: string
/** 描述文本 */
description: string
/** 主按钮配置 */
button?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
/** 取消按钮配置 */
cancelButton?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
}
// 定义组件属性默认值
const props = withDefaults(defineProps<CardBannerProps>(), {
height: '24rem',
image: defaultIcon,
title: '',
description: '',
// 主按钮默认配置
button: () => ({
show: true,
text: '查看详情',
color: 'var(--main-color)',
textColor: '#fff'
}),
// 取消按钮默认配置
cancelButton: () => ({
show: false,
text: '取消',
color: '#f5f5f5',
textColor: '#666'
})
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 主按钮点击事件
(e: 'cancel'): void // 取消按钮点击事件
}>()
// 主按钮点击处理函数
const handleClick = () => {
emit('click')
}
// 取消按钮点击处理函数
const handleCancel = () => {
emit('cancel')
}
</script>
<style lang="scss" scoped>
.card-banner {
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 1.5rem;
background-color: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) + 2px) !important;
.banner-content {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
text-align: center;
}
.banner-icon {
width: 180px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.banner-text {
box-sizing: border-box;
padding: 0 16px;
.banner-title {
margin-bottom: 8px;
font-size: 18px;
font-weight: 600;
color: var(--art-text-gray-800);
}
.banner-description {
margin: 0;
font-size: 14px;
color: var(--art-text-gray-600);
}
}
.banner-buttons {
display: flex;
gap: 12px;
align-items: center;
}
.banner-button {
display: inline-block;
height: var(--el-component-custom-height);
padding: 0 12px;
font-size: 14px;
line-height: var(--el-component-custom-height);
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
&.cancel-button {
border: 1px solid #dcdfe6;
}
}
}
</style>
@@ -0,0 +1,63 @@
<!-- 返回顶部 -->
<template>
<div>
<div class="back-to-top" v-show="showButton" @click="scrollToTop">
<div class="back-to-top-btn">
<i class="iconfont-sys">&#xe864;</i>
<p>顶部</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useCommon } from '@/composables/useCommon'
defineOptions({ name: 'ArtBackToTop' })
const { scrollToTop } = useCommon()
const { y } = useWindowScroll()
const showButton = ref(false)
const scrollThreshold = 2000 // 设置阈值
// 监听滚动位置
watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold
})
</script>
<style lang="scss" scoped>
.back-to-top {
position: fixed;
right: 40px;
bottom: 60px;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
color: var(--art-gray-700);
text-align: center;
cursor: pointer;
border: 1px solid var(--art-border-color);
border-radius: 6px;
transition: all 0.3s;
i {
display: block;
line-height: 1;
}
p {
margin-top: 2px;
font-size: 10px;
line-height: 1;
}
&:hover {
background-color: var(--art-text-gray-200);
border-color: var(--art-border-dashed-color);
}
}
</style>
@@ -0,0 +1,280 @@
<!-- 图标选择器 -->
<template>
<div class="icon-selector">
<div
class="select"
@click="handleClick"
:style="{ width: props.width }"
:class="[size, { 'is-disabled': disabled }, { 'has-icon': selectValue }]"
>
<div class="icon">
<i
:class="`iconfont-sys ${selectValue}`"
v-show="props.iconType === IconTypeEnum.CLASS_NAME"
></i>
<i
class="iconfont-sys"
v-html="selectValue"
v-show="props.iconType === IconTypeEnum.UNICODE"
></i>
</div>
<div class="text"> {{ props.text }} </div>
<div class="arrow">
<i class="iconfont-sys arrow-icon">&#xe709;</i>
<i class="iconfont-sys clear-icon" @click.stop="clearIcon">&#xe83a;</i>
</div>
</div>
<el-dialog title="选择图标" width="40%" v-model="visible" align-center>
<el-scrollbar height="400px">
<ul class="icons-list" v-show="activeName === 'icons'">
<li v-for="icon in iconsList" :key="icon.className" @click="selectorIcon(icon)">
<i
:class="`iconfont-sys ${icon.className}`"
v-show="iconType === IconTypeEnum.CLASS_NAME"
></i>
<i
class="iconfont-sys"
v-html="icon.unicode"
v-show="iconType === IconTypeEnum.UNICODE"
></i>
</li>
</ul>
</el-scrollbar>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" @click="visible = false">确 定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { IconTypeEnum } from '@/enums/appEnum'
import { extractIconClasses, type IconfontType } from '@/utils/constants'
defineOptions({ name: 'ArtIconSelector' })
// 组件大小类型
type ComponentSize = 'large' | 'default' | 'small'
// Props 接口定义
interface Props {
/** 图标类型 */
iconType?: IconTypeEnum
/** v-model 绑定的图标值 */
modelValue?: string
/** 显示文本 */
text?: string
/** 组件宽度 */
width?: string
/** 组件大小 */
size?: ComponentSize
/** 是否禁用 */
disabled?: boolean
}
// Emits 接口定义
interface Emits {
'update:modelValue': [value: string]
getIcon: [value: string]
}
// 使用 withDefaults 定义 props
const props = withDefaults(defineProps<Props>(), {
iconType: IconTypeEnum.CLASS_NAME,
modelValue: '',
text: '图标选择器',
width: '200px',
size: 'default',
disabled: false
})
// 定义 emits
const emits = defineEmits<Emits>()
// 响应式数据
const selectValue = ref<string>(props.modelValue)
const visible = ref<boolean>(false)
const activeName = ref<string>('icons')
// 图标列表 - 使用计算属性优化性能
const iconsList = computed<IconfontType[]>(() => extractIconClasses())
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal: string) => {
selectValue.value = newVal
},
{ immediate: true }
)
// 选择图标
const selectorIcon = (icon: IconfontType): void => {
const iconValue =
props.iconType === IconTypeEnum.CLASS_NAME ? icon.className : icon.unicode || ''
selectValue.value = iconValue
visible.value = false
// 发射 v-model 更新事件和自定义事件
emits('update:modelValue', iconValue)
emits('getIcon', iconValue)
}
// 处理点击事件
const handleClick = (): void => {
if (!props.disabled) {
visible.value = true
}
}
// 清除图标
const clearIcon = (): void => {
selectValue.value = ''
// 发射 v-model 更新事件和自定义事件
emits('update:modelValue', '')
emits('getIcon', '')
}
// 计算属性:当前图标类型(用于模板中的判断)
const iconType = computed<IconTypeEnum>(() => props.iconType)
</script>
<style lang="scss" scoped>
.icon-selector {
width: 100%;
.select {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--el-component-custom-height);
padding: 0 15px;
cursor: pointer;
border: 1px solid var(--art-border-dashed-color);
border-radius: calc(var(--custom-radius) / 3 + 2px) !important;
transition: border 0.3s;
@media (width <= 500px) {
width: 100% !important;
}
&.large {
height: 40px;
}
&.small {
height: 24px;
}
&:hover:not(.is-disabled).has-icon {
.arrow-icon {
display: none;
}
.clear-icon {
display: block !important;
}
}
&:hover {
border-color: var(--art-text-gray-400);
}
.icon {
display: flex;
align-items: center;
width: 20px;
color: var(--art-gray-700);
i {
display: block;
margin: 0 auto;
font-size: 16px;
}
}
.text {
display: flex;
display: inline-block;
align-items: center;
width: 50%;
font-size: 14px;
color: var(--art-gray-600);
@include ellipsis();
@media (width <= 500px) {
display: none;
}
}
.arrow {
display: flex;
align-items: center;
height: calc(100% - 2px);
i {
font-size: 13px;
color: var(--art-gray-600);
}
.clear-icon {
display: none;
}
}
&.is-disabled {
cursor: not-allowed;
background-color: var(--el-disabled-bg-color);
border-color: var(--el-border-color-lighter);
.icon,
.text,
.arrow {
color: var(--el-text-color-placeholder);
}
&:hover {
border-color: var(--el-border-color-lighter);
}
}
}
.icons-list {
display: grid;
grid-template-columns: repeat(10, 1fr);
border-top: 1px solid var(--art-border-color);
border-left: 1px solid var(--art-border-color);
li {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
aspect-ratio: 1 / 1;
color: var(--art-gray-600);
text-align: center;
border-right: 1px solid var(--art-border-color);
border-bottom: 1px solid var(--art-border-color);
&:hover {
cursor: pointer;
background: var(--art-gray-100);
}
i {
font-size: 22px;
color: var(--art-gray-800);
}
}
}
}
</style>
@@ -0,0 +1,34 @@
<!-- 系统logo -->
<template>
<div class="art-logo">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtLogo' })
interface Props {
/** logo 大小 */
size?: number | string
}
const props = withDefaults(defineProps<Props>(), {
size: 36
})
const logoStyle = computed(() => ({ width: `${props.size}px` }))
</script>
<style lang="scss" scoped>
.art-logo {
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
}
}
</style>
@@ -0,0 +1,175 @@
<!-- 柱状图卡片 -->
<template>
<div class="bar-chart-card art-custom-card" :style="{ height: `${height}rem` }">
<div class="card-body">
<div class="chart-header">
<div class="metric">
<p class="value">{{ value }}</p>
<p class="label">{{ label }}</p>
</div>
<div
class="percentage"
:class="{ 'is-increase': percentage > 0, 'is-mini-chart': isMiniChart }"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div class="date" v-if="date" :class="{ 'is-mini-chart': isMiniChart }">{{ date }}</div>
</div>
<div
ref="chartRef"
class="chart-container"
:class="{ 'is-mini-chart': isMiniChart }"
:style="{ height: `calc(${height}rem - 5rem)` }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useChart, useChartOps } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
const { chartRef, isDark, initChart } = useChart()
defineOptions({ name: 'ArtBarChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 +(绿色)-(红色) */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 图表数据 */
chartData: number[]
/** 柱状图宽度 */
barWidth?: string
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11,
barWidth: '26%'
})
const options: () => EChartsOption = () => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 15,
left: 0
},
xAxis: {
type: 'category',
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'bar',
barWidth: props.barWidth,
itemStyle: {
color: computedColor,
borderRadius: 2
}
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.bar-chart-card {
position: relative;
overflow: hidden;
color: #303133;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
transition: 0.3s;
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 20px 0;
margin-bottom: 20px;
}
.metric {
.value {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 1.2;
color: var(--art-text-gray-900);
}
.label {
margin: 4px 0 0;
font-size: 14px;
color: var(--art-text-gray-600);
}
}
.percentage {
font-size: 14px;
font-weight: 500;
color: #f56c6c;
&.is-increase {
color: #67c23a;
}
&.is-mini-chart {
position: absolute;
bottom: 20px;
}
}
.date {
position: absolute;
right: 20px;
bottom: 20px;
font-size: 12px;
color: var(--art-text-gray-600);
}
.chart-container {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: calc(100% - 22px);
height: 100px;
margin: auto;
&.is-mini-chart {
position: absolute;
inset: 25px 20px auto auto;
width: 40%;
height: 60px !important;
}
}
}
</style>
@@ -0,0 +1,144 @@
<!-- 数据列表卡片 -->
<template>
<div class="basic-list-card">
<div class="art-card art-custom-card">
<div class="card-header">
<p class="card-title">{{ title }}</p>
<p class="card-subtitle">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="list-item">
<div class="item-icon" :class="item.class" v-if="item.icon">
<i class="iconfont-sys" v-html="item.icon"></i>
</div>
<div class="item-content">
<div class="item-title">{{ item.title }}</div>
<div class="item-status">{{ item.status }}</div>
</div>
<div class="item-time">{{ item.time }}</div>
</div>
</ElScrollbar>
<ElButton class="more-btn" v-if="showMoreButton" v-ripple @click="handleMore"
>查看更多</ElButton
>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtDataListCard' })
interface Props {
/** 数据列表 */
list: Activity[]
/** 标题 */
title: string
/** 副标题 */
subtitle?: string
/** 最大显示数量 */
maxCount?: number
/** 是否显示更多按钮 */
showMoreButton?: boolean
}
interface Activity {
/** 标题 */
title: string
/** 状态 */
status: string
/** 时间 */
time: string
/** 样式类名 */
class: string
/** 图标 */
icon: string
}
const ITEM_HEIGHT = 66
const DEFAULT_MAX_COUNT = 5
const props = withDefaults(defineProps<Props>(), {
maxCount: DEFAULT_MAX_COUNT
})
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
const emit = defineEmits<{
/** 点击更多按钮事件 */
(e: 'more'): void
}>()
const handleMore = () => emit('more')
</script>
<style lang="scss" scoped>
.basic-list-card {
.art-card {
padding: 30px;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
.card-header {
padding-bottom: 15px;
.card-title {
font-size: 18px;
font-weight: 500;
color: var(--art-gray-900);
}
.card-subtitle {
font-size: 14px;
color: var(--art-gray-500);
}
}
}
.list-item {
display: flex;
align-items: center;
padding: 12px 0;
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-right: 12px;
border-radius: 8px;
i {
font-size: 20px;
}
}
.item-content {
flex: 1;
.item-title {
margin-bottom: 4px;
font-size: 15px;
color: var(--art-gray-900);
}
.item-status {
font-size: 12px;
color: var(--art-gray-600);
}
}
.item-time {
margin-left: 12px;
font-size: 12px;
color: var(--art-gray-500);
}
}
.more-btn {
width: 100%;
margin-top: 25px;
text-align: center;
}
}
</style>
@@ -0,0 +1,209 @@
<!-- 环型图卡片 -->
<template>
<div class="donut-chart-card art-custom-card" :style="{ height: `${height}rem` }">
<div class="card-body">
<div class="card-content">
<div class="data-section">
<p class="title">{{ title }}</p>
<div>
<p class="value">{{ formatNumber(value) }}</p>
<div class="percentage" :class="{ 'is-increase': percentage > 0 }">
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
<span v-if="percentageLabel">{{ percentageLabel }}</span>
</div>
</div>
<div class="chart-legend">
<span class="legend-item current" v-if="currentValue">{{ currentValue }}</span>
<span class="legend-item previous" v-if="previousValue">{{ previousValue }}</span>
</div>
</div>
<div class="chart-section">
<div ref="chartRef" class="chart-container"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EChartsOption } from 'echarts'
import { useChart, useChartOps } from '@/composables/useChart'
const { chartRef, isDark, initChart } = useChart()
defineOptions({ name: 'ArtDonutChartCard' })
interface Props {
/** 数值 */
value: number
/** 标题 */
title: string
/** 百分比 */
percentage: number
/** 百分比标签 */
percentageLabel?: string
/** 当前年份 */
currentValue?: string
/** 去年年份 */
previousValue?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 半径 */
radius?: [string, string]
/** 数据 */
data: [number, number]
}
const props = withDefaults(defineProps<Props>(), {
height: 9,
radius: () => ['70%', '90%'],
data: () => [0, 0]
})
const formatNumber = (num: number) => {
return num.toLocaleString()
}
const options: () => EChartsOption = () => {
const computedColor = props.color || useChartOps().themeColor
return {
series: [
{
type: 'pie',
radius: props.radius,
avoidLabelOverlap: false,
label: {
show: false
},
data: [
{
value: props.data[0],
name: props.currentValue,
itemStyle: { color: computedColor }
},
{
value: props.data[1],
name: props.previousValue,
itemStyle: { color: '#e6e8f7' }
}
]
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.donut-chart-card {
overflow: hidden;
color: #303133;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
transition: 0.3s;
.card-body {
box-sizing: border-box;
display: flex;
height: 100%;
padding: 20px;
}
.card-content {
display: flex;
gap: 20px;
align-items: flex-start;
width: 100%;
}
.data-section {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.chart-section {
display: flex;
flex: 1;
align-items: center;
max-width: 200px;
height: 100%;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 500;
line-height: 1.2;
color: var(--art-text-gray-900);
}
.value {
margin: 0;
margin-top: 10px;
font-size: 20px;
font-weight: 500;
line-height: 1.2;
color: var(--art-text-gray-900);
}
.percentage {
margin-top: 5px;
font-size: 12px;
font-weight: 500;
color: #f56c6c;
&.is-increase {
color: #67c23a;
}
}
.chart-container {
width: 100%;
height: 120px;
}
.chart-legend {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 12px;
color: #909399;
.legend-item {
position: relative;
padding-left: 16px;
&::before {
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
content: '';
border-radius: 50%;
transform: translateY(-50%);
}
&.current::before {
background-color: var(--main-color);
}
&.previous::before {
background-color: #e6e8f7;
}
}
}
}
</style>
@@ -0,0 +1,155 @@
<!-- 图片卡片 -->
<template>
<div class="image-card" @click="handleClick">
<ElCard :body-style="{ padding: '0px' }" shadow="hover" class="art-custom-card">
<div class="image-wrapper">
<ElImage :src="props.imageUrl" fit="cover" loading="lazy">
<template #placeholder>
<div class="image-placeholder">
<ElIcon><Picture /></ElIcon>
</div>
</template>
</ElImage>
<div class="read-time" v-if="props.readTime"> {{ props.readTime }} 阅读 </div>
</div>
<div class="content">
<div class="category" v-if="props.category">
{{ props.category }}
</div>
<p class="title">{{ props.title }}</p>
<div class="stats">
<span class="views" v-if="props.views">
<ElIcon><View /></ElIcon>
{{ props.views }}
</span>
<span class="comments" v-if="props.comments">
<ElIcon><ChatLineRound /></ElIcon>
{{ props.comments }}
</span>
<span class="date">{{ props.date }}</span>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
defineOptions({ name: 'ArtImageCard' })
interface Props {
/** 图片地址 */
imageUrl: string
/** 标题 */
title: string
/** 分类 */
category?: string
/** 阅读时间 */
readTime?: string
/** 浏览量 */
views?: number
/** 评论数 */
comments?: number
/** 日期 */
date?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'click', card: Props): void
}>()
const handleClick = () => {
emit('click', props)
}
</script>
<style lang="scss" scoped>
.image-card {
width: 100%;
cursor: pointer;
.art-custom-card {
border-radius: calc(var(--custom-radius) + 2px) !important;
}
.image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16/10; // 图片宽高比 16:10
overflow: hidden;
.el-image {
width: 100%;
height: 100%;
transition: transform 0.3s ease-in-out;
&:hover {
transform: scale(1.05);
}
}
.read-time {
position: absolute;
right: 15px;
bottom: 15px;
padding: 4px 8px;
font-size: 12px;
background: var(--art-gray-200);
border-radius: 4px;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #f5f7fa;
}
}
.content {
padding: 16px;
.category {
display: inline-block;
padding: 2px 8px;
margin-bottom: 8px;
font-size: 12px;
background: var(--art-gray-200);
border-radius: 4px;
}
.title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 500;
line-height: 1.4;
color: var(--art-text-gray-900);
}
.stats {
display: flex;
gap: 16px;
align-items: center;
font-size: 13px;
color: var(--art-text-gray-600);
.views,
.comments {
display: flex;
gap: 4px;
align-items: center;
}
.el-icon {
font-size: 16px;
}
}
}
}
</style>
@@ -0,0 +1,197 @@
<!-- 折线图卡片 -->
<template>
<div class="line-chart-card art-custom-card" :style="{ height: `${height}rem` }">
<div class="card-body">
<div class="chart-header">
<div class="metric">
<p class="value">{{ value }}</p>
<p class="label">{{ label }}</p>
</div>
<div
class="percentage"
:class="{ 'is-increase': percentage > 0, 'is-mini-chart': isMiniChart }"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div class="date" v-if="date" :class="{ 'is-mini-chart': isMiniChart }">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="chart-container"
:class="{ 'is-mini-chart': isMiniChart }"
:style="{ height: `calc(${height}rem - 5rem)` }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChart, useChartOps } from '@/composables/useChart'
import { EChartsOption } from 'echarts'
const { chartRef, isDark, initChart } = useChart()
defineOptions({ name: 'ArtLineChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 是否显示区域颜色 */
showAreaColor?: boolean
/** 图表数据 */
chartData: number[]
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11
})
const options: () => EChartsOption = () => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
color: computedColor
},
areaStyle: props.showAreaColor
? {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: props.color
? hexToRgba(props.color, 0.2).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
},
{
offset: 1,
color: props.color
? hexToRgba(props.color, 0.01).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
}
])
}
: undefined
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
<style lang="scss" scoped>
.line-chart-card {
position: relative;
overflow: hidden;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 20px 0;
margin-bottom: 10px;
}
.metric {
.value {
font-size: 1.7rem;
font-weight: 500;
line-height: 1;
color: var(--art-text-gray-900);
}
.label {
margin: 4px 0 0;
font-size: 14px;
color: var(--art-text-gray-600);
}
}
.percentage {
font-size: 14px;
font-weight: 500;
color: #f56c6c;
&.is-increase {
color: #67c23a;
}
&.is-mini-chart {
position: absolute;
bottom: 20px;
}
}
.date {
position: absolute;
right: 20px;
bottom: 20px;
font-size: 12px;
color: var(--art-text-gray-600);
}
.chart-container {
position: absolute;
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
width: 100%;
height: 90px;
&.is-mini-chart {
position: absolute;
top: 25px;
right: 40px;
left: auto;
width: 40%;
height: 60px !important;
}
}
}
</style>
@@ -0,0 +1,153 @@
<!-- 进度条卡片 -->
<template>
<div class="progress-card art-custom-card">
<div class="progress-info" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
<div class="left">
<i
v-if="icon"
class="iconfont-sys"
v-html="icon"
:style="{
color: iconColor,
backgroundColor: iconBgColor,
fontSize: iconSize + 'px',
borderRadius: iconBgRadius + 'px'
}"
></i>
</div>
<div class="right">
<CountTo
:key="percentage"
class="percentage"
:style="{ textAlign: icon ? 'right' : 'left' }"
:endVal="percentage"
:duration="2000"
suffix="%"
/>
<p class="title">{{ title }}</p>
</div>
</div>
<ElProgress
:percentage="currentPercentage"
:stroke-width="strokeWidth"
:show-text="false"
:color="color"
/>
</div>
</template>
<script setup lang="ts">
import { CountTo } from 'vue3-count-to'
defineOptions({ name: 'ArtProgressCard' })
interface Props {
/** 进度百分比 */
percentage: number
/** 标题 */
title: string
/** 颜色 */
color?: string
/** 图标 */
icon?: string
/** 图标颜色 */
iconColor?: string
/** 图标背景颜色 */
iconBgColor?: string
/** icon 背景圆角大小 */
iconBgRadius?: number
/** 图标大小 */
iconSize?: number
/** 进度条宽度 */
strokeWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5,
iconBgRadius: 8,
color: '#67C23A'
})
const animationDuration = 500
const currentPercentage = ref(0)
const animateProgress = () => {
const startTime = Date.now()
const startValue = currentPercentage.value
const endValue = props.percentage
const animate = () => {
const currentTime = Date.now()
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / animationDuration, 1)
currentPercentage.value = startValue + (endValue - startValue) * progress
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
onMounted(() => {
animateProgress()
})
// 当 percentage 属性变化时重新执行动画
watch(
() => props.percentage,
() => {
animateProgress()
}
)
</script>
<style lang="scss" scoped>
.progress-card {
display: flex;
flex-direction: column;
justify-content: center;
height: 8rem;
padding: 0 20px;
background-color: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) + 4px);
.progress-info {
display: flex;
align-items: center;
margin-block-end: 15px;
.left {
i {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
background-color: var(--art-gray-300);
}
}
.right {
.percentage {
display: block;
margin-block-end: 4px;
font-size: 1.5rem;
font-weight: 600;
color: var(--art-gray-900);
}
.title {
font-size: 0.875rem;
color: var(--art-gray-600);
}
}
}
:deep(.el-progress-bar__outer) {
background-color: rgb(240 240 240);
}
}
</style>
@@ -0,0 +1,130 @@
<!-- 统计卡片 -->
<template>
<div class="stats-card art-custom-card" :style="{ backgroundColor: backgroundColor }">
<div
v-if="icon"
class="stats-card__icon"
:style="{ backgroundColor: iconBgColor, borderRadius: iconBgRadius + 'px' }"
>
<i
class="iconfont-sys"
v-html="icon"
:style="{
color: iconColor,
fontSize: iconSize + 'px'
}"
></i>
</div>
<div class="stats-card__content">
<p class="stats-card__title" :style="{ color: textColor }" v-if="title">
{{ title }}
</p>
<CountTo v-if="count" class="stats-card__count" :endVal="count" :duration="1000"></CountTo>
<p class="stats-card__description" :style="{ color: textColor }" v-if="description">{{
description
}}</p>
</div>
<div class="stats-card__arrow" v-if="showArrow">
<i class="iconfont-sys">&#xe703;</i>
</div>
</div>
</template>
<script setup lang="ts">
import { CountTo } from 'vue3-count-to'
defineOptions({ name: 'ArtStatsCard' })
interface StatsCardProps {
/** 图标 */
icon?: string
/** 标题 */
title?: string
/** 数值 */
count?: number
/** 描述 */
description: string
/** 图标颜色 */
iconColor?: string
/** 图标背景颜色 */
iconBgColor?: string
/** 图标圆角大小 */
iconBgRadius?: number
/** 图标大小 */
iconSize?: number
/** 文本颜色 */
textColor?: string
/** 背景颜色 */
backgroundColor?: string
/** 是否显示箭头 */
showArrow?: boolean
}
withDefaults(defineProps<StatsCardProps>(), {
iconSize: 30,
iconBgRadius: 50
})
</script>
<style lang="scss" scoped>
.stats-card {
display: flex;
align-items: center;
height: 8rem;
padding: 0 20px;
cursor: pointer;
background-color: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) + 4px) !important;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
margin-right: 16px;
border-radius: 50%;
i {
font-size: 30px;
}
}
&__content {
flex: 1;
}
&__title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: var(--art-gray-900);
}
&__count {
margin: 0;
font-size: 28px;
font-weight: 500;
color: var(--art-gray-900);
}
&__description {
margin: 4px 0 0;
font-size: 14px;
color: var(--art-gray-600);
opacity: 0.9;
}
&__arrow {
i {
font-size: 18px;
color: var(--art-gray-600);
}
}
}
</style>
@@ -0,0 +1,124 @@
<!-- 时间轴列表卡片 -->
<template>
<div class="timeline-list-card">
<div class="art-card art-custom-card">
<div class="card-header">
<p class="card-title">{{ title }}</p>
<p class="card-subtitle">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<ElTimeline>
<ElTimelineItem
v-for="item in list"
:key="item.time"
:timestamp="item.time"
:placement="TIMELINE_PLACEMENT"
:color="item.status"
:center="true"
>
<div class="timeline-item">
<div class="timeline-content">
<span class="timeline-text">{{ item.content }}</span>
<span v-if="item.code" class="timeline-code"> #{{ item.code }} </span>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtTimelineListCard' })
// 常量配置
const ITEM_HEIGHT = 65
const TIMELINE_PLACEMENT = 'top'
const DEFAULT_MAX_COUNT = 5
interface TimelineItem {
/** 时间 */
time: string
/** 状态颜色 */
status: string
/** 内容 */
content: string
/** 代码标识 */
code?: string
}
interface Props {
/** 时间轴列表数据 */
list: TimelineItem[]
/** 标题 */
title: string
/** 副标题 */
subtitle?: string
/** 最大显示数量 */
maxCount?: number
}
// Props 定义和验证
const props = withDefaults(defineProps<Props>(), {
title: '',
subtitle: '',
maxCount: DEFAULT_MAX_COUNT
})
// 计算最大高度
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
</script>
<style lang="scss" scoped>
.timeline-list-card {
.art-card {
padding: 30px;
background-color: var(--art-main-bg-color);
border-radius: var(--custom-radius);
.card-header {
padding-bottom: 15px;
.card-title {
font-size: 18px;
font-weight: 500;
color: var(--art-gray-900);
}
.card-subtitle {
font-size: 14px;
color: var(--art-gray-500);
}
}
}
:deep(.el-timeline-item__tail) {
left: 5px;
}
:deep(.el-timeline-item__node--normal) {
left: 0;
}
.timeline-item {
display: flex;
gap: 12px;
align-items: center;
}
.timeline-content {
display: flex;
gap: 8px;
align-items: center;
.timeline-text {
font-size: 14px;
}
.timeline-code {
font-size: 0.9em;
color: var(--el-color-primary);
}
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<template>
<div ref="chartRef" class="bar-chart" :style="{ height: props.height }"></div>
</template>
<script setup lang="ts">
import { useChartOps, useChart } from '@/composables/useChart'
import { getCssVar } from '@/utils/ui'
import { EChartsOption } from 'echarts'
import * as echarts from 'echarts'
const {
chartRef,
isDark,
initChart,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle
} = useChart()
interface Props {
data?: number[]
xAxisData?: string[]
color?: string
height?: string
barWidth?: string | number
showAxisLabel?: boolean
showAxisLine?: boolean
showSplitLine?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
color: '',
height: useChartOps().chartHeight,
barWidth: '40%',
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true
})
const options: () => EChartsOption = () => {
const computedColor =
props.color ||
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: getCssVar('--el-color-primary-light-4')
},
{
offset: 1,
color: getCssVar('--el-color-primary')
}
])
return {
grid: {
top: 15,
right: 0,
bottom: 0,
left: 0,
containLabel: true
},
tooltip: {
trigger: 'item'
},
xAxis: {
type: 'category',
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel)
},
yAxis: {
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
},
series: [
{
data: props.data,
type: 'bar',
itemStyle: {
borderRadius: 4,
color: computedColor
},
barWidth: props.barWidth
}
]
}
}
watch(isDark, () => {
initChart(options())
})
onMounted(() => {
initChart(options())
})
</script>
@@ -0,0 +1,176 @@
<template>
<div class="chart-container">
<!-- 左侧 Y 轴 -->
<div class="y-axis">
<div v-for="(value, index) in yAxisLabels" :key="index" class="y-axis-label">
{{ value }}
</div>
</div>
<div class="bars-container">
<!-- 柱状图组 -->
<div v-for="(item, index) in props.topData" :key="index" class="bar-group">
<div class="bars-wrapper">
<!-- 上半部分柱子 -->
<div
class="bar bar-top"
:style="{
height: `${getBarHeight(item, maxValue)}%`,
background: props.topColor || defaultTopColor
}"
></div>
<!-- 下半部分柱子 -->
<div
class="bar bar-bottom"
:style="{
height: `${getBarHeight(Math.abs(props.bottomData[index]), maxValue)}%`,
background: props.bottomColor || defaultBottomColor
}"
></div>
</div>
<!-- X轴标签 -->
<div class="x-axis-label">{{ props.xAxisData[index] }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
topData: number[]
bottomData: number[]
xAxisData: string[]
topColor?: string
bottomColor?: string
height?: string
barWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
topData: () => [2.5, 3, 2.8, 2.3, 2],
bottomData: () => [-2, -2.2, -1.8, -3, -2.5],
xAxisData: () => [],
height: '300px',
barWidth: 20
})
// 默认渐变色
const defaultTopColor = 'var(--el-color-primary-light-1)'
const defaultBottomColor = 'rgb(var(--art-secondary), 1)'
// 计算最大值用于缩放
const maxValue = computed(() => {
const allValues = [...props.topData, ...props.bottomData.map(Math.abs)]
return Math.max(...allValues)
})
// 生成Y轴刻度
const yAxisLabels = computed(() => {
const max = Math.ceil(maxValue.value)
return [max, max / 2, 0, max / 2, max]
})
// 计算柱子高度的百分比
const getBarHeight = (value: number, max: number) => {
return (value / max) * 40 // 40% 作为最大高度,留出空间给其他元素
}
</script>
<style scoped lang="scss">
.chart-container {
position: relative;
display: flex;
width: 100%;
height: v-bind('props.height');
padding: 20px 0;
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 40px;
padding-right: 10px;
&-label {
font-size: 12px;
color: #999;
}
}
.bars-container {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: space-around;
padding: 20px 0;
margin-left: -10px;
.bar-group {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
height: 100%;
.bars-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.bar {
position: absolute;
width: v-bind('props.barWidth + "px"');
border-radius: v-bind('props.barWidth / 2 + "px"');
transition: height 1s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: center;
&-top {
bottom: calc(50% + 2px);
transform: scaleY(0);
animation: growUp 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
&-bottom {
top: calc(50% + 2px);
transform: scaleY(0);
animation: growDown 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
}
.x-axis-label {
position: absolute;
bottom: -20px;
font-size: 12px;
color: #999;
}
}
}
}
@keyframes growUp {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@keyframes growDown {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More