feat(admin): art-design-pro integration with local session auth

P1b Task 9: apps/admin based on art-design-pro v3.0.2 (tag, commit
d224dd4; source recorded in apps/admin/SOURCE.md), trimmed to
layout/login/i18n/request/table-form core. Login wired to real
POST /v1/auth/password (single-token session), Bearer header on all
requests, 401 interceptor logout+redirect, request-driven sliding
renewal (refresh at ~80% TTL, single-flight), best-effort
POST /v1/auth/logout on user-menu logout. One protected stub page
(相册管理). Dev port 5176 strictPort + /v1 proxy to 8080. Admin keeps
its own eslint9 flat config; root eslint ignores apps/admin/**.
vitest unit tests for renewal pacing + Playwright e2e (guard redirect,
localized wrong-password error, login lands on stub, token persists
across reload, logout clears session).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:47:32 +08:00
parent 90fe64f637
commit a6cc190751
273 changed files with 32565 additions and 48 deletions
+39
View File
@@ -0,0 +1,39 @@
<template>
<ElConfigProvider
size="default"
:locale="locales[language]"
:z-index="3000"
:card="{
shadow: 'never'
}"
>
<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 { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const locales = {
zh: zh,
en: en
}
onBeforeMount(() => {
toggleTransition(true)
initializeTheme()
})
onMounted(() => {
checkStorageCompatibility()
toggleTransition(false)
})
</script>
+64
View File
@@ -0,0 +1,64 @@
/**
* 认证接口(对齐 @photo/contracts 的 /v1/auth/* 端点)
*
* - 登录:POST /v1/auth/password {account, password} → {sessionToken, expiresAt}
* - 续期:POST /v1/auth/refresh(Bearer)→ {expiresAt}(单令牌滑动续期,令牌本身不变)
* - 注销:POST /v1/auth/logout(Bearer)→ 204 无响应体
*
* 用户信息无后端端点(契约未定义 /users/me),由本地拼装:
* 登录时把账号写入 user store,fetchGetUserInfo 直接读 store,
* 避免路由守卫为拿用户信息打一个不存在的接口。
*/
import request from '@/utils/http'
import { useUserStore } from '@/store/modules/user'
/**
* 账号密码登录
* @param params 登录参数(account + password)
* @returns 会话令牌与过期时间
*/
export function fetchLogin(params: Api.Auth.LoginParams) {
return request.post<Api.Auth.LoginResponse>({
url: '/v1/auth/password',
data: params,
// 凭据错误也是 401,但不能触发「会话过期→登出」分支,标记豁免
skipUnauthorizedLogout: true,
showErrorMessage: false
})
}
/**
* 滑动续期(需 Authorization: Bearer)
* @returns 新的过期时间(令牌不变)
*/
export function fetchRefreshSession() {
return request.post<Api.Auth.RefreshResponse>({
url: '/v1/auth/refresh',
showErrorMessage: false
})
}
/**
* 注销会话(需 Authorization: Bearer),204 无响应体
* 尽力而为:失败也继续本地登出
*/
export async function fetchLogout(): Promise<void> {
await request.del<void>({ url: '/v1/auth/logout', showErrorMessage: false })
}
/**
* 获取用户信息(本地)
* 模板守卫在动态路由初始化时调用;后端无对应端点,从 user store
* 读取登录时保存的账号拼装,角色约定为 R_ADMIN。
*/
export async function fetchGetUserInfo(): Promise<Api.Auth.UserInfo> {
const userStore = useUserStore()
const account = userStore.info?.userName || 'admin'
return {
userId: 0,
userName: account,
email: '',
buttons: [],
roles: ['R_ADMIN']
}
}
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: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 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

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="94" y="34" width="212" height="233"><path d="M306 34H94v233h212V34Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M234.427 155.64h38.36V69.6h-38.36v86.04ZM113.326 155.64h121.1V69.6h-121.1v86.04Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.126 155.354h104.2v-72.95h-104.2v72.95ZM236.369 71.05s0 3.3 1.65 5.05c2.33 2.52 7.38-.2 7.38-.2s-1.75 5.15-1.55 10.19c.29 8.24 6.99 9.51 10 4.75 4.56 4.85 8.94-.29 9.52-2.62 4.27 4.76 9.32-.87 9.32-.87v-6.3l-23.99-12.13-12.33 2.13Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M234.429 155.641h-121.1l-15.93 32.11h121.1l15.93-32.11Z" fill="#fff"/><path d="M234.427 69.6h38.46v86.04M113.326 146.52V69.6h121.1M234.429 155.641l-15.93 32.11h-121.1l15.93-32.11h111.39" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M226.37 159.715H116.82l-12.04 23.86H215l11.37-23.86Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="m288.807 187.751-15.92-32.11h-38.46l16.02 32.11h38.36Z" fill="#fff"/><path d="m238.607 163.981 11.84 23.77h38.36l-15.92-32.11h-38.46" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M207.336 223.734c-3.69-13.77-15.44-23.86-29.33-23.86h-8.65s-27.09 14.94-27.09 33.27c0 18.34 25.44 33.18 25.44 33.18h10.4c13.79-.1 25.44-10.19 29.13-23.87 1.75-12.51 0-18.62.1-18.72Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M243.459 240.421c3.98 0 7.28-3.3 7.28-7.27 0-3.98-3.3-7.28-7.28-7.28h-31.08c-3.98 0-7.28 3.3-7.28 7.28 0 3.97 3.3 7.27 7.28 7.27h31.08Z" fill="#C7DEFF"/><path d="M210.342 223.737c-4.08-13.87-16.9-23.96-32.05-23.96H168.972s-29.62 14.94-29.62 33.37 27.87 33.37 27.87 33.37h11.27c15.05-.1 27.77-10.19 31.75-23.96" stroke="#071F4D"/><path d="M212.379 240.421c-3.98 0-7.28-3.3-7.28-7.27m0 0c0-3.98 3.3-7.28 7.28-7.28" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" fill="#006EFF"/><path d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.775 209.38c-13.14 0-23.79 10.64-23.79 23.77 0 13.12 10.65 23.76 23.79 23.76 13.14 0 23.8-10.64 23.8-23.76 0-13.13-10.66-23.77-23.8-23.77Z" fill="#00E4E5"/><path d="M162.174 223.736a17.48 17.48 0 0 1 14.76-8.05M159.455 231.982c.1-1.36.29-2.62.68-3.88" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.535 209.87c-1.55-.3-3.11-.49-4.76-.49-13.11 0-23.79 10.67-23.79 23.77 0 13.09 10.68 23.76 23.79 23.76 1.65 0 3.21-.19 4.76-.48-10.88-2.23-19.03-11.84-19.03-23.28 0-11.45 8.15-21.05 19.03-23.28Z" fill="#071F4D"/><path d="M219.957 225.774h23.6c4.08 0 7.38 3.3 7.38 7.37m0 0c0 4.08-3.3 7.37-7.38 7.37h-20.1M212.091 225.774h3.3" stroke="#071F4D"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" fill="#fff"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" stroke="#071F4D"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6l2.04-9.6Z" fill="#fff"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6" stroke="#071F4D"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63l2.04-8.63Z" fill="#fff"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63M147.801 34.485v34.92M121.775 34.485v34.92M102.546 204.724v13.97M102.546 222.379v.87M102.546 197.934v3.49M115.268 206.955v26.29M115.268 239.451v5.34M244.43 197.643v11.93M244.43 213.939v3.49M270.359 201.232v33.76M115.369 47.774h-13.6M94.486 47.774h3.4M241.516 47.774h-84.1M280.168 47.774h25.35" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m282.497 183.575-12.04-23.86h-27.29l11.36 23.86h27.97Z" fill="#00E4E5"/><path d="M234.427 134.88V69.6M234.427 140.412v7.66" stroke="#071F4D"/><path d="M220.831 228.684h16.99M240.934 228.684h2.43" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="m223.842 187.462 21.46-.2-10.97-20.66-10.49 20.86Z" fill="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg
viewBox="0 0 400 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="47" y="38" width="307" height="224"><path d="M353.3 38H47.5v223.8h305.8V38Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M299.2 200.6H61.6v5.1h240.3l-2.7-5.1Z" fill="#C7DEFF"/><path d="m308.9 185.8-6.5 20H183.7M332.3 127.6h10.6l-5 16.7-14.8-.1-7.2 21.1M328.8 127.4l13.6-39.6M307.6 166 337 84.7H180.6l-9.8 26.9h-10.5M296.6 196l4.3-11.8M157.2 149.2l6.4-17.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-34.8 95.8h136.4l34.7-95.8ZM169.9 166.2l5-13.6-5 13.6Z" fill="#fff"/><path d="m169.9 166.2 5-13.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-4 11.7h135.8l4.5-11.7Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M102.6 159.5h38.3l2.7 36.6h-38.4c-10.1 0-20.9-8.2-20.9-18.3 0-10.1 8.2-18.3 18.3-18.3Z" fill="#DEEBFC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M84.3 174.102c2.5 3.4 10 5 17.9 2.8 16.6-6.5 23.8-3.9 23.8-3.9s.5-3.4 1.3-5c-5.8-3-15.4.3-26.1 3.1-10.7 2.8-15.8-2.5-15.8-2.5-.4 0-1.1 2.8-1.1 5.5Z" fill="#fff"/><path d="M96.5 194.2c-7.2-3.3-12.2-10.5-12.2-19m0 0c0-11.5 9.3-20.8 20.8-20.8h29.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8l14.5 19.8Zm-14.5-19.8c0-11.5 9.3-20.8 20.8-20.8l-20.8 20.8Zm20.8-20.8c11.5 0 20.8 9.3 20.8 20.8l-20.8-20.8Zm20.8 20.8c0 8.4-5 15.6-12.1 18.9l12.1-18.9Z" fill="#fff"/><path d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8m0 0c0-11.5 9.3-20.8 20.8-20.8m0 0c11.5 0 20.8 9.3 20.8 20.8m0 0c0 8.4-5 15.6-12.1 18.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.5 177.2c0-7.7-6.3-14-14-14s-14 6.3-14 14c0 5.8 3.5 10.8 8.6 12.9.1 0 5.8 1.6 10.7 0 5.3-1.7 8.7-7.1 8.7-12.9Z" fill="#00E4E5"/><path d="M140.5 190.1c-5.8-2.4-9.9-8.2-9.9-14.9 0-8.9 7.2-16.1 16.1-16.1 8.9 0 16.1 7.2 16.1 16.1 0 6.8-4.2 12.5-10.1 14.9M88.4 170.604c2.9 1.3 7.7 2.6 13.6.3 14.7-5.7 22.3-4.3 24.6-3.5M84.5 174.599s5.9 6.5 19 1.7c9.2-3.4 15.3-3.9 18.8-3.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M340.6 112.3h-55.2l-2.7 6.2H338l2.6-6.2Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M236.8 117.9c-16.13 0-29.2 13.07-29.2 29.2s13.07 29.2 29.2 29.2 29.2-13.07 29.2-29.2-13.07-29.2-29.2-29.2Z" fill="#00E4E5"/><path d="M265 123.3c13.1 13.1 13.1 34.4 0 47.6M306 205.9h19.2M61.7 205.9h32.9M181.2 196.2h115.2M47.5 205.9h10v-9.7h73.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M146.7 179.2c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.5 196.2c3.9 0 7.1 3.2 7.1 7.1 0 3.9-3.2 7.1-7.1 7.1H144c-2.1 0-3.9 1.7-3.9 3.9v1c0 2.1 1.7 3.9 3.9 3.9h48c5.1 0 9.2 4.1 9.2 9.2s-4.1 9.3-9.2 9.2h-33.8c-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1h4.2c4.4 0 8 3.6 8 8s-3.6 8-8 8H111c-3.7 0-6.8-3-6.8-6.8 0-3.7 3-6.8 6.8-6.8h.3c2.3 0 4.1-1.8 4.1-4.1s-1.8-4.1-4.1-4.1H79c-4.5 0-8.1-3.6-8.1-8.1s3.6-8.1 8.1-8.1h37.7c2.1 0 3.9-1.7 3.9-3.9 0-2.1-1.7-3.9-3.9-3.9h-7.9c-4.4 0-7.9-3.5-7.9-7.9s3.5-7.9 7.9-7.9h30.4c2.2 0 3.9-1.8 3.9-3.9V187c0-1.9 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5v5.3c0 2.2 1.8 3.9 3.9 3.9h15.5Z" fill="#006EFF"/><path d="m227.8 138.5 18.7 18.7M227.8 157.2l18.7-18.7" stroke="#fff" stroke-width="6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M194.8 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8ZM202.9 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8Z" fill="#fff"/><path d="m291.7 184.3-1.6 4.6h-121M298.1 166.7l22.5-61.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m193 134.1 2.2-5.1h-19.4l-2.3 5.1H193ZM313.2 123.5l2.2-5.1h-24.5l-2.3 5.1h24.6Z" fill="#DEEBFC"/><path d="m164.5 159.2 19.8-54.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M199.6 119.8h-53.2l-4.4 9.3h53.2l4.4-9.3Z" fill="#00E4E5"/><path d="M151.3 129.1H142l4.4-9.3h16.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M353.3 169.4h-67.4l-4.8 12.2h67.3l4.9-12.2Z" fill="#006EFF"/><path d="M332.4 169.4h20.9l-4.9 12.2h-39.7M242.7 235.5v-4.8c0-3.8 3.1-7 7-7h20.2c3.8 0 7 3.1 7 7" stroke="#071F4D"/><path d="M261.1 235.5v-4.8c0-3.8 3.1-7 7-7h13.7c3.8 0 7 3.1 7 7v4.8M242.6 230.7h13.7M235.2 237.7h63.3M224 237.7h6.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.1 141.3H335l3.3-10.7h-10.2l-4 10.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M288.3 230.4c0-3.6-2.9-6.5-6.5-6.5h-14.2c-3.6 0-6.5 2.9-6.5 6.5v5.3h27.2v-5.3Z" fill="#071F4D"/><path d="M80.4 228.5H83M87.7 228.5h19.2M146.3 195.8v2c0 3.6-2.9 6.6-6.6 6.6H138M133.4 204.3h1.5M154 249.9h9.4" stroke="#DEEBFC"/><path d="m299.4 141.9 5.1-13.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+292
View File
@@ -0,0 +1,292 @@
// 全局样式
// 顶部进度条颜色
#nprogress .bar {
z-index: 2400;
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
}
#nprogress .peg {
box-shadow:
0 0 10px var(--theme-color),
0 0 5px var(--theme-color) !important;
}
#nprogress .spinner-icon {
border-top-color: var(--theme-color) !important;
border-left-color: var(--theme-color) !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
* {
cursor: default !important;
}
}
// 背景滤镜
*,
::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: var(--art-el-active-color) !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 {
border: 1px solid var(--art-card-border) !important;
}
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color);
border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
box-shadow: #{$shadow} !important;
--el-card-border-color: var(--default-border) !important;
}
.art-card,
.art-card-sm,
.art-card-xs {
border: 1px solid var(--art-card-border);
}
// 盒子边框
[data-box-mode='border-mode'] {
.page-content,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(var(--art-card-border), none, 4px);
}
.art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px);
}
.art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px);
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
.page-content,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px
);
}
.art-card-sm {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px
);
}
.art-card-xs {
@include art-card-base(
var(--art-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px
);
}
}
// 元素全屏
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100% !important;
z-index: 2300;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--default-box-color);
display: flex;
flex-direction: column;
}
// 表格卡片
.art-table-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
overflow: hidden;
}
}
// 容器全高
.art-full-height {
height: var(--art-full-height);
display: flex;
flex-direction: column;
@media (max-width: 640px) {
height: auto;
}
}
// 徽章样式
.art-badge {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: 6px;
height: 6px;
margin: auto;
background: #ff3860;
border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal {
right: 0;
}
&.art-badge-mixed {
right: 0;
}
&.art-badge-dual {
right: 5px;
top: 5px;
bottom: auto;
}
}
// 文字徽章样式
.art-text-badge {
position: absolute;
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 18px;
line-height: 17px;
padding: 0 5px;
margin: auto;
font-size: 10px;
color: #fff;
text-align: center;
background: #fd4e4e;
border-radius: 4px;
}
@keyframes breathe {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
// 修复老机型 loading 定位问题
.art-loading-fix {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.art-loading-fix .el-loading-spinner {
position: static !important;
top: auto !important;
left: auto !important;
transform: none !important;
}
// 去除移动端点击背景色
@media screen and (max-width: 1180px) {
* {
-webkit-tap-highlight-color: transparent;
}
}
@@ -0,0 +1,93 @@
/*
* 深色主题
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.85);
/* 覆盖element-plus默认深色背景色 */
html.dark {
// element-plus
--el-bg-color: var(--default-box-color);
--el-text-color-regular: #{$font-color};
// 富文本编辑器
// 工具栏背景颜色
--w-e-toolbar-bg-color: #18191c;
// 输入区域背景颜色
--w-e-textarea-bg-color: #090909;
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色
--w-e-toolbar-border-color: var(--default-border-dashed);
// 分割线颜色
--w-e-textarea-border-color: var(--default-border-dashed);
// 链接输入框边框颜色
--w-e-modal-button-border-color: var(--default-border-dashed);
// 表格头颜色
--w-e-textarea-slight-bg-color: #090909;
// 按钮背景颜色
--w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800);
}
.dark {
.page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important;
}
// 富文本编辑器
.editor-wrapper {
*:not(pre code *) {
color: inherit !important;
}
}
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
}
.w-e-select-list,
.w-e-drop-panel,
.w-e-bar-item-group .w-e-bar-item-menus-container,
.w-e-text-container [data-slate-editor] pre > code {
border: 1px solid var(--default-border) !important;
}
// 下拉选择框
.w-e-select-list {
background-color: var(--default-box-color) !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: #090909 !important;
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important;
text-shadow: none !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--default-border-dashed) !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(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
}
@@ -0,0 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
@@ -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'
)
);
@@ -0,0 +1,519 @@
// 优化 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: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--theme-color);
}
}
// 优化 el-form-item 标签高度
.el-form-item__label {
height: var(--el-component-custom-height) !important;
line-height: var(--el-component-custom-height) !important;
}
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
}
// el-card 背景色跟系统背景色保持一致
html.dark .el-card {
--el-card-bg-color: var(--default-box-color) !important;
}
// 修改 el-pagination 大小
.el-pagination--default {
& {
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
}
@media (max-width: 1180px) {
& {
--el-pagination-button-width: 28px !important;
}
}
.el-select--default .el-select__wrapper {
min-height: var(--el-pagination-button-width) !important;
}
.el-pagination__jump .el-input {
height: var(--el-pagination-button-width) !important;
}
}
.el-pager li {
padding: 0 10px !important;
// border: 1px solid red !important;
}
// 优化菜单折叠展开动画(提升动画流畅度)
.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;
}
// circle 按钮宽度优化
.el-button--default.is-circle {
width: 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;
border-radius: var(--el-border-radius-small) !important;
}
.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: var(--art-gray-300);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
}
// el-message 样式优化
.el-message {
background-color: var(--default-box-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 {
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-el-active-color) !important;
}
&:focus: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: 640px) {
.el-message-box,
.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 {
font-weight: 500;
transition: all 0s !important;
&.el-tag--default {
height: 26px !important;
}
}
.el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important;
.el-checkbox__label {
font-weight: 400 !important;
}
}
}
.el-radio--default {
// 优化单选按钮大小
.el-radio__input {
.el-radio__inner {
width: 16px;
height: 16px;
&::after {
width: 6px;
height: 6px;
}
}
}
}
.el-checkbox {
.el-checkbox__inner {
border-radius: 2px !important;
}
}
// 优化复选框样式
.el-checkbox--default {
.el-checkbox__inner {
width: 16px !important;
height: 16px !important;
border-radius: 4px !important;
&::before {
content: '';
height: 4px !important;
top: 5px !important;
background-color: #fff !important;
transform: scale(0.6) !important;
}
}
.is-checked {
.el-checkbox__inner {
&::after {
width: 3px;
height: 8px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center;
}
}
}
}
.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 {
top: 7px;
right: 7px;
width: 30px;
height: 30px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important;
}
}
.el-message-box {
padding: 25px 20px !important;
}
.el-message-box__title {
font-weight: 500 !important;
}
.el-table__column-filter-trigger i {
color: var(--theme-color) !important;
margin: -3px 0 0 2px;
}
// 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible {
outline: unset;
}
// ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) {
.el-table-fixed-column--right {
padding-right: 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-el-active-color) !important;
margin-bottom: 4px !important;
}
&:hover {
background-color: var(--art-hover-color) !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;
}
// 优化颜色选择器圆角
.el-color-picker__color {
border-radius: 2px !important;
}
// 优化日期时间选择器底部圆角
.el-picker-panel {
.el-picker-panel__footer {
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
}
}
// 优化树型菜单样式
.el-tree-node__content {
border-radius: 4px;
margin-bottom: 4px;
padding: 1px 0;
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important;
}
}
// 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within {
box-shadow: none !important;
outline: none !important;
}
// 数字输入组件右侧按钮高度跟随自定义组件高度
.el-input-number--default.is-controls-right {
.el-input-number__decrease,
.el-input-number__increase {
height: calc((var(--el-component-size) / 2)) !important;
}
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -0,0 +1,41 @@
@charset "UTF-8";
/*滚动条*/
/*滚动条整体部分,必须要设置*/
::-webkit-scrollbar {
width: 8px !important;
height: 0 !important;
}
/*滚动条的轨道*/
::-webkit-scrollbar-track {
background-color: var(--art-gray-200);
}
/*滚动条的滑块按钮*/
::-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(--default-bg-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--art-gray-300) !important;
}
}
@@ -0,0 +1,104 @@
@use 'sass:map';
// === 变量区域 ===
$transition: (
// 动画持续时间
duration: 0.25s,
// 滑动动画的移动距离
distance: 15px,
// 默认缓动函数
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
// 淡入淡出专用的缓动函数
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
);
// 抽取配置值函数,提高可复用性
@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 {
transition:
opacity $duration $easing,
transform $duration $easing;
will-change: opacity, transform;
}
&-leave-active {
transition:
opacity calc($duration * 0.7) $easing,
transform calc($duration * 0.7) $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');
}
@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* ==================== Light Mode Variables ==================== */
:root {
/* Base Colors */
--art-color: #ffffff;
--theme-color: var(--main-color);
/* Theme Colors - OKLCH Format */
--art-primary: oklch(0.7 0.23 260);
--art-secondary: oklch(0.72 0.19 231.6);
--art-error: oklch(0.73 0.15 25.3);
--art-info: oklch(0.58 0.03 254.1);
--art-success: oklch(0.78 0.17 166.1);
--art-warning: oklch(0.78 0.14 75.5);
--art-danger: oklch(0.68 0.22 25.3);
/* Gray Scale - Light Mode */
--art-gray-100: #f9fafb;
--art-gray-200: #f2f4f5;
--art-gray-300: #e6eaeb;
--art-gray-400: #dbdfe1;
--art-gray-500: #949eb7;
--art-gray-600: #7987a1;
--art-gray-700: #4d5875;
--art-gray-800: #383853;
--art-gray-900: #323251;
/* Border Colors */
--art-card-border: rgba(0, 0, 0, 0.08);
--default-border: #e2e8ee;
--default-border-dashed: #dbdfe9;
/* Background Colors */
--default-bg-color: #fafbfc;
--default-box-color: #ffffff;
/* Hover Color */
--art-hover-color: #edeff0;
/* Active Color */
--art-active-color: #f2f4f5;
/* Element Component Active Color */
--art-el-active-color: #f2f4f5;
}
/* ==================== Dark Mode Variables ==================== */
.dark {
/* Base Colors */
--art-color: #000000;
/* Gray Scale - Dark Mode */
--art-gray-100: #110f0f;
--art-gray-200: #17171c;
--art-gray-300: #393946;
--art-gray-400: #505062;
--art-gray-500: #73738c;
--art-gray-600: #8f8fa3;
--art-gray-700: #ababba;
--art-gray-800: #c7c7d1;
--art-gray-900: #e3e3e8;
/* Border Colors */
--art-card-border: rgba(255, 255, 255, 0.08);
--default-border: rgba(255, 255, 255, 0.1);
--default-border-dashed: #363843;
/* Background Colors */
--default-bg-color: #070707;
--default-box-color: #161618;
/* Hover Color */
--art-hover-color: #252530;
/* Active Color */
--art-active-color: #202226;
/* Element Component Active Color */
--art-el-active-color: #2e2e38;
}
/* ==================== Tailwind Theme Configuration ==================== */
@theme {
/* Box Color (Light: white / Dark: black) */
--color-box: var(--default-box-color);
/* System Theme Color */
--color-theme: var(--theme-color);
/* Hover Color */
--color-hover-color: var(--art-hover-color);
/* Active Color */
--color-active-color: var(--art-active-color);
/* Active Color */
--color-el-active-color: var(--art-active-color);
/* ElementPlus Theme Colors */
--color-primary: var(--art-primary);
--color-secondary: var(--art-secondary);
--color-error: var(--art-error);
--color-info: var(--art-info);
--color-success: var(--art-success);
--color-warning: var(--art-warning);
--color-danger: var(--art-danger);
/* Gray Scale Colors (Auto-adapts to dark mode) */
--color-g-100: var(--art-gray-100);
--color-g-200: var(--art-gray-200);
--color-g-300: var(--art-gray-300);
--color-g-400: var(--art-gray-400);
--color-g-500: var(--art-gray-500);
--color-g-600: var(--art-gray-600);
--color-g-700: var(--art-gray-700);
--color-g-800: var(--art-gray-800);
--color-g-900: var(--art-gray-900);
}
/* ==================== Custom Border Radius Utilities ==================== */
@utility rounded-custom-xs {
border-radius: calc(var(--custom-radius) / 2);
}
@utility rounded-custom-sm {
border-radius: calc(var(--custom-radius) / 2 + 2px);
}
/* ==================== Custom Utility Classes ==================== */
@layer utilities {
/* Flexbox Layout Utilities */
.flex-c {
@apply flex items-center;
}
.flex-b {
@apply flex justify-between;
}
.flex-cc {
@apply flex items-center justify-center;
}
.flex-cb {
@apply flex items-center justify-between;
}
/* Transition Utilities */
.tad-200 {
@apply transition-all duration-200;
}
.tad-300 {
@apply transition-all duration-300;
}
/* Border Utilities */
.border-full-d {
@apply border border-[var(--default-border)];
}
.border-b-d {
@apply border-b border-[var(--default-border)];
}
.border-t-d {
@apply border-t border-[var(--default-border)];
}
.border-l-d {
@apply border-l border-[var(--default-border)];
}
.border-r-d {
@apply border-r border-[var(--default-border)];
}
/* Cursor Utilities */
.c-p {
@apply cursor-pointer;
}
}
/* ==================== Custom Component Classes ==================== */
@layer components {
/* Art Card Header Component */
.art-card-header {
@apply flex justify-between pr-6 pb-1;
.title {
h4 {
@apply text-lg font-medium text-g-900;
}
p {
@apply mt-1 text-sm text-g-600;
span {
@apply ml-2 font-medium;
}
}
}
}
}
@@ -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 both;
}
&::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 both;
}
&::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);
}
@@ -0,0 +1,11 @@
// 主题切换过渡优化,优化除视觉上的不适感
.theme-change {
* {
transition: 0s !important;
}
.el-switch__core,
.el-switch__action {
transition: all 0.3s !important;
}
}
@@ -0,0 +1,98 @@
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
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;
}
+23
View File
@@ -0,0 +1,23 @@
// 重置默认样式
@use './core/reset.scss';
// 应用全局样式
@use './core/app.scss';
// Element Plus 样式优化
@use './core/el-ui.scss';
// Element Plus 暗黑主题
@use './core/el-dark.scss';
// 暗黑主题样式优化
@use './core/dark.scss';
// 路由切换动画
@use './core/router-transition';
// 主题切换过渡优化
@use './core/theme-change.scss';
// 主题切换圆形扩散动画
@use './core/theme-animation.scss';
+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: 20px 20px;
animation: rotate 1.6s linear infinite;
}
.dot {
fill: var(--theme-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="art-card basic-banner"
:class="[{ 'has-decoration': decoration }, boxStyle]"
:style="{ 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
/** 盒子样式 */
boxStyle?: string
/** 是否显示装饰效果 */
decoration?: boolean
/** 按钮配置 */
buttonConfig?: ButtonConfig
/** 流星配置 */
meteorConfig?: MeteorConfig
/** 图片配置 */
imageConfig?: ImageConfig
/** 标题颜色 */
titleColor?: string
/** 副标题颜色 */
subtitleColor?: string
}
// 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), {
height: '11rem',
titleColor: 'white',
subtitleColor: 'white',
boxStyle: '!bg-theme/60',
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 (width <= 640px) {
.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,114 @@
<!-- 卡片横幅组件 -->
<template>
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
<div class="flex-c flex-col gap-4 text-center">
<div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" />
</div>
<div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
<p class="m-0 text-sm text-g-600">{{ description }}</p>
</div>
<div class="flex-c gap-3">
<div
v-if="cancelButton?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
:style="{
backgroundColor: cancelButton?.color,
color: cancelButton?.textColor
}"
@click="handleCancel"
>
{{ cancelButton?.text }}
</div>
<div
v-if="button?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
:style="{ backgroundColor: button?.color, color: button?.textColor }"
@click="handleClick"
>
{{ 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
}
}
// 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), {
height: '24rem',
image: defaultIcon,
title: '',
description: '',
// 主按钮默认配置
button: () => ({
show: true,
text: '查看详情',
color: 'var(--theme-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>
@@ -0,0 +1,40 @@
<!-- 返回顶部按钮 -->
<template>
<Transition
enter-active-class="tad-300 ease-out"
leave-active-class="tad-200 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-show="showButton"
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
@click="scrollToTop"
>
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
</div>
</Transition>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon'
defineOptions({ name: 'ArtBackToTop' })
const { scrollToTop } = useCommon()
const showButton = ref(false)
const scrollThreshold = 300
onMounted(() => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
const { y } = useScroll(scrollContainer)
watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold
})
}
})
</script>
@@ -0,0 +1,21 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
</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>
@@ -0,0 +1,24 @@
<!-- 图标组件 -->
<template>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
interface Props {
/** Iconify icon name */
icon?: string
}
defineProps<Props>()
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}))
</script>
@@ -0,0 +1,74 @@
<!-- 数据列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
<ArtSvgIcon :icon="item.icon" class="text-xl" />
</div>
<div class="flex-1">
<div class="mb-1 text-sm">{{ item.title }}</div>
<div class="text-xs text-g-500">{{ item.status }}</div>
</div>
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
</div>
</ElScrollbar>
<ElButton
class="mt-[25px] w-full text-center"
v-if="showMoreButton"
v-ripple
@click="handleMore"
>查看更多</ElButton
>
</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>
@@ -0,0 +1,89 @@
<!-- 图片卡片 -->
<template>
<div class="w-full c-p" @click="handleClick">
<div class="art-card overflow-hidden">
<div class="relative w-full aspect-[16/10] overflow-hidden">
<ElImage
:src="props.imageUrl"
fit="cover"
loading="lazy"
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
>
<template #placeholder>
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
<ElIcon><Picture /></ElIcon>
</div>
</template>
</ElImage>
<div
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
v-if="props.readTime"
>
{{ props.readTime }} 阅读
</div>
</div>
<div class="p-4">
<div
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
v-if="props.category"
>
{{ props.category }}
</div>
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
<div class="flex-c gap-4 text-xs text-g-600">
<span class="flex-c gap-1" v-if="props.views">
<ElIcon class="text-base"><View /></ElIcon>
{{ props.views }}
</span>
<span class="flex-c gap-1" v-if="props.comments">
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
{{ props.comments }}
</span>
<span>{{ props.date }}</span>
</div>
</div>
</div>
</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 = withDefaults(defineProps<Props>(), {
imageUrl: '',
title: '',
category: '',
readTime: '',
views: 0,
comments: 0,
date: ''
})
const emit = defineEmits<{
(e: 'click', card: Props): void
}>()
const handleClick = () => {
emit('click', props)
}
</script>
@@ -0,0 +1,86 @@
<!-- 进度条卡片 -->
<template>
<div class="art-card h-32 flex flex-col justify-center px-5">
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
</div>
<div>
<ArtCountTo
class="mb-1 block text-2xl font-semibold"
:target="percentage"
:duration="2000"
suffix="%"
:style="{ textAlign: icon ? 'right' : 'left' }"
/>
<p class="text-sm text-g-500">{{ title }}</p>
</div>
</div>
<ElProgress
:percentage="currentPercentage"
:stroke-width="strokeWidth"
:show-text="false"
:color="color"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtProgressCard' })
interface Props {
/** 进度百分比 */
percentage: number
/** 标题 */
title: string
/** 颜色 */
color?: string
/** 图标 */
icon?: string
/** 图标样式 */
iconStyle?: string
/** 进度条宽度 */
strokeWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5,
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>
@@ -0,0 +1,67 @@
<!-- 统计卡片 -->
<template>
<div
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
:class="boxStyle"
>
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
</div>
<div class="flex-1">
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
{{ title }}
</p>
<ArtCountTo
class="m-0 text-2xl font-medium"
v-if="count !== undefined"
:target="count"
:duration="2000"
:decimals="decimals"
:separator="separator"
/>
<p
class="mt-1 text-sm text-g-500 opacity-90"
:style="{ color: textColor }"
v-if="description"
>{{ description }}</p
>
</div>
<div v-if="showArrow">
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtStatsCard' })
interface StatsCardProps {
/** 盒子样式 */
boxStyle?: string
/** 图标 */
icon?: string
/** 图标样式 */
iconStyle?: string
/** 标题 */
title?: string
/** 数值 */
count?: number
/** 小数位 */
decimals?: number
/** 分隔符 */
separator?: string
/** 描述 */
description: string
/** 文本颜色 */
textColor?: string
/** 是否显示箭头 */
showArrow?: boolean
}
withDefaults(defineProps<StatsCardProps>(), {
iconSize: 30,
iconBgRadius: 50,
decimals: 0,
separator: ','
})
</script>
@@ -0,0 +1,69 @@
<!-- 时间轴列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<ElTimeline class="!pl-0.5">
<ElTimelineItem
v-for="item in list"
:key="item.time"
:timestamp="item.time"
:placement="TIMELINE_PLACEMENT"
:color="item.status"
:center="true"
>
<div class="flex-c gap-3">
<div class="flex-c gap-2">
<span class="text-sm">{{ item.content }}</span>
<span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</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>
@@ -0,0 +1,71 @@
<!-- 更多按钮 -->
<template>
<div>
<ElDropdown v-if="hasAnyAuthItem">
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
<template #dropdown>
<ElDropdownMenu>
<template v-for="item in list" :key="item.key">
<ElDropdownItem
v-if="!item.auth || hasAuth(item.auth)"
:disabled="item.disabled"
@click="handleClick(item)"
>
<div class="flex-c gap-2" :style="{ color: item.color }">
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
<span>{{ item.label }}</span>
</div>
</ElDropdownItem>
</template>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<script setup lang="ts">
import { useAuth } from '@/hooks/core/useAuth'
defineOptions({ name: 'ArtButtonMore' })
const { hasAuth } = useAuth()
export interface ButtonMoreItem {
/** 按钮标识,可用于点击事件 */
key: string | number
/** 按钮文本 */
label: string
/** 是否禁用 */
disabled?: boolean
/** 权限标识 */
auth?: string
/** 图标组件 */
icon?: string
/** 文本颜色 */
color?: string
/** 图标颜色(优先级高于 color) */
iconColor?: string
}
interface Props {
/** 下拉项列表 */
list: ButtonMoreItem[]
/** 整体权限控制 */
auth?: string
}
const props = withDefaults(defineProps<Props>(), {})
// 检查是否有任何有权限的 item
const hasAnyAuthItem = computed(() => {
return props.list.some((item) => !item.auth || hasAuth(item.auth))
})
const emit = defineEmits<{
(e: 'click', item: ButtonMoreItem): void
}>()
const handleClick = (item: ButtonMoreItem) => {
emit('click', item)
}
</script>
@@ -0,0 +1,59 @@
<!-- 表格按钮 -->
<template>
<div
:class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
buttonClass
]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick"
>
<ArtSvgIcon :icon="iconContent" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtButtonTable' })
interface Props {
/** 按钮类型 */
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
/** 按钮图标 */
icon?: string
/** 按钮样式类 */
iconClass?: string
/** icon 颜色 */
iconColor?: string
/** 按钮背景色 */
buttonBgColor?: string
}
const props = withDefaults(defineProps<Props>(), {})
const emit = defineEmits<{
(e: 'click'): void
}>()
// 默认按钮配置
const defaultButtons = {
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
more: { icon: 'ri:more-2-fill', class: '' }
} as const
// 获取图标内容
const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
})
// 获取按钮样式类
const buttonClass = computed(() => {
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
})
const handleClick = () => {
emit('click')
}
</script>
@@ -0,0 +1,430 @@
<!-- 拖拽验证组件 -->
<template>
<div
ref="dragVerify"
class="drag_verify"
:style="dragVerifyStyle"
@mousemove="dragMoving"
@mouseup="dragFinish"
@mouseleave="dragFinish"
@touchmove="dragMoving"
@touchend="dragFinish"
>
<!-- 进度条 -->
<div
class="dv_progress_bar"
:class="{ goFirst2: isOk }"
ref="progressBar"
:style="progressBarStyle"
>
</div>
<!-- 提示文本 -->
<div class="dv_text" :style="textStyle" ref="messageRef">
<slot name="textBefore" v-if="$slots.textBefore"></slot>
{{ message }}
<slot name="textAfter" v-if="$slots.textAfter"></slot>
</div>
<!-- 滑块处理器 -->
<div
class="dv_handler dv_handler_bg"
:class="{ goFirst: isOk }"
@mousedown="dragStart"
@touchstart="dragStart"
ref="handler"
:style="handlerStyle"
>
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtDragVerify' })
// 事件定义
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
// 组件属性接口定义
interface PropsType {
/** 是否通过验证 */
value: boolean
/** 组件宽度 */
width?: number | string
/** 组件高度 */
height?: number
/** 默认提示文本 */
text?: string
/** 成功提示文本 */
successText?: string
/** 背景色 */
background?: string
/** 进度条背景色 */
progressBarBg?: string
/** 完成状态背景色 */
completedBg?: string
/** 是否圆角 */
circle?: boolean
/** 圆角大小 */
radius?: string
/** 滑块图标 */
handlerIcon?: string
/** 成功图标 */
successIcon?: string
/** 滑块背景色 */
handlerBg?: string
/** 文本大小 */
textSize?: string
/** 文本颜色 */
textColor?: string
}
// 属性默认值设置
const props = withDefaults(defineProps<PropsType>(), {
value: false,
width: '100%',
height: 40,
text: '按住滑块拖动',
successText: 'success',
background: '#eee',
progressBarBg: '#1385FF',
completedBg: '#57D187',
circle: false,
radius: 'calc(var(--custom-radius) / 3 + 2px)',
handlerIcon: 'solar:double-alt-arrow-right-linear',
successIcon: 'ri:check-fill',
handlerBg: '#fff',
textSize: '13px',
textColor: '#333'
})
// 组件状态接口定义
interface StateType {
isMoving: boolean // 是否正在拖拽
x: number // 拖拽起始位置
isOk: boolean // 是否验证成功
}
// 响应式状态定义
const state = reactive(<StateType>{
isMoving: false,
x: 0,
isOk: false
})
// 解构响应式状态
const { isOk } = toRefs(state)
// DOM 元素引用
const dragVerify = ref()
const messageRef = ref()
const handler = ref()
const progressBar = ref()
// 触摸事件变量 - 用于禁止页面滑动
let startX: number, startY: number, moveX: number, moveY: number
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX
startY = e.targetTouches[0].pageY
}
/**
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
* @param e 触摸事件对象
*/
const onTouchMove = (e: any) => {
moveX = e.targetTouches[0].pageX
moveY = e.targetTouches[0].pageY
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
e.preventDefault()
}
}
// 全局事件监听器添加
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false })
// 获取数值形式的宽度
const getNumericWidth = (): number => {
if (typeof props.width === 'string') {
// 如果是字符串,尝试从DOM元素获取实际宽度
return dragVerify.value?.offsetWidth || 260
}
return props.width
}
// 获取样式字符串形式的宽度
const getStyleWidth = (): string => {
if (typeof props.width === 'string') {
return props.width
}
return props.width + 'px'
}
// 组件挂载后的初始化
onMounted(() => {
// 设置 CSS 自定义属性
dragVerify.value?.style.setProperty('--textColor', props.textColor)
// 等待DOM更新后设置宽度相关属性
nextTick(() => {
const numericWidth = getNumericWidth()
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
})
// 重复添加事件监听器(确保事件绑定)
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false })
})
// 组件卸载前清理事件监听器
onBeforeUnmount(() => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
})
// 滑块样式计算
const handlerStyle = {
left: '0',
width: props.height + 'px',
height: props.height + 'px',
background: props.handlerBg
}
// 主容器样式计算
const dragVerifyStyle = computed(() => ({
width: getStyleWidth(),
height: props.height + 'px',
lineHeight: props.height + 'px',
background: props.background,
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
}))
// 进度条样式计算
const progressBarStyle = {
background: props.progressBarBg,
height: props.height + 'px',
borderRadius: props.circle
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
: props.radius
}
// 文本样式计算
const textStyle = computed(() => ({
fontSize: props.textSize
}))
// 显示消息计算属性
const message = computed(() => {
return props.value ? props.successText : props.text
})
/**
* 拖拽开始处理函数
* @param e 鼠标或触摸事件对象
*/
const dragStart = (e: any) => {
if (!props.value) {
state.isMoving = true
handler.value.style.transition = 'none'
// 计算拖拽起始位置
state.x =
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
}
emit('handlerMove')
}
/**
* 拖拽移动处理函数
* @param e 鼠标或触摸事件对象
*/
const dragMoving = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth()
// 计算当前位置
let _x = (e.pageX || e.touches[0].pageX) - state.x
// 在有效范围内移动
if (_x > 0 && _x <= numericWidth - props.height) {
handler.value.style.left = _x + 'px'
progressBar.value.style.width = _x + props.height / 2 + 'px'
} else if (_x > numericWidth - props.height) {
// 拖拽到末端,触发验证成功
handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify()
}
}
}
/**
* 拖拽结束处理函数
* @param e 鼠标或触摸事件对象
*/
const dragFinish = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth()
// 计算最终位置
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
if (_x < numericWidth - props.height) {
// 未拖拽到末端,重置位置
state.isOk = true
handler.value.style.left = '0'
handler.value.style.transition = 'all 0.2s'
progressBar.value.style.width = '0'
state.isOk = false
} else {
// 拖拽到末端,保持验证成功状态
handler.value.style.transition = 'none'
handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify()
}
state.isMoving = false
}
}
/**
* 验证通过处理函数
*/
const passVerify = () => {
emit('update:value', true)
state.isMoving = false
// 更新样式为成功状态
progressBar.value.style.background = props.completedBg
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = '#fff'
emit('passCallback')
}
/**
* 重置验证状态函数
*/
const reset = () => {
// 重置滑块位置
handler.value.style.left = '0'
progressBar.value.style.width = '0'
progressBar.value.style.background = props.progressBarBg
// 重置文本样式
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = props.background
// 重置状态
emit('update:value', false)
state.isOk = false
state.isMoving = false
state.x = 0
}
// 暴露重置方法给父组件
defineExpose({
reset
})
</script>
<style lang="scss" scoped>
.drag_verify {
position: relative;
box-sizing: border-box;
overflow: hidden;
text-align: center;
border: 1px solid var(--default-border-dashed);
.dv_handler {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
i {
padding-left: 0;
font-size: 14px;
color: #999;
}
.el-icon-circle-check {
margin-top: 9px;
color: #6c6;
}
}
.dv_progress_bar {
position: absolute;
width: 0;
height: 34px;
}
.dv_text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
user-select: none;
background: linear-gradient(
to right,
var(--textColor) 0%,
var(--textColor) 40%,
#fff 50%,
var(--textColor) 60%,
var(--textColor) 100%
);
-webkit-background-clip: text;
background-clip: text;
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
-webkit-text-fill-color: transparent;
text-size-adjust: none;
* {
-webkit-text-fill-color: var(--textColor);
}
}
}
.goFirst {
left: 0 !important;
transition: left 0.5s;
}
.goFirst2 {
width: 0 !important;
transition: width 0.5s;
}
</style>
<style lang="scss">
@keyframes slidetounlock {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--width) 0;
}
}
@keyframes slidetounlock2 {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--pwidth) 0;
}
}
</style>
@@ -0,0 +1,507 @@
<!-- 表单组件 -->
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
<template>
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow class="flex flex-wrap" :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
:model-value="getFieldValue(item.key)"
@update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
<div
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
:style="actionButtonsStyle"
>
<div class="flex gap-2 md:justify-center">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
{{ t('table.form.reset') }}
</ElButton>
<ElButton
v-if="showSubmit"
type="primary"
class="submit-button"
@click="handleSubmit"
v-ripple
:disabled="disabledSubmit"
>
{{ t('table.form.submit') }}
</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { toRaw, type Component } from 'vue'
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance
} from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtForm' })
const componentMap = {
input: ElInput, // 输入框
inputtag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器
}
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置
export interface FormItem {
/** 表单项的唯一标识 */
key: string
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component
/** 是否隐藏该表单项 */
hidden?: boolean
/** 表单项占据的列宽,基于24格栅格系统 */
span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>
/** 传递给表单项组件的属性 */
props?: Record<string, any>
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */
placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface FormProps {
/** 表单数据 */
items: FormItem[]
/** 每列的宽度(基于 24 格布局) */
span?: number
/** 表单控件间隙 */
gutter?: number
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */
labelWidth?: string | number
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示提交按钮 */
showSubmit?: boolean
/** 是否禁用提交按钮 */
disabledSubmit?: boolean
/** 提交时是否清洗空值 */
sanitizeOutput?: Partial<SanitizeOutputOptions>
}
interface SanitizeOutputOptions {
/** 移除空字符串 */
removeEmptyString: boolean
/** 移除空数组 */
removeEmptyArray: boolean
/** 移除清洗后为空的对象 */
removeEmptyObject: boolean
/** 移除空富文本占位内容,如 <p><br></p> */
removeEmptyRichText: boolean
/** 保留数字 0 这类有效值 */
keepZero: boolean
/** 保留 false 这类有效值 */
keepFalse: boolean
}
const props = withDefaults(defineProps<FormProps>(), {
items: () => [],
span: 6,
gutter: 12,
labelPosition: 'right',
labelWidth: '70px',
buttonLeftLimit: 2,
showReset: true,
showSubmit: true,
disabledSubmit: false,
sanitizeOutput: () => ({})
})
interface FormEmits {
reset: []
submit: [Record<string, any>]
}
const emit = defineEmits<FormEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
const initialModelValue = ref<Record<string, any>>({})
// 保存组件初始化时的表单快照,用于 reset 时恢复默认值。
const cloneModelValue = (value: Record<string, any> | undefined) => {
if (!value) return {}
const deepClone = (source: unknown): unknown => {
if (Array.isArray(source)) {
return source.map((item) => deepClone(item))
}
if (source && typeof source === 'object') {
const rawSource = toRaw(source)
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key])
return accumulator
}, {})
}
return source
}
return deepClone(toRaw(value)) as Record<string, any>
}
initialModelValue.value = cloneModelValue(modelValue.value)
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
// 输出时的清洗策略默认偏“接口友好”,但允许按业务覆盖。
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
removeEmptyString: true,
removeEmptyArray: true,
removeEmptyObject: true,
removeEmptyRichText: true,
keepZero: true,
keepFalse: true,
...props.sanitizeOutput
}))
const PATH_NUMBER_RE = /^\d+$/
// 兼容 a.b、a.0.b 这类路径写法,数字段会被当作数组索引处理。
const parsePath = (path: string) => {
return path
.split('.')
.filter(Boolean)
.map((segment) => (PATH_NUMBER_RE.test(segment) ? Number(segment) : segment))
}
const getFieldValue = (path: string) => {
return parsePath(path).reduce<any>((currentValue, segment) => {
if (currentValue == null) return undefined
return currentValue[segment]
}, modelValue.value)
}
// 清空字段时只删除路径的最后一段,避免误删同级数据。
const deleteFieldValue = (path: string) => {
const segments = parsePath(path)
if (!segments.length) return
const lastSegment = segments.pop()
const parent = segments.reduce<any>((currentValue, segment) => {
if (currentValue == null) return undefined
return currentValue[segment]
}, modelValue.value)
if (parent != null && lastSegment !== undefined) {
delete parent[lastSegment]
}
}
// 表单清空输入时不保留空字符串,同时按路径自动补齐中间对象或数组。
const setFieldValue = (path: string, value: unknown) => {
const normalizedValue = value === '' ? undefined : value
const segments = parsePath(path)
if (!segments.length) return
if (normalizedValue === undefined) {
deleteFieldValue(path)
return
}
let currentValue: any = modelValue.value
segments.forEach((segment, index) => {
const isLast = index === segments.length - 1
if (isLast) {
currentValue[segment] = normalizedValue
return
}
const nextSegment = segments[index + 1]
const nextContainer = typeof nextSegment === 'number' ? [] : {}
if (
currentValue[segment] === null ||
currentValue[segment] === undefined ||
typeof currentValue[segment] !== 'object'
) {
currentValue[segment] = nextContainer
}
currentValue = currentValue[segment]
})
}
const isRichTextEmpty = (value: string) => {
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
return false
}
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
return (
value
.replace(/&nbsp;/gi, '')
.replace(/<br\s*\/?>/gi, '')
.replace(/<[^>]*>/g, '')
.trim() === ''
)
}
// 提交时按配置清洗空值,但保留 0 和 false 这类有效值。
const sanitizeOutputValue = (value: unknown): unknown => {
const options = sanitizeOutputOptions.value
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeOutputValue(item))
.filter((item) => item !== undefined)
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray
}
if (value && typeof value === 'object') {
const rawValue = toRaw(value)
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
(accumulator, [key, item]) => {
const sanitizedItem = sanitizeOutputValue(item)
if (sanitizedItem !== undefined) {
accumulator[key] = sanitizedItem
}
return accumulator
},
{}
)
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
? undefined
: sanitizedObject
}
if (typeof value === 'string') {
if (options.removeEmptyString && value.trim() === '') {
return undefined
}
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
return undefined
}
return value
}
if (value === 0) {
return options.keepZero ? value : undefined
}
if (value === false) {
return options.keepFalse ? value : undefined
}
return value ?? undefined
}
const getSanitizedOutput = () => {
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>
}
const getProps = (item: FormItem) => {
if (item.props) return item.props
const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props
}
// 获取插槽
const getSlots = (item: FormItem) => {
if (!item.slots) return {}
const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn
}
})
return validSlots
}
// 组件
const getComponent = (item: FormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render
}
// 使用 type 获取预定义组件
const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input']
}
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
}
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
return props.items.filter((item) => !item.hidden)
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value
? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start'
: 'flex-end'
}))
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段(UI 层)
formInstance.value?.resetFields()
// 恢复初始表单值,保留默认值而不是简单清空。
Object.keys(modelValue.value).forEach((key) => {
delete modelValue.value[key]
})
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value))
// 触发 reset 事件
emit('reset')
}
/**
* 处理提交事件
*/
const handleSubmit = () => {
// 对外只抛出清洗后的结果,避免业务层重复过滤空值。
emit('submit', getSanitizedOutput())
}
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset,
// 允许外部在不触发提交事件时主动获取清洗后的输出。
getOutput: getSanitizedOutput
})
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script>
@@ -0,0 +1,581 @@
<!-- 表格搜索组件 -->
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
<template>
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
:model-value="getFieldValue(item.key)"
@update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
<div class="form-buttons">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
{{ t('table.searchBar.reset') }}
</ElButton>
<ElButton
v-if="showSearch"
type="primary"
class="search-button"
@click="handleSearch"
v-ripple
:disabled="disabledSearch"
>
{{ t('table.searchBar.search') }}
</ElButton>
</div>
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
<span>{{ expandToggleText }}</span>
<div class="icon-wrapper">
<ElIcon>
<ArrowUpBold v-if="isExpanded" />
<ArrowDownBold v-else />
</ElIcon>
</div>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { toRaw, type Component } from 'vue'
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance
} from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtSearchBar' })
const componentMap = {
input: ElInput, // 输入框
inputTag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器
}
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置
export interface SearchFormItem {
/** 表单项的唯一标识 */
key: string
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component
/** 是否隐藏该表单项 */
hidden?: boolean
/** 表单项占据的列宽,基于24格栅格系统 */
span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>
/** 传递给表单项组件的属性 */
props?: Record<string, any>
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */
placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface SearchBarProps {
/** 表单数据 */
items: SearchFormItem[]
/** 每列的宽度(基于 24 格布局) */
span?: number
/** 表单控件间隙 */
gutter?: number
/** 展开/收起 */
isExpand?: boolean
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */
labelWidth?: string | number
/** 是否需要展示,收起 */
showExpand?: boolean
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示搜索按钮 */
showSearch?: boolean
/** 是否禁用搜索按钮 */
disabledSearch?: boolean
/** 搜索时是否清洗空值 */
sanitizeOutput?: Partial<SanitizeOutputOptions>
}
interface SanitizeOutputOptions {
/** 移除空字符串 */
removeEmptyString: boolean
/** 移除空数组 */
removeEmptyArray: boolean
/** 移除清洗后为空的对象 */
removeEmptyObject: boolean
/** 移除空富文本占位内容,如 <p><br></p> */
removeEmptyRichText: boolean
/** 保留数字 0 这类有效筛选值 */
keepZero: boolean
/** 保留 false 这类有效筛选值 */
keepFalse: boolean
}
const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [],
span: 6,
gutter: 12,
isExpand: false,
labelPosition: 'right',
labelWidth: '70px',
showExpand: true,
defaultExpanded: false,
buttonLeftLimit: 2,
showReset: true,
showSearch: true,
disabledSearch: false,
sanitizeOutput: () => ({})
})
interface SearchBarEmits {
reset: []
search: [Record<string, any>]
}
const emit = defineEmits<SearchBarEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
const initialModelValue = ref<Record<string, any>>({})
// 保存组件初始化时的表单快照,用于 reset 时恢复默认筛选条件。
const cloneModelValue = (value: Record<string, any> | undefined) => {
if (!value) return {}
const deepClone = (source: unknown): unknown => {
if (Array.isArray(source)) {
return source.map((item) => deepClone(item))
}
if (source && typeof source === 'object') {
const rawSource = toRaw(source)
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key])
return accumulator
}, {})
}
return source
}
return deepClone(toRaw(value)) as Record<string, any>
}
initialModelValue.value = cloneModelValue(modelValue.value)
/**
* 是否展开状态
*/
const isExpanded = ref(props.defaultExpanded)
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
// 搜索参数默认更激进地去掉空值,减少无效 query 参数。
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
removeEmptyString: true,
removeEmptyArray: true,
removeEmptyObject: true,
removeEmptyRichText: true,
keepZero: true,
keepFalse: true,
...props.sanitizeOutput
}))
const getProps = (item: SearchFormItem) => {
if (item.props) return item.props
const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props
}
// 获取插槽
const getSlots = (item: SearchFormItem) => {
if (!item.slots) return {}
const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn
}
})
return validSlots
}
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
}
// 搜索表单清空输入时不保留空字符串,避免后续请求携带空字段。
const normalizeFieldValue = (value: unknown) => {
return value === '' ? undefined : value
}
const getFieldValue = (key: string) => modelValue.value[key]
const setFieldValue = (key: string, value: unknown) => {
const normalizedValue = normalizeFieldValue(value)
if (normalizedValue === undefined) {
delete modelValue.value[key]
return
}
modelValue.value[key] = normalizedValue
}
const isRichTextEmpty = (value: string) => {
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
return false
}
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
return (
value
.replace(/&nbsp;/gi, '')
.replace(/<br\s*\/?>/gi, '')
.replace(/<[^>]*>/g, '')
.trim() === ''
)
}
// 搜索时按配置清洗空值,但保留 0 和 false 这类有效筛选条件。
const sanitizeOutputValue = (value: unknown): unknown => {
const options = sanitizeOutputOptions.value
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeOutputValue(item))
.filter((item) => item !== undefined)
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray
}
if (value && typeof value === 'object') {
const rawValue = toRaw(value)
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
(accumulator, [key, item]) => {
const sanitizedItem = sanitizeOutputValue(item)
if (sanitizedItem !== undefined) {
accumulator[key] = sanitizedItem
}
return accumulator
},
{}
)
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
? undefined
: sanitizedObject
}
if (typeof value === 'string') {
if (options.removeEmptyString && value.trim() === '') {
return undefined
}
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
return undefined
}
return value
}
if (value === 0) {
return options.keepZero ? value : undefined
}
if (value === false) {
return options.keepFalse ? value : undefined
}
return value ?? undefined
}
const getSanitizedOutput = () => {
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>
}
// 组件
const getComponent = (item: SearchFormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render
}
// 使用 type 获取预定义组件
const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input']
}
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden)
const shouldShowLess = !props.isExpand && !isExpanded.value
if (shouldShowLess) {
const maxItemsPerRow = Math.floor(24 / props.span) - 1
return filteredItems.slice(0, maxItemsPerRow)
}
return filteredItems
})
/**
* 是否应该显示展开/收起按钮
*/
const shouldShowExpandToggle = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden)
return (
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
)
})
/**
* 展开/收起按钮文本
*/
const expandToggleText = computed(() => {
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value
? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start'
: 'flex-end'
}))
/**
* 切换展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段(UI 层)
formInstance.value?.resetFields()
// 恢复初始表单值,保留默认搜索条件而不是简单清空。
Object.keys(modelValue.value).forEach((key) => {
delete modelValue.value[key]
})
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value))
// 触发 reset 事件
emit('reset')
}
/**
* 处理搜索事件
*/
const handleSearch = () => {
// 对外只抛出清洗后的查询参数,避免接口收到空数组/空字符串。
emit('search', getSanitizedOutput())
}
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset,
// 允许外部在手动组装请求前直接读取清洗后的参数。
getOutput: getSanitizedOutput
})
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script>
<style lang="scss" scoped>
.art-search-bar {
padding: 15px 20px 0;
.action-column {
flex: 1;
max-width: 100%;
.action-buttons-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.form-buttons {
display: flex;
gap: 8px;
}
.filter-toggle {
display: flex;
align-items: center;
margin-left: 10px;
line-height: 32px;
color: var(--theme-color);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--ElColor-primary);
}
span {
font-size: 14px;
user-select: none;
}
.icon-wrapper {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 14px;
transition: transform 0.2s ease;
}
}
}
}
// 响应式优化
@media (width <= 768px) {
.art-search-bar {
padding: 16px 16px 0;
.action-column {
.action-buttons-wrapper {
flex-direction: column;
gap: 8px;
align-items: stretch;
.form-buttons {
justify-content: center;
}
.filter-toggle {
justify-content: center;
margin-left: 0;
}
}
}
}
}
</style>
@@ -0,0 +1,142 @@
<!-- 面包屑导航 -->
<template>
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
<ul class="flex-c h-full">
<li
v-for="(item, index) in breadcrumbItems"
:key="item.path"
class="box-border flex-c h-7 text-sm leading-7"
>
<div
:class="
isClickable(item, index)
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
: ''
"
@click="handleBreadcrumbClick(item, index)"
>
<span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
>{{ formatMenuTitle(item.meta?.title as string) }}</span
>
</div>
<div
v-if="!isLastItem(index) && item.meta?.title"
class="mx-1 text-sm not-italic text-g-500"
aria-hidden="true"
>
/
</div>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
import { formatMenuTitle } from '@/utils/router'
defineOptions({ name: 'ArtBreadcrumb' })
export interface BreadcrumbItem {
path: string
meta: RouteRecordRaw['meta']
}
const route = useRoute()
const router = useRouter()
// 使用computed替代watch,提高性能
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const { matched } = route
const matchedLength = matched.length
// 处理首页情况
if (!matchedLength || isHomeRoute(matched[0])) {
return []
}
// 处理一级菜单和普通路由
const firstRoute = matched[0]
const isFirstLevel = firstRoute.meta?.isFirstLevel
const lastIndex = matchedLength - 1
const currentRoute = matched[lastIndex]
const currentRouteMeta = currentRoute.meta
let items = isFirstLevel
? [createBreadcrumbItem(currentRoute)]
: matched.map(createBreadcrumbItem)
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
if (items.length > 1 && isWrapperContainer(items[0])) {
items = items.slice(1)
}
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
return [createBreadcrumbItem(currentRoute)]
}
return items
})
// 辅助函数:判断是否为包裹容器路由
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
item.path === '/outside' && !!item.meta?.isIframe
// 辅助函数:创建面包屑项目
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
path: route.path,
meta: route.meta
})
// 辅助函数:判断是否为首页
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
// 辅助函数:判断是否为最后一项
const isLastItem = (index: number): boolean => {
const itemsLength = breadcrumbItems.value.length
return index === itemsLength - 1
}
// 辅助函数:判断是否可点击
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
item.path !== '/outside' && !isLastItem(index)
// 辅助函数:查找路由的第一个有效子路由
const findFirstValidChild = (route: RouteRecordRaw) =>
route.children?.find((child) => !child.redirect && !child.meta?.isHide)
// 辅助函数:构建完整路径
const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/')
// 处理面包屑点击事件
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
// 如果是最后一项或外部链接,不处理
if (isLastItem(index) || item.path === '/outside') {
return
}
try {
// 缓存路由表查找结果
const routes = router.getRoutes()
const targetRoute = routes.find((route) => route.path === item.path)
if (!targetRoute?.children?.length) {
await router.push(item.path)
return
}
const firstValidChild = findFirstValidChild(targetRoute)
if (firstValidChild) {
await router.push(buildFullPath(firstValidChild.path))
} else {
await router.push(item.path)
}
} catch (error) {
console.error('导航失败:', error)
}
}
</script>
@@ -0,0 +1,262 @@
<!-- 系统聊天窗口 -->
<template>
<div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
<div class="mb-5 flex-cb">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="mt-1.5 flex-c gap-1">
<div
class="h-2 w-2 rounded-full"
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
></div>
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
</div>
</div>
<div>
<ElIcon class="c-p" :size="20" @click="closeChat">
<Close />
</ElIcon>
</div>
</div>
<div class="flex h-[calc(100%-70px)] flex-col">
<!-- 聊天消息区域 -->
<div
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
ref="messageContainer"
>
<template v-for="(message, index) in messages" :key="index">
<div
:class="[
'mb-7.5 flex w-full items-start gap-2',
message.isMe ? 'flex-row-reverse' : 'flex-row'
]"
>
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
<div
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
>
<div
:class="[
'mb-1 flex gap-2 text-xs',
message.isMe ? 'flex-row-reverse' : 'flex-row'
]"
>
<span class="font-medium">{{ message.sender }}</span>
<span class="text-g-600">{{ message.time }}</span>
</div>
<div
:class="[
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50'
]"
>{{ message.content }}</div
>
</div>
</div>
</template>
</div>
<!-- 聊天输入区域 -->
<div class="px-4 pt-4">
<ElInput
v-model="messageText"
type="textarea"
:rows="3"
placeholder="输入消息"
resize="none"
@keyup.enter.prevent="sendMessage"
>
<template #append>
<div class="flex gap-2 py-2">
<ElButton :icon="Paperclip" circle plain />
<ElButton :icon="Picture" circle plain />
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
</div>
</template>
</ElInput>
<div class="mt-3 flex-cb">
<div class="flex-c">
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
</div>
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
</div>
</div>
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys'
import meAvatar from '@/assets/images/avatar/avatar5.webp'
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
defineOptions({ name: 'ArtChatWindow' })
// 类型定义
interface ChatMessage {
id: number
sender: string
content: string
time: string
isMe: boolean
avatar: string
}
// 常量定义
const MOBILE_BREAKPOINT = 640
const SCROLL_DELAY = 100
const BOT_NAME = 'Art Bot'
const USER_NAME = 'Ricky'
// 响应式布局
const { width } = useWindowSize()
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
// 组件状态
const isDrawerVisible = ref(false)
const isOnline = ref(true)
// 消息相关状态
const messageText = ref('')
const messageId = ref(10)
const messageContainer = ref<HTMLElement | null>(null)
// 初始化聊天消息数据
const initializeMessages = (): ChatMessage[] => [
{
id: 1,
sender: BOT_NAME,
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
time: '10:00',
isMe: false,
avatar: aiAvatar
},
{
id: 2,
sender: USER_NAME,
content: '我想了解一下系统的使用方法。',
time: '10:01',
isMe: true,
avatar: meAvatar
},
{
id: 3,
sender: BOT_NAME,
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
time: '10:02',
isMe: false,
avatar: aiAvatar
},
{
id: 4,
sender: USER_NAME,
content: '听起来很不错,能具体讲讲数据分析部分吗?',
time: '10:05',
isMe: true,
avatar: meAvatar
},
{
id: 5,
sender: BOT_NAME,
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
time: '10:06',
isMe: false,
avatar: aiAvatar
},
{
id: 6,
sender: USER_NAME,
content: '太好了,那我如何开始使用呢?',
time: '10:08',
isMe: true,
avatar: meAvatar
},
{
id: 7,
sender: BOT_NAME,
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
time: '10:09',
isMe: false,
avatar: aiAvatar
},
{
id: 8,
sender: USER_NAME,
content: '明白了,谢谢你的帮助!',
time: '10:10',
isMe: true,
avatar: meAvatar
},
{
id: 9,
sender: BOT_NAME,
content: '不客气,有任何问题随时联系我。',
time: '10:11',
isMe: false,
avatar: aiAvatar
}
]
const messages = ref<ChatMessage[]>(initializeMessages())
// 工具函数
const formatCurrentTime = (): string => {
return new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const scrollToBottom = (): void => {
nextTick(() => {
setTimeout(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}, SCROLL_DELAY)
})
}
// 消息处理方法
const sendMessage = (): void => {
const text = messageText.value.trim()
if (!text) return
const newMessage: ChatMessage = {
id: messageId.value++,
sender: USER_NAME,
content: text,
time: formatCurrentTime(),
isMe: true,
avatar: meAvatar
}
messages.value.push(newMessage)
messageText.value = ''
scrollToBottom()
}
// 聊天窗口控制方法
const openChat = (): void => {
isDrawerVisible.value = true
scrollToBottom()
}
const closeChat = (): void => {
isDrawerVisible.value = false
}
// 生命周期
onMounted(() => {
scrollToBottom()
mittBus.on('openChat', openChat)
})
onUnmounted(() => {
mittBus.off('openChat', openChat)
})
</script>
@@ -0,0 +1,113 @@
<!-- 顶部快速入口面板 -->
<template>
<ElPopover
ref="popoverRef"
:width="700"
:offset="0"
:show-arrow="false"
trigger="hover"
placement="bottom-start"
popper-class="fast-enter-popover"
:popper-style="{
border: '1px solid var(--default-border)',
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
}"
>
<template #reference>
<div class="flex-c gap-2">
<slot />
</div>
</template>
<div class="grid grid-cols-[2fr_0.8fr]">
<div>
<div class="grid grid-cols-2 gap-1.5">
<!-- 应用列表 -->
<div
v-for="application in enabledApplications"
:key="application.name"
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
@click="handleApplicationClick(application)"
>
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
<ArtSvgIcon
class="text-xl"
:icon="application.icon"
:style="{ color: application.iconColor }"
/>
</div>
<div>
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3>
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
</div>
</div>
</div>
</div>
<div class="border-l-d pl-6 pt-2">
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
<ul>
<li
v-for="quickLink in enabledQuickLinks"
:key="quickLink.name"
class="c-p py-2 hover:[&_span]:text-theme"
@click="handleQuickLinkClick(quickLink)"
>
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
</li>
</ul>
</div>
</div>
</ElPopover>
</template>
<script setup lang="ts">
import { useFastEnter } from '@/hooks/core/useFastEnter'
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
defineOptions({ name: 'ArtFastEnter' })
const router = useRouter()
const popoverRef = ref()
// 使用快速入口配置
const { enabledApplications, enabledQuickLinks } = useFastEnter()
/**
* 处理导航跳转
* @param routeName 路由名称
* @param link 外部链接
*/
const handleNavigate = (routeName?: string, link?: string): void => {
const targetPath = routeName || link
if (!targetPath) {
console.warn('导航配置无效:缺少路由名称或链接')
return
}
if (targetPath.startsWith('http')) {
window.open(targetPath, '_blank')
} else {
router.push({ name: targetPath })
}
popoverRef.value?.hide()
}
/**
* 处理应用项点击
* @param application 应用配置对象
*/
const handleApplicationClick = (application: FastEnterApplication): void => {
handleNavigate(application.routeName, application.link)
}
/**
* 处理快速链接点击
* @param quickLink 快速链接配置对象
*/
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
handleNavigate(quickLink.routeName, quickLink.link)
}
</script>
@@ -0,0 +1,633 @@
<!-- 烟花效果 | 礼花效果 -->
<template>
<canvas
ref="canvasRef"
class="fixed top-0 left-0 z-[9999] w-full h-full pointer-events-none"
></canvas>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { mittBus } from '@/utils/sys'
import type { Handler } from 'mitt'
import bp from '@/assets/images/ceremony/hb.png'
import sd from '@/assets/images/ceremony/sd.png'
import yd from '@/assets/images/ceremony/yd.png'
defineOptions({ name: 'ArtFireworksEffect' })
/**
* 烟花系统配置接口
* 定义了烟花效果的所有可配置参数
*/
interface FireworkConfig {
/** 对象池大小 - 预先创建的粒子对象数量 */
readonly POOL_SIZE: number
/** 每次爆炸产生的粒子数量 */
readonly PARTICLES_PER_BURST: number
/** 各种形状的尺寸配置 */
readonly SIZES: {
/** 矩形粒子的宽高 */
readonly RECTANGLE: { readonly WIDTH: number; readonly HEIGHT: number }
/** 正方形粒子的边长 */
readonly SQUARE: { readonly SIZE: number }
/** 圆形粒子的直径 */
readonly CIRCLE: { readonly SIZE: number }
/** 三角形粒子的边长 */
readonly TRIANGLE: { readonly SIZE: number }
/** 椭圆粒子的宽高 */
readonly OVAL: { readonly WIDTH: number; readonly HEIGHT: number }
/** 图片粒子的宽高 */
readonly IMAGE: { readonly WIDTH: number; readonly HEIGHT: number }
}
/** 旋转相关参数 */
readonly ROTATION: {
/** 基础旋转速度 */
readonly BASE_SPEED: number
/** 随机旋转速度增量 */
readonly RANDOM_SPEED: number
/** 旋转衰减系数 - 控制旋转速度递减 */
readonly DECAY: number
}
/** 物理效果参数 */
readonly PHYSICS: {
/** 重力加速度 */
readonly GRAVITY: number
/** 下落速度阈值 - 超过此值开始淡出 */
readonly VELOCITY_THRESHOLD: number
/** 透明度衰减速度 */
readonly OPACITY_DECAY: number
}
/** 烟花粒子颜色配置(带透明度) */
readonly COLORS: readonly string[]
/** 烟花粒子形状配置(矩形出现概率更高) */
readonly SHAPES: readonly string[]
}
/**
* 单个烟花粒子对象
* 包含粒子的位置、速度、外观等所有属性
*/
interface Firework {
/** X坐标位置 */
x: number
/** Y坐标位置 */
y: number
/** X方向速度 */
vx: number
/** Y方向速度 */
vy: number
/** 粒子颜色 (RGBA格式) */
color: string
/** 当前旋转角度 */
rotation: number
/** 旋转速度 */
rotationSpeed: number
/** 缩放比例 */
scale: number
/** 粒子形状类型 */
shape: string
/** 透明度 (0-1) */
opacity: number
/** 是否处于活动状态 */
active: boolean
/** 图片URL (当shape为image时使用) */
imageUrl?: string
}
/**
* 图片缓存接口
* 用于缓存预加载的图片资源
*/
interface ImageCache {
[url: string]: HTMLImageElement
}
/**
* 烟花效果的全局配置
* 使用 as const 确保配置的不可变性
*/
const CONFIG: FireworkConfig = {
// 性能相关配置
POOL_SIZE: 600, // 对象池大小,影响同时存在的最大粒子数
PARTICLES_PER_BURST: 200, // 每次爆炸的粒子数量,影响视觉效果密度
// 粒子尺寸配置
SIZES: {
RECTANGLE: { WIDTH: 24, HEIGHT: 12 }, // 矩形粒子尺寸
SQUARE: { SIZE: 12 }, // 正方形粒子尺寸
CIRCLE: { SIZE: 12 }, // 圆形粒子尺寸
TRIANGLE: { SIZE: 10 }, // 三角形粒子尺寸
OVAL: { WIDTH: 24, HEIGHT: 12 }, // 椭圆粒子尺寸
IMAGE: { WIDTH: 30, HEIGHT: 30 } // 图片粒子尺寸
},
// 旋转动画配置
ROTATION: {
BASE_SPEED: 2, // 基础旋转速度
RANDOM_SPEED: 3, // 额外随机旋转速度范围
DECAY: 0.98 // 旋转速度衰减系数 (越小衰减越快)
},
// 物理效果配置
PHYSICS: {
GRAVITY: 0.525, // 重力加速度,影响粒子下落速度
VELOCITY_THRESHOLD: 10, // 速度阈值,超过时开始透明度衰减
OPACITY_DECAY: 0.02 // 透明度衰减速度,影响粒子消失快慢
},
// 粒子颜色配置 - 使用RGBA格式支持透明度
COLORS: [
'rgba(255, 68, 68, 1)', // 红色系
'rgba(255, 68, 68, 0.9)',
'rgba(255, 68, 68, 0.8)',
'rgba(255, 116, 188, 1)', // 粉色系
'rgba(255, 116, 188, 0.9)',
'rgba(255, 116, 188, 0.8)',
'rgba(68, 68, 255, 0.8)', // 蓝色系
'rgba(92, 202, 56, 0.7)', // 绿色系
'rgba(255, 68, 255, 0.8)', // 紫色系
'rgba(68, 255, 255, 0.7)', // 青色系
'rgba(255, 136, 68, 0.7)', // 橙色系
'rgba(68, 136, 255, 1)', // 蓝色系
'rgba(250, 198, 122, 0.8)' // 金色系
],
// 粒子形状配置 - 矩形出现概率更高,营造更丰富的视觉效果
SHAPES: [
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'circle',
'triangle',
'oval'
]
} as const
/** Canvas DOM 元素引用 */
const canvasRef = ref<HTMLCanvasElement>()
/** Canvas 2D 绘制上下文 */
const ctx = ref<CanvasRenderingContext2D | null>(null)
/**
* 烟花系统核心类
* 负责管理粒子生命周期、渲染和动画
*/
class FireworkSystem {
/** 粒子对象池 - 预先创建的粒子对象数组 */
private particlePool: Firework[] = []
/** 当前活动的粒子数组 */
private activeParticles: Firework[] = []
/** 对象池索引指针 - 用于循环分配粒子 */
private poolIndex = 0
/** 图片资源缓存 */
private imageCache: ImageCache = {}
/** 动画帧ID - 用于取消动画 */
private animationId = 0
/** 画布宽度缓存 */
private canvasWidth = 0
/** 画布高度缓存 */
private canvasHeight = 0
constructor() {
this.initializePool()
}
/**
* 初始化对象池
* 预先创建指定数量的粒子对象,避免运行时频繁创建
*/
private initializePool(): void {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
this.particlePool.push(this.createParticle())
}
}
/**
* 创建一个新的粒子对象
* 返回初始化状态的粒子
*/
private createParticle(): Firework {
return {
x: 0,
y: 0,
vx: 0,
vy: 0,
color: '',
rotation: 0,
rotationSpeed: 0,
scale: 1,
shape: 'circle',
opacity: 1,
active: false
}
}
/**
* 从对象池获取可用粒子 (性能优化版本)
* 使用循环索引而非Array.find(),时间复杂度从O(n)降至O(1)
* @returns 可用的粒子对象或null
*/
private getAvailableParticle(): Firework | null {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
const index = (this.poolIndex + i) % CONFIG.POOL_SIZE
const particle = this.particlePool[index]
if (!particle.active) {
this.poolIndex = (index + 1) % CONFIG.POOL_SIZE
particle.active = true
return particle
}
}
return null
}
/**
* 预加载单个图片资源
* @param url 图片URL
* @returns Promise<HTMLImageElement>
*/
async preloadImage(url: string): Promise<HTMLImageElement> {
// 如果已缓存,直接返回
if (this.imageCache[url]) {
return this.imageCache[url]
}
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous' // 处理跨域问题
img.onload = () => {
this.imageCache[url] = img
resolve(img)
}
img.onerror = reject
img.src = url
})
}
/**
* 预加载所有需要的图片资源
* 在组件初始化时调用,确保图片ready
*/
async preloadAllImages(): Promise<void> {
const imageUrls = [bp, sd, yd]
try {
await Promise.all(imageUrls.map((url) => this.preloadImage(url)))
} catch (error) {
console.error('Image preloading failed:', error)
}
}
/**
* 创建烟花爆炸效果
* @param imageUrl 可选的图片URL,如果提供则使用图片粒子
*/
createFirework(imageUrl?: string): void {
// 随机确定爆炸起始位置
const startX = Math.random() * this.canvasWidth
const startY = this.canvasHeight
// 根据是否有图片确定可用形状
const availableShapes = imageUrl && this.imageCache[imageUrl] ? ['image'] : CONFIG.SHAPES
// 批量创建粒子数组,减少频繁的数组操作
const particles: Firework[] = []
for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) {
const particle = this.getAvailableParticle()
if (!particle) continue
// 计算粒子发射角度和速度 (恢复原始算法)
const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2) // 扇形分布
const speed = (12 + Math.random() * 6) * 1.5 // 随机速度
const spread = Math.random() * Math.PI * 2 // 360度随机扩散
// 直接属性赋值,避免Object.assign的性能开销
particle.x = startX
particle.y = startY
// 复杂的速度计算,模拟真实烟花爆炸轨迹
particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5)
particle.vy = Math.sin(angle) * speed - 15 // 向上初始速度
particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)]
particle.rotation = Math.random() * 360
particle.rotationSpeed =
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
(Math.random() > 0.5 ? 1 : -1) // 随机旋转方向
particle.scale = 0.8 + Math.random() * 0.4 // 随机缩放
particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)]
particle.opacity = 1
particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : undefined
particles.push(particle)
}
// 批量添加到活动粒子数组,减少多次数组操作
this.activeParticles.push(...particles)
}
/**
* 更新所有粒子的物理状态 (性能优化版本)
* 包括位置、速度、旋转、透明度等
*/
private updateParticles(): void {
const { GRAVITY, VELOCITY_THRESHOLD, OPACITY_DECAY } = CONFIG.PHYSICS
const { DECAY } = CONFIG.ROTATION
// 使用倒序遍历,避免删除元素时的索引混乱问题
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
const particle = this.activeParticles[i]
// 更新粒子位置 (匀加速运动)
particle.x += particle.vx
particle.y += particle.vy
particle.vy += GRAVITY // 重力影响
// 更新旋转状态
particle.rotation += particle.rotationSpeed
particle.rotationSpeed *= DECAY // 旋转速度衰减
// 透明度衰减逻辑 - 当粒子下落速度超过阈值时开始淡出
if (particle.vy > VELOCITY_THRESHOLD) {
particle.opacity -= OPACITY_DECAY
if (particle.opacity <= 0) {
this.recycleParticle(i)
continue
}
}
// 边界检查 - 移除超出屏幕范围的粒子
if (this.isOutOfBounds(particle)) {
this.recycleParticle(i)
}
}
}
/**
* 回收粒子到对象池
* @param index 要回收的粒子在活动数组中的索引
*/
private recycleParticle(index: number): void {
const particle = this.activeParticles[index]
particle.active = false // 标记为非活动状态
this.activeParticles.splice(index, 1) // 从活动数组中移除
}
/**
* 检查粒子是否超出屏幕边界
* @param particle 要检查的粒子
* @returns 是否超出边界
*/
private isOutOfBounds(particle: Firework): boolean {
const margin = 100 // 边界缓冲区
return (
particle.x < -margin ||
particle.x > this.canvasWidth + margin ||
particle.y < -margin ||
particle.y > this.canvasHeight + margin
)
}
/**
* 绘制单个粒子
* @param particle 要绘制的粒子对象
*/
private drawParticle(particle: Firework): void {
if (!ctx.value) return
// 保存当前画布状态
ctx.value.save()
ctx.value.globalAlpha = particle.opacity // 设置透明度
ctx.value.translate(particle.x, particle.y) // 移动到粒子位置
ctx.value.rotate((particle.rotation * Math.PI) / 180) // 应用旋转
ctx.value.scale(particle.scale, particle.scale) // 应用缩放
// 渲染粒子形状
this.renderShape(particle)
// 恢复画布状态
ctx.value.restore()
}
/**
* 根据粒子类型渲染对应的形状
* @param particle 要渲染的粒子
*/
private renderShape(particle: Firework): void {
if (!ctx.value) return
const { SIZES } = CONFIG
ctx.value.fillStyle = particle.color
switch (particle.shape) {
case 'rectangle':
// 绘制矩形
ctx.value.fillRect(
-SIZES.RECTANGLE.WIDTH / 2,
-SIZES.RECTANGLE.HEIGHT / 2,
SIZES.RECTANGLE.WIDTH,
SIZES.RECTANGLE.HEIGHT
)
break
case 'square':
// 绘制正方形
ctx.value.fillRect(
-SIZES.SQUARE.SIZE / 2,
-SIZES.SQUARE.SIZE / 2,
SIZES.SQUARE.SIZE,
SIZES.SQUARE.SIZE
)
break
case 'circle':
// 绘制圆形
ctx.value.beginPath()
ctx.value.arc(0, 0, SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2)
ctx.value.fill()
break
case 'triangle':
// 绘制三角形
ctx.value.beginPath()
ctx.value.moveTo(0, -SIZES.TRIANGLE.SIZE)
ctx.value.lineTo(SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
ctx.value.lineTo(-SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
ctx.value.closePath()
ctx.value.fill()
break
case 'oval':
// 绘制椭圆
ctx.value.beginPath()
ctx.value.ellipse(0, 0, SIZES.OVAL.WIDTH / 2, SIZES.OVAL.HEIGHT / 2, 0, 0, Math.PI * 2)
ctx.value.fill()
break
case 'image':
// 绘制图片
this.renderImage(particle)
break
}
}
/**
* 渲染图片类型的粒子
* @param particle 包含图片URL的粒子对象
*/
private renderImage(particle: Firework): void {
if (!ctx.value || !particle.imageUrl) return
const img = this.imageCache[particle.imageUrl]
if (img?.complete) {
const { WIDTH, HEIGHT } = CONFIG.SIZES.IMAGE
ctx.value.drawImage(img, -WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT)
}
}
/**
* 渲染所有活动粒子到画布
* 清除画布并重新绘制所有粒子
*/
private render(): void {
if (!ctx.value || !canvasRef.value) return
// 清除整个画布
ctx.value.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 设置混合模式为"变亮",增强视觉效果
ctx.value.globalCompositeOperation = 'lighter'
// 渲染所有活动粒子
for (const particle of this.activeParticles) {
this.drawParticle(particle)
}
}
/**
* 动画主循环
* 使用箭头函数保持this绑定
*/
private animate = (): void => {
this.updateParticles() // 更新粒子状态
this.render() // 渲染粒子
this.animationId = requestAnimationFrame(this.animate) // 请求下一帧
}
/**
* 更新画布尺寸缓存
* 在窗口大小改变时调用
* @param width 新的画布宽度
* @param height 新的画布高度
*/
updateCanvasSize(width: number, height: number): void {
this.canvasWidth = width
this.canvasHeight = height
}
/**
* 启动动画循环
*/
start(): void {
this.animate()
}
/**
* 停止动画循环
* 在组件卸载时调用,避免内存泄漏
*/
stop(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId)
this.animationId = 0
}
}
/**
* 获取当前活动粒子数量
* 用于调试和性能监控
* @returns 活动粒子数量
*/
getActiveParticleCount(): number {
return this.activeParticles.length
}
}
/** 烟花系统实例 */
const fireworkSystem = new FireworkSystem()
/**
* 处理键盘快捷键
* 监听 Ctrl+Shift+P 或 Cmd+Shift+P 组合键触发烟花
* @param event 键盘事件对象
*/
const handleKeyPress = (event: KeyboardEvent): void => {
const isFireworkShortcut =
(event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'p') ||
(event.metaKey && event.shiftKey && event.key.toLowerCase() === 'p')
if (isFireworkShortcut) {
event.preventDefault()
fireworkSystem.createFirework()
}
}
/**
* 调整Canvas画布大小
* 响应窗口大小变化,确保画布始终覆盖整个视口
*/
const resizeCanvas = (): void => {
if (!canvasRef.value) return
const { innerWidth, innerHeight } = window
canvasRef.value.width = innerWidth
canvasRef.value.height = innerHeight
fireworkSystem.updateCanvasSize(innerWidth, innerHeight)
}
/**
* 处理外部触发的烟花事件
* 通过 mittBus 事件总线接收触发指令
* @param event 事件数据,可能包含图片URL
*/
const handleFireworkTrigger: Handler<unknown> = (event: unknown) => {
const imageUrl = event as string | undefined
fireworkSystem.createFirework(imageUrl)
}
/**
* 组件挂载时的初始化逻辑
*/
onMounted(async () => {
if (!canvasRef.value) return
// 获取Canvas 2D绘制上下文
ctx.value = canvasRef.value.getContext('2d')
if (!ctx.value) return
// 设置初始画布大小
resizeCanvas()
// 预加载所有图片资源
await fireworkSystem.preloadAllImages()
// 启动动画循环
fireworkSystem.start()
// 注册事件监听器
useEventListener(window, 'keydown', handleKeyPress) // 键盘快捷键
useEventListener(window, 'resize', resizeCanvas) // 窗口大小变化
mittBus.on('triggerFireworks', handleFireworkTrigger) // 外部触发事件
})
/**
* 组件卸载时的清理逻辑
* 停止动画循环并移除事件监听器,防止内存泄漏
*/
onUnmounted(() => {
fireworkSystem.stop()
mittBus.off('triggerFireworks', handleFireworkTrigger)
})
</script>
@@ -0,0 +1,14 @@
<!-- 全局组件 -->
<template>
<component
v-for="componentConfig in enabledComponents"
:key="componentConfig.key"
:is="componentConfig.component"
/>
</template>
<script setup lang="ts">
import { getEnabledGlobalComponents } from '@/config/modules/component'
defineOptions({ name: 'ArtGlobalComponent' })
const enabledComponents = computed(() => getEnabledGlobalComponents())
</script>
@@ -0,0 +1,431 @@
<!-- 全局搜索组件 -->
<template>
<div class="layout-search">
<ElDialog
v-model="showSearchDialog"
width="600"
:show-close="false"
:lock-scroll="false"
modal-class="search-modal"
@close="closeSearchDialog"
>
<ElInput
v-model.trim="searchVal"
:placeholder="$t('search.placeholder')"
@input="search"
@blur="searchBlur"
ref="searchInput"
:prefix-icon="Search"
class="h-12"
>
<template #suffix>
<div
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
>
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
</div>
</template>
</ElInput>
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
<div class="result w-full" v-show="searchResult.length">
<div
class="box !mt-0 c-p text-base leading-none"
v-for="(item, index) in searchResult"
:key="index"
>
<div
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
:class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''"
@click="searchGoPage(item)"
@mouseenter="highlightOnHover(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
</div>
</div>
</div>
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
<div class="mt-1.5 w-full">
<div
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
v-for="(item, index) in historyResult"
:key="index"
:class="
historyHIndex === index
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
: ''
"
@click="searchGoPage(item)"
@mouseenter="highlightOnHoverHistory(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<div
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
@click.stop="deleteHistory(index)"
>
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
</div>
</div>
</div>
</div>
</ElScrollbar>
<template #footer>
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
<div class="flex-cc">
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span>
</div>
<div class="flex-c">
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span>
</div>
<div class="flex-c">
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span>
</div>
</div>
</template>
</ElDialog>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store/modules/user'
import { AppRouteRecord } from '@/types/router'
import { Search } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { type ScrollbarInstance } from 'element-plus'
defineOptions({ name: 'ArtGlobalSearch' })
const userStore = useUserStore()
const { menuList } = storeToRefs(useMenuStore())
const showSearchDialog = ref(false)
const searchVal = ref('')
const searchResult = ref<AppRouteRecord[]>([])
const historyMaxLength = 10
const { searchHistory: historyResult } = storeToRefs(userStore)
const searchInput = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(0)
const historyHIndex = ref(0)
const searchResultScrollbar = ref<ScrollbarInstance>()
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
// 生命周期钩子
onMounted(() => {
mittBus.on('openSearchDialog', openSearchDialog)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 键盘快捷键处理
const handleKeydown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
if (isCommandKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
showSearchDialog.value = true
focusInput()
}
// 当搜索对话框打开时,处理方向键和回车键
if (showSearchDialog.value) {
if (event.key === 'ArrowUp') {
event.preventDefault()
highlightPrevious()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
highlightNext()
} else if (event.key === 'Enter') {
event.preventDefault()
selectHighlighted()
} else if (event.key === 'Escape') {
event.preventDefault()
showSearchDialog.value = false
}
}
}
const focusInput = () => {
setTimeout(() => {
searchInput.value?.focus()
}, 100)
}
// 搜索逻辑
const search = (val: string) => {
if (val) {
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
} else {
searchResult.value = []
}
}
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
const lowerVal = val.toLowerCase()
const result: AppRouteRecord[] = []
const flattenAndMatch = (item: AppRouteRecord) => {
if (item.meta?.isHide) return
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
if (item.children && item.children.length > 0) {
item.children.forEach(flattenAndMatch)
return
}
if (
lowerItemTitle.includes(lowerVal) &&
((item.path && item.path.trim()) || item.meta.link || item.meta.isIframe)
) {
result.push({ ...item, children: undefined })
}
}
items.forEach(flattenAndMatch)
return result
}
// 高亮控制并实现滚动条跟随
const highlightPrevious = () => {
isKeyboardNavigating.value = true
if (searchVal.value) {
highlightedIndex.value =
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
scrollToHighlightedItem()
} else {
historyHIndex.value =
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
scrollToHighlightedHistoryItem()
}
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
setTimeout(() => {
isKeyboardNavigating.value = false
}, 100)
}
const highlightNext = () => {
isKeyboardNavigating.value = true
if (searchVal.value) {
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
scrollToHighlightedItem()
} else {
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
scrollToHighlightedHistoryItem()
}
setTimeout(() => {
isKeyboardNavigating.value = false
}, 100)
}
const scrollToHighlightedItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !searchResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
if (!highlightedElements[highlightedIndex.value]) return
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
}
})
}
const scrollToHighlightedHistoryItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !historyResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
if (!historyItems[historyHIndex.value]) return
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
}
})
}
const selectHighlighted = () => {
if (searchVal.value && searchResult.value.length) {
searchGoPage(searchResult.value[highlightedIndex.value])
} else if (!searchVal.value && historyResult.value.length) {
searchGoPage(historyResult.value[historyHIndex.value])
}
}
const isHighlighted = (index: number) => {
return highlightedIndex.value === index
}
const searchBlur = () => {
highlightedIndex.value = 0
}
const searchGoPage = (item: AppRouteRecord) => {
showSearchDialog.value = false
addHistory(item)
handleMenuJump(item)
searchVal.value = ''
searchResult.value = []
}
// 历史记录管理
const updateHistory = () => {
if (Array.isArray(historyResult.value)) {
userStore.setSearchHistory(historyResult.value)
}
}
const addHistory = (item: AppRouteRecord) => {
const itemKey = item.path || String(item.meta.link || '')
const hasItemIndex = historyResult.value.findIndex(
(historyItem: AppRouteRecord) =>
(historyItem.path || String(historyItem.meta.link || '')) === itemKey
)
if (hasItemIndex !== -1) {
historyResult.value.splice(hasItemIndex, 1)
} else if (historyResult.value.length >= historyMaxLength) {
historyResult.value.pop()
}
const cleanedItem = { ...item }
delete cleanedItem.children
delete cleanedItem.meta.authList
historyResult.value.unshift(cleanedItem)
updateHistory()
}
const deleteHistory = (index: number) => {
historyResult.value.splice(index, 1)
updateHistory()
}
// 对话框控制
const openSearchDialog = () => {
showSearchDialog.value = true
focusInput()
}
const closeSearchDialog = () => {
searchVal.value = ''
searchResult.value = []
highlightedIndex.value = 0
historyHIndex.value = 0
}
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
const highlightOnHover = (index: number) => {
if (!isKeyboardNavigating.value && searchVal.value) {
highlightedIndex.value = index
}
}
const highlightOnHoverHistory = (index: number) => {
if (!isKeyboardNavigating.value && !searchVal.value) {
historyHIndex.value = index
}
}
</script>
<style lang="scss" scoped>
.layout-search {
:deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
}
:deep(.el-dialog__body) {
padding: 5px 0 0 !important;
}
:deep(.el-dialog__header) {
padding: 0;
}
.el-input {
:deep(.el-input__wrapper) {
background-color: var(--art-gray-200);
border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none;
}
:deep(.el-input__inner) {
color: var(--art-gray-800) !important;
}
}
}
.dark .layout-search {
.el-input {
:deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
}
}
:deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
backdrop-filter: none;
}
:deep(.el-dialog) {
background-color: #252526;
}
}
</style>
<style scoped>
@reference '@styles/core/tailwind.css';
.keyboard {
@apply mr-2
box-border
h-5
w-5.5
rounded
border
border-g-400
px-1
text-g-500
shadow-[0_2px_0_var(--default-border-dashed)]
last-of-type:mr-1.5;
}
</style>
@@ -0,0 +1,485 @@
<!-- 顶部栏 -->
<template>
<div
class="w-full bg-[var(--default-bg-color)]"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
]"
>
<div
class="relative box-border flex-b h-15 leading-15 select-none"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google'
? 'border-b border-[var(--art-card-border)]'
: ''
]"
>
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
<!-- 系统信息 -->
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu">
<ArtLogo class="pl-4.5" />
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p>
</div>
<ArtLogo
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
@click="toHome"
/>
<!-- 菜单按钮 -->
<ArtIconButton
v-if="isLeftMenu && shouldShowMenuButton"
icon="ri:menu-2-fill"
class="ml-3 max-sm:ml-[7px]"
@click="visibleMenu"
/>
<!-- 刷新按钮 -->
<ArtIconButton
v-if="shouldShowRefreshButton"
icon="ri:refresh-line"
class="!ml-3 refresh-btn max-sm:!hidden"
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
@click="reload"
/>
<!-- 快速入口 -->
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
<ArtIconButton icon="ri:function-line" class="ml-3" />
</ArtFastEnter>
<!-- 面包屑 -->
<ArtBreadcrumb
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
/>
<!-- 顶部菜单 -->
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
<!-- 混合菜单-顶部 -->
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
</div>
<div class="flex-c gap-2.5">
<!-- 搜索 -->
<div
v-if="shouldShowGlobalSearch"
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden"
@click="openSearchDialog"
>
<div class="flex-c">
<ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
<span class="ml-1 text-xs font-normal text-g-500">{{ $t('topBar.search.title') }}</span>
</div>
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
<span class="ml-0.5 text-xs">k</span>
</div>
</div>
<!-- 全屏按钮 -->
<ArtIconButton
v-if="shouldShowFullscreen"
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<!-- 国际化按钮 -->
<ElDropdown
@command="changeLanguage"
popper-class="langDropDownStyle"
v-if="shouldShowLanguage"
>
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
<template #dropdown>
<ElDropdownMenu>
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
<ElDropdownItem
:command="item.value"
:class="{ 'is-selected': locale === item.value }"
>
<span class="menu-txt">{{ item.label }}</span>
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 通知按钮 -->
<ArtIconButton
v-if="shouldShowNotification"
icon="ri:notification-2-line"
class="notice-button relative"
@click="visibleNotice"
>
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
</ArtIconButton>
<!-- 聊天按钮 -->
<ArtIconButton
v-if="shouldShowChat"
icon="ri:message-3-line"
class="chat-button relative"
@click="openChat"
>
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div>
</ArtIconButton>
<!-- 设置按钮 -->
<div v-if="shouldShowSettings">
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
<template #reference>
<div class="flex-cc">
<ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
</div>
</template>
<template #default>
<p
>{{ $t('topBar.guide.title')
}}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span
>、 <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span
>{{ $t('topBar.guide.description') }}
</p>
</template>
</ElPopover>
</div>
<!-- 主题切换按钮 -->
<ArtIconButton
v-if="shouldShowThemeToggle"
@click="themeAnimation"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
<!-- 用户头像、菜单 -->
<ArtUserMenu />
</div>
</div>
<!-- 标签页 -->
<ArtWorkTab />
<!-- 通知 -->
<ArtNotification v-model:value="showNotice" ref="notice" />
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useFullscreen, useWindowSize } from '@vueuse/core'
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import AppConfig from '@/config'
import { languageOptions } from '@/locales'
import { mittBus } from '@/utils/sys'
import { themeAnimation } from '@/utils/ui/animation'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import ArtUserMenu from './widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtHeaderBar' })
// 检测操作系统类型
const isWindows = navigator.userAgent.includes('Windows')
const router = useRouter()
const { locale } = useI18n()
const { width } = useWindowSize()
const settingStore = useSettingStore()
const userStore = useUserStore()
const menuStore = useMenuStore()
// 顶部栏功能配置
const {
shouldShowMenuButton,
shouldShowRefreshButton,
shouldShowFastEnter,
shouldShowBreadcrumb,
shouldShowGlobalSearch,
shouldShowFullscreen,
shouldShowNotification,
shouldShowChat,
shouldShowLanguage,
shouldShowSettings,
shouldShowThemeToggle,
fastEnterMinWidth: headerBarFastEnterMinWidth
} = useHeaderBar()
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
storeToRefs(settingStore)
const { language } = storeToRefs(userStore)
const { menuList } = storeToRefs(menuStore)
const showNotice = ref(false)
const notice = ref(null)
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
})
/**
* 切换全屏状态
*/
const toggleFullScreen = (): void => {
toggleFullscreen()
}
/**
* 切换菜单显示/隐藏状态
*/
const visibleMenu = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
}
const { homePath } = useCommon()
const { refresh } = useCommon()
/**
* 跳转到首页
*/
const toHome = (): void => {
router.push(homePath.value)
}
/**
* 刷新页面
* @param {number} time - 延迟时间,默认为0毫秒
*/
const reload = (time: number = 0): void => {
setTimeout(() => {
refresh()
}, time)
}
/**
* 初始化语言设置
*/
const initLanguage = (): void => {
locale.value = language.value
}
/**
* 切换系统语言
* @param {LanguageEnum} lang - 目标语言类型
*/
const changeLanguage = (lang: LanguageEnum): void => {
if (locale.value === lang) return
locale.value = lang
userStore.setLanguage(lang)
reload(50)
}
/**
* 打开设置面板
*/
const openSetting = (): void => {
mittBus.emit('openSetting')
// 隐藏设置引导提示
if (showSettingGuide.value) {
settingStore.hideSettingGuide()
}
}
/**
* 打开全局搜索对话框
*/
const openSearchDialog = (): void => {
mittBus.emit('openSearchDialog')
}
/**
* 点击页面其他区域关闭通知面板
* @param {Event} e - 点击事件对象
*/
const bodyCloseNotice = (e: any): void => {
if (!showNotice.value) return
const target = e.target as HTMLElement
// 检查是否点击了通知按钮或通知面板内部
const isNoticeButton = target.closest('.notice-button')
const isNoticePanel = target.closest('.art-notification-panel')
if (!isNoticeButton && !isNoticePanel) {
showNotice.value = false
}
}
/**
* 切换通知面板显示状态
*/
const visibleNotice = (): void => {
showNotice.value = !showNotice.value
}
/**
* 打开聊天窗口
*/
const openChat = (): void => {
mittBus.emit('openChat')
}
</script>
<style lang="scss" scoped>
/* Custom animations */
@keyframes rotate180 {
0% {
transform: rotate(0);
}
100% {
transform: rotate(180deg);
}
}
@keyframes shake {
0% {
transform: rotate(0);
}
25% {
transform: rotate(-5deg);
}
50% {
transform: rotate(5deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0);
}
}
@keyframes expand {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes shrink {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
@keyframes moveUp {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
100% {
transform: translateY(0);
}
}
@keyframes breathing {
0% {
opacity: 0.4;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.4;
transform: scale(0.9);
}
}
/* Hover animation classes */
.refresh-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s;
}
.language-btn:hover :deep(.art-svg-icon) {
animation: moveUp 0.4s;
}
.setting-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s;
}
.full-screen-btn:hover :deep(.art-svg-icon) {
animation: expand 0.6s forwards;
}
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
animation: shrink 0.6s forwards;
}
.notice-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out;
}
/* Breathing animation for chat dot */
.breathing-dot {
animation: breathing 1.5s ease-in-out infinite;
}
/* iPad breakpoint adjustments */
@media screen and (width <= 768px) {
.logo2 {
display: block !important;
}
}
@media screen and (width <= 640px) {
.btn-box {
width: 40px;
}
}
</style>
@@ -0,0 +1,146 @@
<!-- 用户菜单 -->
<template>
<ElPopover
ref="userMenuPopover"
placement="bottom-end"
:width="240"
:hide-after="0"
:offset="10"
trigger="hover"
:show-arrow="false"
popper-class="user-menu-popover"
popper-style="padding: 5px 16px;"
>
<template #reference>
<img
class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]"
src="@imgs/user/avatar.webp"
alt="avatar"
/>
</template>
<template #default>
<div class="pt-3">
<div class="flex-c pb-1 px-0">
<img
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
src="@imgs/user/avatar.webp"
/>
<div class="w-[calc(100%-60px)] h-full">
<span class="block text-sm font-medium text-g-800 truncate">{{
userInfo.userName
}}</span>
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span>
</div>
</div>
<ul class="py-4 mt-3 border-t border-g-300/80">
<li class="btn-item" @click="goPage('/albums/manage')">
<ArtSvgIcon icon="ri:gallery-line" />
<span>{{ $t('menus.albums.manage') }}</span>
</li>
<li class="btn-item" @click="lockScreen()">
<ArtSvgIcon icon="ri:lock-line" />
<span>{{ $t('topBar.user.lockScreen') }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<div class="log-out c-p" @click="loginOut">
{{ $t('topBar.user.logout') }}
</div>
</ul>
</div>
</template>
</ElPopover>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { mittBus } from '@/utils/sys'
import { logoutWithApi } from '@/utils/auth/logout'
defineOptions({ name: 'ArtUserMenu' })
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { getUserInfo: userInfo } = storeToRefs(userStore)
const userMenuPopover = ref()
/**
* 页面跳转
* @param {string} path - 目标路径
*/
const goPage = (path: string): void => {
router.push(path)
}
/**
* 打开锁屏功能
*/
const lockScreen = (): void => {
mittBus.emit('openLockScreen')
}
/**
* 用户登出确认
*/
const loginOut = (): void => {
closeUserMenu()
setTimeout(() => {
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
customClass: 'login-out-dialog'
}).then(() => {
// 先尽力吊销服务端会话(POST /v1/auth/logout),再清本地态
logoutWithApi()
})
}, 200)
}
/**
* 关闭用户菜单弹出层
*/
const closeUserMenu = (): void => {
setTimeout(() => {
userMenuPopover.value.hide()
}, 100)
}
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
@layer components {
.btn-item {
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
span {
@apply text-sm;
}
.art-svg-icon {
@apply mr-2 text-base;
}
&:hover {
background-color: var(--art-gray-200);
}
}
}
.log-out {
@apply py-1.5
mt-5
text-xs
text-center
border
border-g-400
rounded-md
transition-all
duration-200
hover:shadow-xl;
}
</style>
@@ -0,0 +1,110 @@
<!-- 水平菜单 -->
<template>
<div class="flex-1 overflow-hidden">
<ElMenu
:ellipsis="true"
mode="horizontal"
:default-active="routerPath"
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
:popper-offset="-6"
background-color="transparent"
:show-timeout="50"
:hide-timeout="50"
popper-class="horizontal-menu-popper"
class="w-full border-none"
>
<HorizontalSubmenu
v-for="item in filteredMenuItems"
:key="item.path"
:item="item"
:isMobile="false"
:level="0"
/>
</ElMenu>
</div>
</template>
<script setup lang="ts">
import type { AppRouteRecord } from '@/types/router'
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'ArtHorizontalMenu' })
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[]
}
const route = useRoute()
const props = withDefaults(defineProps<Props>(), {
list: () => []
})
/**
* 过滤后的菜单项列表
* 只显示未隐藏的菜单项
*/
const filteredMenuItems = computed(() => {
return filterMenuItems(props.list)
})
/**
* 当前激活的路由路径
* 用于菜单高亮显示
*/
const routerPath = computed(() => String(route.meta.activePath || route.path))
/**
* 递归过滤菜单项,移除隐藏的菜单
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组
* @returns 过滤后的菜单项数组
*/
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items
.filter((item) => {
// 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) {
return false
}
// 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = filterMenuItems(item.children)
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0
}
// 叶子节点且未被隐藏,保留
return true
})
.map((item) => ({
...item,
children: item.children ? filterMenuItems(item.children) : undefined
}))
}
</script>
<style scoped>
/* Remove el-menu bottom border */
:deep(.el-menu) {
border-bottom: none !important;
}
/* Remove default styles for first-level menu items */
:deep(.el-menu-item[tabindex='0']) {
background-color: transparent !important;
border: none !important;
}
/* Remove bottom border from submenu titles */
:deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
</style>
@@ -0,0 +1,106 @@
<template>
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
<template #title>
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="art-text-badge">
{{ item.meta.showTextBadge }}
</div>
</template>
<!-- 递归调用自身处理子菜单 -->
<HorizontalSubmenu
v-for="child in filteredChildren"
:key="child.path"
:item="child"
:theme="theme"
:is-mobile="isMobile"
:level="level + 1"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else-if="isNavigableRoute"
:index="item.path || item.meta.title"
@click="goPage(item)"
>
<ArtSvgIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
class="mr-1 text-lg"
:style="{ color: theme.iconColor }"
/>
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div
v-if="item.meta.showBadge"
class="art-badge"
:style="{ right: level === 0 ? '10px' : '20px' }"
/>
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
{{ item.meta.showTextBadge }}
</div>
</ElMenuItem>
</template>
<script lang="ts" setup>
import { computed, type PropType } from 'vue'
import { AppRouteRecord } from '@/types/router'
import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/utils/router'
const props = defineProps({
item: {
type: Object as PropType<AppRouteRecord>,
required: true
},
theme: {
type: Object,
default: () => ({})
},
isMobile: Boolean,
level: {
type: Number,
default: 0
}
})
const emit = defineEmits(['close'])
// 过滤后的子菜单项(不包含隐藏的)
const filteredChildren = computed(() => {
return props.item.children?.filter((child) => !child.meta.isHide) || []
})
// 父菜单如果本身就是页面,则即使没有可见子菜单也应该保留为菜单项。
const isNavigableRoute = computed(() => {
return !!(
!props.item.meta.isHide &&
((props.item.path && props.item.path.trim()) ||
props.item.meta.link ||
props.item.meta.isIframe === true) &&
(props.item.component || props.item.meta.link || props.item.meta.isIframe === true)
)
})
// 计算当前项是否有可见的子菜单
const hasChildren = computed(() => {
return filteredChildren.value.length > 0
})
const goPage = (item: AppRouteRecord) => {
closeMenu()
handleMenuJump(item)
}
const closeMenu = () => {
emit('close')
}
</script>
<style scoped>
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important;
}
</style>
@@ -0,0 +1,279 @@
<!-- 混合菜单 -->
<template>
<div class="relative box-border flex-c w-full overflow-hidden">
<!-- 左侧滚动按钮 -->
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
<ElIcon>
<ArrowLeft />
</ElIcon>
</div>
<!-- 滚动容器 -->
<ElScrollbar
ref="scrollbarRef"
wrap-class="scrollbar-wrapper"
:horizontal="true"
@scroll="handleScroll"
@wheel="handleWheel"
>
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
<template v-for="item in processedMenuList" :key="item.meta.title">
<div
v-if="!item.meta.isHide"
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
:class="{
'menu-item-active text-theme': item.isActive
}"
@click="handleMenuJump(item, true)"
>
<ArtSvgIcon
:icon="item.meta.icon"
class="text-lg text-g-700 dark:text-g-800 mr-1"
:class="item.isActive && '!text-theme'"
/>
<span
class="text-md text-g-700 dark:text-g-800"
:class="item.isActive && '!text-theme'"
>
{{ item.formattedTitle }}
</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
</div>
</template>
</div>
</ElScrollbar>
<!-- 右侧滚动按钮 -->
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
<ElIcon>
<ArrowRight />
</ElIcon>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { useThrottleFn } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import type { AppRouteRecord } from '@/types/router'
defineOptions({ name: 'ArtMixedMenu' })
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[]
}
interface ProcessedMenuItem extends AppRouteRecord {
isActive: boolean
formattedTitle: string
}
type ScrollDirection = 'left' | 'right'
const route = useRoute()
const props = withDefaults(defineProps<Props>(), {
list: () => []
})
const scrollbarRef = ref<any>()
const showLeftArrow = ref(false)
const showRightArrow = ref(false)
/** 滚动配置 */
const SCROLL_CONFIG = {
/** 点击按钮时的滚动距离 */
BUTTON_SCROLL_DISTANCE: 200,
/** 鼠标滚轮快速滚动时的步长 */
WHEEL_FAST_STEP: 35,
/** 鼠标滚轮慢速滚动时的步长 */
WHEEL_SLOW_STEP: 30,
/** 区分快慢滚动的阈值 */
WHEEL_FAST_THRESHOLD: 100
}
/**
* 获取当前激活路径
* 使用computed缓存,避免重复计算
*/
const currentActivePath = computed(() => {
return String(route.meta.activePath || route.path)
})
/**
* 判断菜单项是否为激活状态
* 递归检查子菜单中是否包含当前路径
* @param item 菜单项数据
* @returns 是否为激活状态
*/
const isMenuItemActive = (item: AppRouteRecord): boolean => {
const activePath = currentActivePath.value
// 如果有子菜单,递归检查子菜单
if (item.children?.length) {
return item.children.some((child) => {
if (child.children?.length) {
return isMenuItemActive(child)
}
return child.path === activePath
})
}
// 直接比较路径
return item.path === activePath
}
/**
* 预处理菜单列表
* 缓存每个菜单项的激活状态和格式化标题
*/
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
return props.list.map((item) => ({
...item,
isActive: isMenuItemActive(item),
formattedTitle: formatMenuTitle(item.meta.title)
}))
})
/**
* 处理滚动事件的核心逻辑
* 根据滚动位置显示/隐藏滚动按钮
*/
const handleScrollCore = (): void => {
if (!scrollbarRef.value?.wrapRef) return
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
// 判断是否显示左侧滚动按钮
showLeftArrow.value = scrollLeft > 0
// 判断是否显示右侧滚动按钮
showRightArrow.value = scrollLeft + clientWidth < scrollWidth
}
/**
* 节流后的滚动事件处理函数
* 调整节流间隔为16ms,约等于60fps
*/
const handleScroll = useThrottleFn(handleScrollCore, 16)
/**
* 滚动菜单容器
* @param direction 滚动方向,left 或 right
*/
const scroll = (direction: ScrollDirection): void => {
if (!scrollbarRef.value?.wrapRef) return
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
const targetScroll =
direction === 'left'
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
// 平滑滚动到目标位置
scrollbarRef.value.wrapRef.scrollTo({
left: targetScroll,
behavior: 'smooth'
})
}
/**
* 处理鼠标滚轮事件
* 优化滚轮响应性能
* @param event 滚轮事件
*/
const handleWheel = (event: WheelEvent): void => {
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
event.preventDefault()
event.stopPropagation()
// 直接处理滚动,提升响应性
if (!scrollbarRef.value?.wrapRef) return
const { wrapRef } = scrollbarRef.value
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
// 使用更小的滚动步长,让滚动更平滑
const scrollStep =
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
? SCROLL_CONFIG.WHEEL_FAST_STEP
: SCROLL_CONFIG.WHEEL_SLOW_STEP
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth))
// 立即滚动,无动画
wrapRef.scrollLeft = targetScroll
// 更新滚动按钮状态
handleScrollCore()
}
/**
* 初始化滚动状态
*/
const initScrollState = (): void => {
nextTick(() => {
handleScrollCore()
})
}
onMounted(initScrollState)
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button-arrow {
@apply absolute
top-1/2
z-2
flex
items-center
justify-center
size-7.5
text-g-600
cursor-pointer
rounded
transition-all
duration-300
-translate-y-1/2
hover:text-g-900
hover:bg-g-200;
}
</style>
<style scoped>
:deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
display: none;
height: 2px;
}
:deep(.scrollbar-wrapper) {
flex: 1;
min-width: 0;
margin: 0 50px 0 30px;
}
.menu-item-active::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 40px;
height: 2px;
margin: auto;
content: '';
background-color: var(--theme-color);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
</style>
@@ -0,0 +1,354 @@
<!-- 左侧菜单 或 双列菜单 -->
<template>
<div
class="layout-sidebar"
v-if="showLeftMenu || isDualMenu"
:class="{ 'no-border': menuList.length === 0 }"
>
<!-- 双列菜单(左侧) -->
<div
v-if="isDualMenu"
class="dual-menu-left"
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }"
>
<ArtLogo class="logo" @click="navigateToHome" />
<ElScrollbar style="height: calc(100% - 135px)">
<ul>
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
<ElTooltip
class="box-item"
effect="dark"
:content="$t(menu.meta.title)"
placement="right"
:offset="15"
:hide-after="0"
:disabled="dualMenuShowText"
>
<div
:class="{
'is-active': menu.meta.isFirstLevel
? menu.path === route.path
: menu.path === firstLevelMenuPath
}"
:style="{
height: dualMenuShowText ? '60px' : '46px'
}"
>
<ArtSvgIcon
class="menu-icon text-g-700 dark:text-g-800"
:icon="menu.meta.icon"
:style="{
marginBottom: dualMenuShowText ? '5px' : '0'
}"
/>
<span v-if="dualMenuShowText" class="text-md text-g-700">
{{ $t(menu.meta.title) }}
</span>
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
</div>
</ElTooltip>
</li>
</ul>
</ElScrollbar>
<ArtIconButton
class="switch-btn size-10"
icon="ri:arrow-left-right-fill"
@click="toggleDualMenuMode"
/>
</div>
<!-- 左侧菜单 || 双列菜单(右侧) -->
<div
v-show="menuList.length > 0"
class="menu-left"
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }"
>
<!-- Logo、系统名称 -->
<div
class="header"
@click="navigateToHome"
:style="{
background: getMenuTheme.background
}"
>
<ArtLogo v-if="!isDualMenu" class="logo" />
<p
:class="{ 'is-dual-menu-name': isDualMenu }"
:style="{
color: getMenuTheme.systemNameColor,
opacity: !menuOpen ? 0 : 1
}"
>
{{ AppConfig.systemInfo.name }}
</p>
</div>
<ElScrollbar :style="scrollbarStyle">
<ElMenu
:class="'el-menu-' + getMenuTheme.theme"
:collapse="!menuOpen"
:default-active="routerPath"
:text-color="getMenuTheme.textColor"
:unique-opened="uniqueOpened"
:background-color="getMenuTheme.background"
:default-openeds="defaultOpenedMenus"
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
:show-timeout="50"
:hide-timeout="50"
>
<SidebarSubmenu
:list="menuList"
:isMobile="isMobileMode"
:theme="getMenuTheme"
@close="handleMenuClose"
/>
</ElMenu>
</ElScrollbar>
<!-- 双列菜单右侧折叠按钮 -->
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
<ArtSvgIcon
class="text-g-500/70"
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
/>
</div>
<div
class="menu-model"
@click="toggleMenuVisibility"
:style="{
opacity: !menuOpen ? 0 : 1,
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useSettingStore } from '@/store/modules/setting'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { useMenuStore } from '@/store/modules/menu'
import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation'
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
defineOptions({ name: 'ArtSidebarMenu' })
const MOBILE_BREAKPOINT = 800
const ANIMATION_DELAY = 350
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
const route = useRoute()
const router = useRouter()
const settingStore = useSettingStore()
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
storeToRefs(settingStore)
// 组件内部状态
const defaultOpenedMenus = ref<string[]>([])
const isMobileMode = ref(false)
const showMobileModal = ref(false)
// 使用 VueUse 的窗口尺寸监听
const { width } = useWindowSize()
// 菜单宽度相关
const menuopenwidth = computed(() => getMenuOpenWidth.value)
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
// 菜单类型判断
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const showLeftMenu = computed(
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
// 移动端屏幕判断(使用 computed 避免重复计算)
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
// 路由相关
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
const routerPath = computed(() => String(route.meta.activePath || route.path))
// 菜单数据
const firstLevelMenus = computed(() => {
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
})
const menuList = computed(() => {
const menuStore = useMenuStore()
const allMenus = menuStore.menuList
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
if (!isTopLeftMenu.value && !isDualMenu.value) {
return allMenus
}
// 处理 iframe 路径
if (isIframe(route.path)) {
return findIframeMenuList(route.path, allMenus)
}
// 处理一级菜单
if (route.meta.isFirstLevel) {
return []
}
// 返回当前顶级路径对应的子菜单
const currentTopPath = `/${route.path.split('/')[1]}`
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
return currentMenu?.children ?? []
})
// 双列菜单收起时的滚动条样式
const scrollbarStyle = computed(() => {
const isCollapsed = isDualMenu.value && !menuOpen.value
return {
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
height: isCollapsed ? 'calc(100% + 50px)' : 'calc(100% - 60px)',
transition: 'transform 0.3s ease'
}
})
/**
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
*/
const { start: delayHideMobileModal } = useTimeoutFn(
() => {
showMobileModal.value = false
},
ANIMATION_DELAY,
{ immediate: false }
)
/**
* 查找 iframe 对应的二级菜单列表
*/
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
// 递归查找包含当前路径的菜单项
const hasPath = (items: any[]): boolean => {
for (const item of items) {
if (item.path === currentPath) {
return true
}
if (item.children && hasPath(item.children)) {
return true
}
}
return false
}
// 遍历一级菜单查找匹配的子菜单
for (const menu of menuList) {
if (menu.children && hasPath(menu.children)) {
return menu.children
}
}
return []
}
const { homePath } = useCommon()
/**
* 导航到首页
*/
const navigateToHome = (): void => {
router.push(homePath.value)
}
/**
* 切换菜单显示/隐藏
*/
const toggleMenuVisibility = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
// 移动端模态框控制逻辑
if (isMobileScreen.value) {
if (!menuOpen.value) {
// 菜单即将打开,立即显示模态框
showMobileModal.value = true
} else {
// 菜单即将关闭,延迟隐藏模态框确保动画完成
delayHideMobileModal()
}
}
}
/**
* 处理菜单关闭(来自子组件)
*/
const handleMenuClose = (): void => {
if (isMobileScreen.value) {
settingStore.setMenuOpen(false)
delayHideMobileModal()
}
}
/**
* 切换双列菜单模式
*/
const toggleDualMenuMode = (): void => {
settingStore.setDualMenuShowText(!dualMenuShowText.value)
}
/**
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false)
if (!menuOpen.value) {
showMobileModal.value = false
}
} else {
showMobileModal.value = false
}
})
/**
* 监听菜单开关状态变化
*/
watch(menuOpen, (isMenuOpen: boolean) => {
if (!isMobileScreen.value) {
// 大屏幕设备上,模态框始终隐藏
showMobileModal.value = false
} else {
// 小屏幕设备上,根据菜单状态控制模态框
if (isMenuOpen) {
// 菜单打开时立即显示模态框
showMobileModal.value = true
} else {
// 菜单关闭时延迟隐藏模态框,确保动画完成
delayHideMobileModal()
}
}
})
</script>
<style lang="scss" scoped>
@use './style';
</style>
<style lang="scss">
@use './theme';
.layout-sidebar {
// 展开的宽度
.el-menu:not(.el-menu--collapse) {
width: v-bind(menuopenwidth);
}
// 折叠后宽度
.el-menu--collapse {
width: v-bind(menuclosewidth);
}
}
</style>

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