chore: initialize GSREA website project
No files matched your search
@@ -0,0 +1,41 @@
|
||||
<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 { systemUpgrade } from './utils/sys'
|
||||
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)
|
||||
systemUpgrade()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchAboutPage(pageKey: string) {
|
||||
return request.get<Record<string, any>>({ url: `/api/admin/about/pages/${pageKey}` })
|
||||
}
|
||||
|
||||
export function saveAboutPage(pageKey: string, data: Record<string, unknown>) {
|
||||
return request.put({ url: `/api/admin/about/pages/${pageKey}`, data })
|
||||
}
|
||||
|
||||
export function fetchAboutArticles(category: string) {
|
||||
return request.get<{ category: string; items: any[] }>({
|
||||
url: `/api/admin/about/articles/${category}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAboutExperts() {
|
||||
return request.get<{ items: any[] }>({ url: '/api/admin/about/experts' })
|
||||
}
|
||||
|
||||
export function createAboutExpert(data: Record<string, unknown>) {
|
||||
return request.post<{ id: number }>({ url: '/api/admin/about/experts', data })
|
||||
}
|
||||
|
||||
export function updateAboutExpert(id: number, data: Record<string, unknown>) {
|
||||
return request.put({ url: `/api/admin/about/experts/${id}`, data })
|
||||
}
|
||||
|
||||
export function deleteAboutExpert(id: number) {
|
||||
return request.del({ url: `/api/admin/about/experts/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/http'
|
||||
import type { AdminCaptcha, AdminLoginResult } from '@/types/admin'
|
||||
|
||||
export interface LoginBody {
|
||||
username: string
|
||||
password: string
|
||||
captchaId: string
|
||||
captchaCode: string
|
||||
}
|
||||
|
||||
export function fetchCaptcha() {
|
||||
return request.get<AdminCaptcha>({
|
||||
url: '/api/admin/auth/captcha'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchLogin(data: LoginBody) {
|
||||
return request.post<AdminLoginResult>({
|
||||
url: '/api/admin/auth/login',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchChangePassword(data: { oldPassword: string; newPassword: string }) {
|
||||
return request.post<{ ok?: boolean }>({
|
||||
url: '/api/admin/auth/password',
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchDisciplineNotice() {
|
||||
return request.get<Record<string, any>>({ url: '/api/admin/discipline/notice' })
|
||||
}
|
||||
|
||||
export function saveDisciplineNotice(data: Record<string, unknown>) {
|
||||
return request.put<Record<string, any>>({ url: '/api/admin/discipline/notice', data })
|
||||
}
|
||||
|
||||
export function fetchDisciplineComplaints(status?: string) {
|
||||
return request.get<{ items: any[]; pendingCount: number; workdays: number }>({
|
||||
url: '/api/admin/discipline/complaints',
|
||||
params: status ? { status } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDisciplineComplaint(id: number) {
|
||||
return request.get<Record<string, any>>({ url: `/api/admin/discipline/complaints/${id}` })
|
||||
}
|
||||
|
||||
export function processDisciplineComplaint(id: number) {
|
||||
return request.post({ url: `/api/admin/discipline/complaints/${id}/process` })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchExpertPendingCount() {
|
||||
return request.get<{ count: number }>({ url: '/api/admin/expert-questions/pending-count' })
|
||||
}
|
||||
|
||||
export function fetchExpertQuestions(status?: string) {
|
||||
return request.get<{ items: any[]; pendingCount: number; replyWorkdays: number }>({
|
||||
url: '/api/admin/expert-questions',
|
||||
params: status ? { status } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchExpertQuestion(id: number) {
|
||||
return request.get<Record<string, any>>({ url: `/api/admin/expert-questions/${id}` })
|
||||
}
|
||||
|
||||
export function replyExpertQuestion(id: number, data: Record<string, unknown>) {
|
||||
return request.post({ url: `/api/admin/expert-questions/${id}/reply`, data })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchAdminHome() {
|
||||
return request.get<Record<string, any>>({ url: '/api/admin/home' })
|
||||
}
|
||||
|
||||
export function saveAdminHome(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home', data })
|
||||
}
|
||||
|
||||
export function saveArticleDisplay(data: Record<string, number>) {
|
||||
return request.put({ url: '/api/admin/home/articles/display', data })
|
||||
}
|
||||
|
||||
export function createHomeArticle(data: Record<string, unknown>) {
|
||||
return request.post<{ id: number }>({ url: '/api/admin/home/articles', data })
|
||||
}
|
||||
|
||||
export function updateHomeArticle(id: number, data: Record<string, unknown>) {
|
||||
return request.put({ url: `/api/admin/home/articles/${id}`, data })
|
||||
}
|
||||
|
||||
export function deleteHomeArticle(id: number) {
|
||||
return request.del({ url: `/api/admin/home/articles/${id}` })
|
||||
}
|
||||
|
||||
export function createHomeSlide(data: Record<string, unknown>) {
|
||||
return request.post<{ id: number }>({ url: '/api/admin/home/slides', data })
|
||||
}
|
||||
|
||||
export function updateHomeSlide(id: number, data: Record<string, unknown>) {
|
||||
return request.put({ url: `/api/admin/home/slides/${id}`, data })
|
||||
}
|
||||
|
||||
export function deleteHomeSlide(id: number) {
|
||||
return request.del({ url: `/api/admin/home/slides/${id}` })
|
||||
}
|
||||
|
||||
export function saveAudiences(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/audiences', data })
|
||||
}
|
||||
|
||||
export function saveHotLinks(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/hot-links', data })
|
||||
}
|
||||
|
||||
export function saveHomeMenu(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/menu', data })
|
||||
}
|
||||
|
||||
export function fetchEduPlatforms() {
|
||||
return request.get<{ items: Record<string, any>[] }>({ url: '/api/admin/edu/platforms' })
|
||||
}
|
||||
|
||||
export function saveEduPlatforms(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/edu/platforms', data })
|
||||
}
|
||||
|
||||
export function saveHonors(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/honors', data })
|
||||
}
|
||||
|
||||
export function saveMembers(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/members', data })
|
||||
}
|
||||
|
||||
export function saveContact(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/contact', data })
|
||||
}
|
||||
|
||||
export function saveRelatedLinks(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/related-links', data })
|
||||
}
|
||||
|
||||
export function saveCompliance(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/compliance', data })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchLegalPage(pageKey: string) {
|
||||
return request.get<Record<string, any>>({ url: `/api/admin/legal/${pageKey}` })
|
||||
}
|
||||
|
||||
export function saveLegalPage(pageKey: string, data: Record<string, unknown>) {
|
||||
return request.put({ url: `/api/admin/legal/${pageKey}`, data })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export interface ReportFiling {
|
||||
id: number
|
||||
institutionName: string
|
||||
reportNumber: string
|
||||
issuedOn: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export function fetchReportFilings() {
|
||||
return request.get<{ items: ReportFiling[] }>({ url: '/api/admin/reports' })
|
||||
}
|
||||
|
||||
export function createReportFiling(data: Omit<ReportFiling, 'id'>) {
|
||||
return request.post<{ id: number }>({ url: '/api/admin/reports', data })
|
||||
}
|
||||
|
||||
export function updateReportFiling(id: number, data: Omit<ReportFiling, 'id'>) {
|
||||
return request.put({ url: `/api/admin/reports/${id}`, data })
|
||||
}
|
||||
|
||||
export function deleteReportFiling(id: number) {
|
||||
return request.del({ url: `/api/admin/reports/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
import request from '@/utils/http'
|
||||
import type {
|
||||
AdminFile,
|
||||
AdminFileQuery,
|
||||
AdminResource,
|
||||
AdminResourcePayload,
|
||||
AdminRole,
|
||||
AdminRolePayload,
|
||||
AdminRoleQuery,
|
||||
AdminUser,
|
||||
AdminUserPayload,
|
||||
AdminUserQuery,
|
||||
AuditLogItem,
|
||||
AuditLogQuery,
|
||||
FilterPreview,
|
||||
IpBlacklistItem,
|
||||
LoginSecurity,
|
||||
PageResult,
|
||||
SensitiveWord,
|
||||
SiteSettings
|
||||
} from '@/types/admin'
|
||||
|
||||
function cleanQuery(params?: object): Record<string, unknown> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (!params) return query
|
||||
Object.entries(params as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
query[key] = value
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
function asPage<T>(payload: unknown): PageResult<T> {
|
||||
if (Array.isArray(payload)) {
|
||||
return { records: payload as T[], current: 1, size: payload.length, total: payload.length }
|
||||
}
|
||||
if (payload && typeof payload === 'object') {
|
||||
const data = payload as Partial<PageResult<T>>
|
||||
const records = Array.isArray(data.records) ? data.records : []
|
||||
return {
|
||||
records,
|
||||
current: Number(data.current ?? 1),
|
||||
size: Number(data.size ?? records.length),
|
||||
total: Number(data.total ?? records.length)
|
||||
}
|
||||
}
|
||||
return { records: [], current: 1, size: 0, total: 0 }
|
||||
}
|
||||
|
||||
function asList<T>(payload: unknown): T[] {
|
||||
if (Array.isArray(payload)) return payload as T[]
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
Array.isArray((payload as { records?: T[] }).records)
|
||||
) {
|
||||
return (payload as { records: T[] }).records
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function fetchGetUserList(params: AdminUserQuery) {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/users', params: cleanQuery(params) })
|
||||
.then((data) => asPage<AdminUser>(data))
|
||||
}
|
||||
|
||||
export function fetchCreateUser(data: AdminUserPayload) {
|
||||
return request.post<AdminUser>({ url: '/api/admin/users', data })
|
||||
}
|
||||
|
||||
export function fetchUpdateUser(id: number, data: Partial<AdminUserPayload>) {
|
||||
return request.put<AdminUser>({ url: `/api/admin/users/${id}`, data })
|
||||
}
|
||||
|
||||
export function fetchResetUserPassword(id: number, newPassword: string) {
|
||||
return request.post({ url: `/api/admin/users/${id}/password`, data: { newPassword } })
|
||||
}
|
||||
|
||||
export function fetchGetRoleList(params: AdminRoleQuery) {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/roles', params: cleanQuery(params) })
|
||||
.then((data) => asPage<AdminRole>(data))
|
||||
}
|
||||
|
||||
export async function fetchAllRoles() {
|
||||
const first = await fetchGetRoleList({ current: 1, size: 100 })
|
||||
const records = [...first.records]
|
||||
const size = first.size || 200
|
||||
const pages = Math.ceil((first.total || records.length) / size)
|
||||
for (let current = 2; current <= pages && current <= 20; current += 1) {
|
||||
const next = await fetchGetRoleList({ current, size })
|
||||
records.push(...next.records)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
export function fetchCreateRole(data: AdminRolePayload) {
|
||||
return request.post<AdminRole>({ url: '/api/admin/roles', data })
|
||||
}
|
||||
|
||||
export function fetchUpdateRole(id: number, data: AdminRolePayload) {
|
||||
return request.put<AdminRole>({ url: `/api/admin/roles/${id}`, data })
|
||||
}
|
||||
|
||||
export function fetchUpdateRoleResources(id: number, resourceIds: number[]) {
|
||||
return request.put({ url: `/api/admin/roles/${id}/resources`, data: { resourceIds } })
|
||||
}
|
||||
|
||||
export function fetchGetResources() {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/resources' })
|
||||
.then((data) => asList<AdminResource>(data))
|
||||
}
|
||||
|
||||
export function fetchCreateResource(data: AdminResourcePayload) {
|
||||
return request.post<AdminResource>({ url: '/api/admin/resources', data })
|
||||
}
|
||||
|
||||
export function fetchUpdateResource(id: number, data: AdminResourcePayload) {
|
||||
return request.put<AdminResource>({ url: `/api/admin/resources/${id}`, data })
|
||||
}
|
||||
|
||||
export function fetchDeleteResource(id: number) {
|
||||
return request.del({ url: `/api/admin/resources/${id}` })
|
||||
}
|
||||
|
||||
export function fetchGetAuditLogs(params: AuditLogQuery) {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/audit-logs', params: cleanQuery(params) })
|
||||
.then((data) => asPage<AuditLogItem>(data))
|
||||
}
|
||||
|
||||
export function fetchGetLoginSecurity() {
|
||||
return request.get<LoginSecurity>({ url: '/api/admin/login-security' })
|
||||
}
|
||||
|
||||
export function fetchUpdateLoginSecurity(data: LoginSecurity) {
|
||||
return request.put<LoginSecurity>({ url: '/api/admin/login-security', data })
|
||||
}
|
||||
|
||||
export function fetchGetIpBlacklist() {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/ip-blacklist' })
|
||||
.then((data) => asList<IpBlacklistItem>(data))
|
||||
}
|
||||
|
||||
export function fetchCreateIpBlacklist(data: { ip: string; remark: string }) {
|
||||
return request.post<IpBlacklistItem>({ url: '/api/admin/ip-blacklist', data })
|
||||
}
|
||||
|
||||
export function fetchDeleteIpBlacklist(id: number) {
|
||||
return request.del({ url: `/api/admin/ip-blacklist/${id}` })
|
||||
}
|
||||
|
||||
export function uploadAdminFile(
|
||||
file: File,
|
||||
bucket: 'public' | 'private',
|
||||
onProgress?: (percent: number) => void,
|
||||
showErrorMessage = true
|
||||
) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
data.append('bucket', bucket)
|
||||
return request.post<AdminFile>({
|
||||
url: '/api/admin/files',
|
||||
data,
|
||||
timeout: 120000,
|
||||
showErrorMessage,
|
||||
onUploadProgress: (event: AxiosProgressEvent) => {
|
||||
if (!onProgress || !event.total) return
|
||||
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchGetFileList(params: AdminFileQuery) {
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/files', params: cleanQuery(params) })
|
||||
.then((data) => asPage<AdminFile>(data))
|
||||
}
|
||||
|
||||
export function fetchGetFile(id: number) {
|
||||
return request.get<AdminFile>({ url: `/api/admin/files/${id}` })
|
||||
}
|
||||
|
||||
export function fetchDeleteFile(id: number) {
|
||||
return request.del({ url: `/api/admin/files/${id}` })
|
||||
}
|
||||
|
||||
export function fetchGetSiteSettings() {
|
||||
return request.get<SiteSettings>({ url: '/api/admin/site-settings' })
|
||||
}
|
||||
|
||||
export function fetchUpdateSiteSettings(data: Partial<SiteSettings>) {
|
||||
return request.put<SiteSettings>({ url: '/api/admin/site-settings', data })
|
||||
}
|
||||
|
||||
export function fetchGetSensitiveWords(params?: {
|
||||
current?: number
|
||||
size?: number
|
||||
word?: string
|
||||
}) {
|
||||
const word = params?.word?.trim().toLowerCase() || ''
|
||||
const query = { ...(params || {}) }
|
||||
delete query.word
|
||||
return request
|
||||
.get<unknown>({ url: '/api/admin/sensitive-words', params: cleanQuery(query) })
|
||||
.then((data) => {
|
||||
const page = asPage<SensitiveWord>(data)
|
||||
const records = word
|
||||
? page.records.filter((item) => item.word.toLowerCase().includes(word))
|
||||
: page.records
|
||||
if (!Array.isArray(data) || !params?.current || !params.size) {
|
||||
return { ...page, records, total: records.length }
|
||||
}
|
||||
const start = (params.current - 1) * params.size
|
||||
return {
|
||||
records: records.slice(start, start + params.size),
|
||||
current: params.current,
|
||||
size: params.size,
|
||||
total: records.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateSensitiveWord(word: string) {
|
||||
return request.post<SensitiveWord>({ url: '/api/admin/sensitive-words', data: { word } })
|
||||
}
|
||||
|
||||
export function fetchDeleteSensitiveWord(id: number) {
|
||||
return request.del({ url: `/api/admin/sensitive-words/${id}` })
|
||||
}
|
||||
|
||||
export function fetchPreviewFilter(html: string) {
|
||||
return request.post<FilterPreview>({
|
||||
url: '/api/admin/content/preview-filter',
|
||||
data: { html }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export function fetchUploadedCases(kind: string, current = 1, size = 10, q = '') {
|
||||
const keyword = q.trim()
|
||||
return request.get<{ items: any[]; total: number; current: number; size: number }>({
|
||||
url: '/api/admin/uploaded-cases',
|
||||
params: { kind, current, size, ...(keyword ? { q: keyword } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUploadedCase(id: number) {
|
||||
return request.get<Record<string, any>>({ url: `/api/admin/uploaded-cases/${id}` })
|
||||
}
|
||||
|
After Width: | Height: | Size: 954 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 944 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 416 B |
|
After Width: | Height: | Size: 509 B |
@@ -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 |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M290.7 38.4h-50.3v63.5h50.3V38.4Zm-16 47.5V54.4h-18.3v31.5h18.3ZM199 71.3V38.7h-16v48.6h32v14.6h16V38.7h-16v32.6h-16ZM331.7 87.3v14.6h16V38.7h-16v32.6h-16V38.7h-16v48.6h32Z" fill="#DEEBFC"/><path d="M324.3 119.5h24.1v-17.4h-24.1" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M218.5 120H69.4v11.5h130.5l18.6-11.5ZM231.5 131.5h117.2V120H231.5v11.5Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M348.4 119.5v-17.4h-24.1v17.4h24.1Z" fill="#071F4D"/><path d="M159.6 119.5h164.9V102H169.2M114.8 102H57.9v17.5h39.6" stroke="#071F4D"/><path d="M242.5 223.2V98.7c0-3.6 2.9-6.6 6.6-6.6m0 0c3.6 0 6.6 2.9 6.6 6.6v2.8M243.1 153.9h53.1M243.1 193.9h53.1M296.5 219.1V98.8c0-3.6 2.9-6.6 6.6-6.6 3.6 0 6.6 2.9 6.6 6.6v2.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m140.5 96.4 15.9 12.9 6.6-9.4-12.2-11.2-8.2 5.8-2.1 1.9Z" fill="#C7DEFF"/><path d="m170.2 93.5-4.2 4-3-2.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M150.2 87.1s3.7-5.3 8.1-3.9c2.7.8 1.7-1.6 1.7-1.6l-9.2-5.6-3.9 8.5 3.3 2.6Z" fill="#C7DEFF"/><path d="m148 85.5 13 12.1" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M179.202 87.001c.9-.1 1.7-.6 2.3-1.4.6-.6.9-1.4.8-1.9-.2-1.1-38.4-36-39.6-37.1-.7-.6-2-.7-3.3 0-.7.3-1.3 1-1.8 1.9 0 0-3 .4 1.3 11.1 4.3 10.6 6.6 19.1 6.6 19.1s-1.8 4.1-2.8 6.6c-.9 1.7 5.8 2.1 8.4-4.4 4.4.8 13.6 3.4 19 12.6v.1c1.1 1.9 1.9 1.2 2.4.9 3.2-2.4 7.2-5.8 6.7-7.5Z" fill="#00E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M182.3 83.802c-.2-1.1-38.4-36-39.6-37.1-.7-.6-2-.7-3.3 0l42.2 39c.5-.7.8-1.4.7-1.9Z" fill="#071F4D"/><path d="M137.8 48.4 179.2 87M140.4 63.2l5.2-3.9M143.6 72.4s3.2-2.1 6.8-2.3M144.8 75.903c.6-.2 5.4-1.8 7.7-1.5M142.3 91.6l4.2-3.8M159.9 103.9l5-6.5" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m206.1 129.6 11.7 16.7 9-7.2-8.7-14.1-9.5 3.3-2.5 1.3Z" fill="#C7DEFF"/><path d="m235.4 134.9-5.2 2.7-2.2-3.2" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M217.9 123.3s4.9-4.1 8.8-1.6c2.4 1.5 2.1-1.1 2.1-1.1l-7.3-7.9-6.1 7.2 2.5 3.4Z" fill="#C7DEFF"/><path d="m216.2 121.1 9.2 15.2" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M237.397 136.399c3.7-1.4 8.5-3.7 8.5-5.4.8.2 1.8-.1 2.6-.7.7-.4 1.3-1 1.3-1.6.1-1.2-27.2-45.1-28.1-46.5-.5-.7-1.8-1.1-3.2-.8-.8.2-1.6.6-2.3 1.4 0 0-3-.4-1.8 11 1.3 11.4 1.2 20.2 1.2 20.2s-2.8 3.5-4.4 5.6c-1.3 1.4 5 3.5 9.3-2 4.1 1.9 12.2 6.9 14.9 17.2v.1c.5 2.1 1.5 1.7 2 1.5Z" fill="#00E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M249.8 128.699c.1-1.2-27.2-45.1-28.1-46.5-.5-.7-1.8-1.1-3.2-.8l30 49c.6-.5 1.2-1.1 1.3-1.7Z" fill="#071F4D"/><path d="m216.5 82.6 29.4 48.4M214.9 97.6l6.1-2.3M215.5 107.395s3.7-1.2 7.2-.4M215.7 111.098c.6 0 5.7-.3 7.8.7M209.1 125.5l5-2.5M222.7 142.1l6.5-4.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M208.2 122c-2.5 1-29.9 15.6-47.1 28.8-17.2 13.2-27 23.3-30.3 30.3-1.8 3.6-2.4 4.4-2.4 4.4-1.3-3.4-3.6-10.1-3.9-12.4-.4-4.1 0-9 3.3-14.3 3.3-5.3 27.4-43.9 32.4-51.9-2.4-1.9-8.2-6.8-19.8-16.9-3.4 2.8-35.6 29.9-44.6 38.5-9 8.6-10.8 15.7-10.8 29.3v63.6s25.2 3.9 44.3 2.1c19.1-1.8 44.1-4.1 44.1-4.1s-2.6-29.6 2.9-36.1c5.4-6.5 41.6-34.2 45.5-37.5-3.8-7.5-9-15.8-13.6-23.8Z" fill="#006EFF"/><path d="M154.103 116.7c-9 14.4-23.8 38.1-26.3 42.1-3.3 5.3-3.7 10.2-3.3 14.3M85.1 215.7v-57.9c0-13.6 1.8-20.8 10.8-29.3 1.8-1.7 4.5-4.2 7.8-7.1M197.402 165.1c-9.5 7.6-18.6 15.2-21.1 18.2-5.4 6.5-2.9 36.1-2.9 36.1M123.4 218.3s4.2-30.3 7.5-37.2c3.3-6.9 13-17.1 30.3-30.3 10.5-8.1 24.9-16.6 35-22.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.1 256.4v-8h-68V264s14.2-5.5 31.1-3.8c29.1 2.9 36.8-3.8 36.8-3.8h.1ZM271.5 255.1h-31.1v9.2s6.5-5.5 14.2-3.8c13.4 2.9 16.9-3.8 16.9-3.8v-1.6Z" fill="#C7DEFF"/><path d="M105.2 245h51.9M240.7 251.7h30.6M165.6 245h7.2M56.7 219.6c10.4 0 17.4.5 23.2 1.1 11.4 1.3 18.4 3.1 39 3.1 31.1 0 31.1-4.3 62.2-4.3s31.2 4.3 51.5 4.3 27.7-5.8 51.5-5.8c15.1 0 20.4 5.8 51.5 5.8" stroke="#071F4D"/><path d="M284.1 218c16.2 0 21.5 5.8 51.5 5.8" stroke="#071F4D"/><path d="M159.5 203.5v6.2M202.4 146.7l-31.6 25.9c-7.1 5.9-11.3 14.7-11.3 23.9M206.7 143.2l2.7-2.3M138.098 118.6l-19.2 24c-7.5 9.4-11.3 21.3-10.6 33.3l2.4 38.5M143.6 111.7l3.2-4" stroke="#DEEBFC"/><path d="m141.8 178.4 6-1.1M94.5 140.6l5.4-4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M135.2 236.099c7.395-.581 13.632-1.202 19.262-1.762 19.569-1.948 31.803-3.166 59.838.562 24.429 3.262 31.821 1.804 43.661-.53 3.766-.743 7.982-1.574 13.339-2.37 14.976-2.226 25.902.28 35.11 2.393 4.442 1.019 8.485 1.946 12.39 2.207 12 .7 16.8-.3 16.8-.3v-9.6c-.1.04-4.95.99-16.8.3-3.972-.232-8.097-1.176-12.641-2.215-9.183-2.101-20.074-4.593-34.859-2.385-5.305.792-9.493 1.619-13.239 2.358-11.858 2.342-19.291 3.809-43.761.542-28.035-3.728-40.269-2.51-59.838-.562-5.63.56-11.867 1.181-19.262 1.Line truncated
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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,537 @@
|
||||
// 优化 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-overlay-dialog {
|
||||
box-sizing: border-box;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 80px);
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
flex-shrink: 0;
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
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__footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,34 @@
|
||||
<template>
|
||||
<ElImage
|
||||
class="cover-thumb"
|
||||
:src="url || coverEmpty"
|
||||
:fit="url ? 'cover' : 'contain'"
|
||||
:style="{ width: `${width}px`, height: `${height}px` }"
|
||||
:preview-src-list="url ? [url] : undefined"
|
||||
preview-teleported
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import coverEmpty from '@/assets/images/cover-empty.png'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
url?: string | null
|
||||
width?: number
|
||||
height?: number
|
||||
}>(),
|
||||
{
|
||||
url: '',
|
||||
width: 48,
|
||||
height: 48
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cover-thumb {
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<ElInput
|
||||
v-model="draft"
|
||||
clearable
|
||||
:placeholder="placeholder"
|
||||
class="list-keyword-search"
|
||||
@keyup.enter="submit"
|
||||
@clear="submit"
|
||||
/>
|
||||
<ElButton type="primary" @click="submit">检索</ElButton>
|
||||
<ElButton @click="reset">重置</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: '输入关键词检索'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
search: [string]
|
||||
}>()
|
||||
|
||||
const draft = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
draft.value = value
|
||||
}
|
||||
)
|
||||
|
||||
function submit() {
|
||||
const value = draft.value.trim()
|
||||
draft.value = value
|
||||
emit('update:modelValue', value)
|
||||
emit('search', value)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
draft.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('search', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-keyword-search {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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,26 @@
|
||||
<!-- 系统logo -->
|
||||
<template>
|
||||
<div class="flex-cc">
|
||||
<img
|
||||
:style="logoStyle"
|
||||
src="@imgs/common/logo.webp"
|
||||
alt="甘肃省房地产价格评估行业协会"
|
||||
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,103 @@
|
||||
<!-- 柱状图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||
<div>
|
||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-danger"
|
||||
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 mx-auto"
|
||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
|
||||
defineOptions({ name: 'ArtBarChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 +(绿色)-(红色) */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 柱状图宽度 */
|
||||
barWidth?: string
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11,
|
||||
barWidth: '26%'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 15,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</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,124 @@
|
||||
<!-- 环型图卡片 -->
|
||||
<template>
|
||||
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="flex box-border h-full p-5 pr-2">
|
||||
<div class="flex w-full items-start gap-5">
|
||||
<div class="flex-b h-full flex-1 flex-col">
|
||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div>
|
||||
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
||||
{{ formatNumber(value) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 text-xs font-medium"
|
||||
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||
<div v-if="currentValue" class="flex-cc">
|
||||
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div v-if="previousValue" class="flex-cc">
|
||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||
{{ previousValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c h-full max-w-40 flex-1">
|
||||
<div ref="chartRef" class="h-30 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtDonutChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 百分比标签 */
|
||||
percentageLabel?: string
|
||||
/** 当前年份 */
|
||||
currentValue?: string
|
||||
/** 去年年份 */
|
||||
previousValue?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 半径 */
|
||||
radius?: [string, string]
|
||||
/** 数据 */
|
||||
data: [number, number]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 9,
|
||||
radius: () => ['70%', '90%'],
|
||||
data: () => [0, 0]
|
||||
})
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: props.data.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => props.data.every((val) => val === 0),
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.color,
|
||||
() => props.radius,
|
||||
() => props.currentValue,
|
||||
() => props.previousValue
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.data[0],
|
||||
name: props.currentValue,
|
||||
itemStyle: { color: computedColor }
|
||||
},
|
||||
{
|
||||
value: props.data[1],
|
||||
name: props.previousValue,
|
||||
itemStyle: { color: '#e6e8f7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</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,126 @@
|
||||
<!-- 折线图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-2.5 flex-b items-start p-5">
|
||||
<div>
|
||||
<p class="text-2xl font-medium leading-none">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="[
|
||||
percentage > 0 ? 'text-success' : 'text-danger',
|
||||
isMiniChart ? 'absolute bottom-5' : ''
|
||||
]"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 box-border w-full"
|
||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtLineChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 是否显示区域颜色 */
|
||||
showAreaColor?: boolean
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</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,203 @@
|
||||
<!-- 柱状图 -->
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtBarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
borderRadius: 4,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '40%',
|
||||
stack: false,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
if (customColor) return customColor
|
||||
|
||||
if (index !== undefined) {
|
||||
return props.colors![index % props.colors!.length]
|
||||
}
|
||||
|
||||
// 默认渐变色
|
||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary')
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: props.borderRadius,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<!-- 双向堆叠柱状图 -->
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
|
||||
import type { BidirectionalBarChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtDualBarCompareChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
positiveData: () => [],
|
||||
negativeData: () => [],
|
||||
xAxisData: () => [],
|
||||
positiveName: '正向数据',
|
||||
negativeName: '负向数据',
|
||||
barWidth: 16,
|
||||
yAxisMin: -100,
|
||||
yAxisMax: 100,
|
||||
|
||||
// 样式配置
|
||||
showDataLabel: false,
|
||||
positiveBorderRadius: () => [10, 10, 0, 0],
|
||||
negativeBorderRadius: () => [0, 0, 10, 10],
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: false,
|
||||
showSplitLine: false,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 创建系列配置的辅助函数
|
||||
const createSeriesConfig = (config: {
|
||||
name: string
|
||||
data: number[]
|
||||
borderRadius: number | number[]
|
||||
labelPosition: 'top' | 'bottom'
|
||||
colorIndex: number
|
||||
formatter?: (params: unknown) => string
|
||||
}): BarSeriesOption => {
|
||||
const { fontColor } = useChartOps()
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: props.barWidth,
|
||||
barGap: '-100%',
|
||||
data: config.data,
|
||||
itemStyle: {
|
||||
borderRadius: config.borderRadius,
|
||||
color: props.colors[config.colorIndex]
|
||||
},
|
||||
label: {
|
||||
show: props.showDataLabel,
|
||||
position: config.labelPosition,
|
||||
formatter:
|
||||
config.formatter ||
|
||||
((params: unknown) => String((params as Record<string, unknown>).value)),
|
||||
color: fontColor,
|
||||
fontSize: 12
|
||||
},
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
props.isEmpty ||
|
||||
!props.positiveData.length ||
|
||||
!props.negativeData.length ||
|
||||
(props.positiveData.every((val) => val === 0) &&
|
||||
props.negativeData.every((val) => val === 0))
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.positiveData,
|
||||
() => props.negativeData,
|
||||
() => props.xAxisData,
|
||||
() => props.colors
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 处理负向数据,确保为负值
|
||||
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
|
||||
|
||||
// 优化的Grid配置
|
||||
const gridConfig = {
|
||||
top: props.showLegend ? 50 : 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0, // 增加底部间距
|
||||
containLabel: true
|
||||
}
|
||||
|
||||
const options: EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
|
||||
|
||||
// 优化的提示框配置
|
||||
tooltip: props.showTooltip
|
||||
? {
|
||||
...getTooltipStyle(),
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'none' // 去除指示线
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// 图例配置
|
||||
legend: props.showLegend
|
||||
? {
|
||||
...getLegendStyle(props.legendPosition),
|
||||
data: [props.negativeName, props.positiveName]
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// X轴配置
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
boundaryGap: true
|
||||
},
|
||||
|
||||
// Y轴配置
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: props.yAxisMin,
|
||||
max: props.yAxisMax,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
|
||||
// 系列配置
|
||||
series: [
|
||||
// 负向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.negativeName,
|
||||
data: processedNegativeData,
|
||||
borderRadius: props.negativeBorderRadius,
|
||||
labelPosition: 'bottom',
|
||||
colorIndex: 1,
|
||||
formatter: (params: unknown) =>
|
||||
String(Math.abs((params as Record<string, unknown>).value as number))
|
||||
}),
|
||||
// 正向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.positiveName,
|
||||
data: props.positiveData,
|
||||
borderRadius: props.positiveBorderRadius,
|
||||
labelPosition: 'top',
|
||||
colorIndex: 0
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
<!-- 水平柱状图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtHBarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '36%',
|
||||
stack: false,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
if (customColor) return customColor
|
||||
|
||||
if (index !== undefined) {
|
||||
return props.colors![index % props.colors!.length]
|
||||
}
|
||||
|
||||
// 默认渐变色
|
||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: 4,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- k线图表 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { KLineChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtKLineChart' })
|
||||
|
||||
const props = withDefaults(defineProps<KLineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
showDataZoom: false,
|
||||
dataZoomStart: 0,
|
||||
dataZoomEnd: 100
|
||||
})
|
||||
|
||||
// 获取实际使用的颜色
|
||||
const getActualColors = () => {
|
||||
const defaultUpColor = '#4C87F3'
|
||||
const defaultDownColor = '#8BD8FC'
|
||||
|
||||
return {
|
||||
upColor: props.colors?.[0] || defaultUpColor,
|
||||
downColor: props.colors?.[1] || defaultDownColor
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
!props.data?.length ||
|
||||
props.data.every(
|
||||
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
|
||||
)
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.colors,
|
||||
() => props.showDataZoom,
|
||||
() => props.dataZoomStart,
|
||||
() => props.dataZoomEnd
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const { upColor, downColor } = getActualColors()
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: props.showDataZoom ? 80 : 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: getTooltipStyle('axis', {
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
||||
const param = params[0]
|
||||
const data = param.data
|
||||
return `
|
||||
<div style="padding: 5px;">
|
||||
<div><strong>时间:</strong>${param.name}</div>
|
||||
<div><strong>开盘:</strong>${data[0]}</div>
|
||||
<div><strong>收盘:</strong>${data[1]}</div>
|
||||
<div><strong>最低:</strong>${data[2]}</div>
|
||||
<div><strong>最高:</strong>${data[3]}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}),
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.data.map((item) => item.time),
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
axisLabel: getAxisLabelStyle(true)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
splitLine: getSplitLineStyle(true)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'candlestick',
|
||||
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
|
||||
itemStyle: {
|
||||
color: upColor,
|
||||
color0: downColor,
|
||||
borderColor: upColor,
|
||||
borderColor0: downColor,
|
||||
borderWidth: 1
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
],
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
type: 'slider',
|
||||
top: '90%',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,371 @@
|
||||
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-[calc(100%+10px)]"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtLineChart' })
|
||||
|
||||
const props = withDefaults(defineProps<LineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
lineWidth: 2.5,
|
||||
showAreaColor: false,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
symbolSize: 6,
|
||||
animationDelay: 200,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 动画状态管理
|
||||
const isAnimating = ref(false)
|
||||
const animationTimers = ref<number[]>([])
|
||||
const animatedData = ref<number[] | LineDataItem[]>([])
|
||||
|
||||
// 清理所有定时器
|
||||
const clearAnimationTimers = () => {
|
||||
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
||||
animationTimers.value = []
|
||||
}
|
||||
|
||||
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 缓存计算的最大值,避免重复计算
|
||||
const maxValue = computed(() => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.reduce((max, item) => {
|
||||
if (item.data?.length) {
|
||||
const itemMax = Math.max(...item.data)
|
||||
return Math.max(max, itemMax)
|
||||
}
|
||||
return max
|
||||
}, 0)
|
||||
} else {
|
||||
const singleData = props.data as number[]
|
||||
return singleData?.length ? Math.max(...singleData) : 0
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化动画数据(优化:减少条件判断)
|
||||
const initAnimationData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.map((item) => ({
|
||||
...item,
|
||||
data: Array(item.data.length).fill(0)
|
||||
}))
|
||||
}
|
||||
const singleData = props.data as number[]
|
||||
return Array(singleData.length).fill(0)
|
||||
}
|
||||
|
||||
// 复制真实数据(优化:使用结构化克隆)
|
||||
const copyRealData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
||||
}
|
||||
return [...(props.data as number[])]
|
||||
}
|
||||
|
||||
// 获取颜色配置(优化:缓存主题色)
|
||||
const primaryColor = computed(() => getCssVar('--el-color-primary'))
|
||||
|
||||
const getColor = (customColor?: string, index?: number): string => {
|
||||
if (customColor) return customColor
|
||||
if (index !== undefined) return props.colors![index % props.colors!.length]
|
||||
return primaryColor.value
|
||||
}
|
||||
|
||||
// 生成区域样式
|
||||
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
||||
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
||||
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
||||
|
||||
const areaConfig = item.areaStyle || {}
|
||||
if (areaConfig.custom) return areaConfig.custom
|
||||
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 生成单数据区域样式
|
||||
const generateSingleAreaStyle = () => {
|
||||
if (!props.showAreaColor) return undefined
|
||||
|
||||
const color = getColor(props.colors[0])
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string
|
||||
smooth?: boolean
|
||||
symbol?: string
|
||||
symbolSize?: number
|
||||
lineWidth?: number
|
||||
areaStyle?: any
|
||||
}) => {
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'line' as const,
|
||||
color: config.color,
|
||||
smooth: config.smooth ?? props.smooth,
|
||||
symbol: config.symbol ?? props.symbol,
|
||||
symbolSize: config.symbolSize ?? props.symbolSize,
|
||||
lineStyle: {
|
||||
width: config.lineWidth ?? props.lineWidth,
|
||||
color: config.color
|
||||
},
|
||||
areaStyle: config.areaStyle,
|
||||
emphasis: {
|
||||
focus: 'series' as const,
|
||||
lineStyle: {
|
||||
width: (config.lineWidth ?? props.lineWidth) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图表配置
|
||||
const generateChartOptions = (isInitial = false): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
animation: true,
|
||||
animationDuration: isInitial ? 0 : 1300,
|
||||
animationDurationUpdate: isInitial ? 0 : 1300,
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 15,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = animatedData.value as LineDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const itemColor = getColor(props.colors[index], index)
|
||||
const areaStyle = generateAreaStyle(item, itemColor)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: itemColor,
|
||||
smooth: item.smooth,
|
||||
symbol: item.symbol,
|
||||
lineWidth: item.lineWidth,
|
||||
areaStyle
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = animatedData.value as number[]
|
||||
const computedColor = getColor(props.colors[0])
|
||||
const areaStyle = generateSingleAreaStyle()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor,
|
||||
areaStyle
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateChartOptions = (options: EChartsOption) => {
|
||||
initChart(options)
|
||||
}
|
||||
|
||||
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
||||
const initChartWithAnimation = () => {
|
||||
clearAnimationTimers()
|
||||
isAnimating.value = true
|
||||
|
||||
// 初始化为0值数据
|
||||
animatedData.value = initAnimationData()
|
||||
updateChartOptions(generateChartOptions(true))
|
||||
|
||||
if (isMultipleData.value) {
|
||||
// 多数据阶梯式动画
|
||||
const multiData = props.data as LineDataItem[]
|
||||
const currentAnimatedData = animatedData.value as LineDataItem[]
|
||||
|
||||
multiData.forEach((item, index) => {
|
||||
const timer = window.setTimeout(
|
||||
() => {
|
||||
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
||||
animatedData.value = [...currentAnimatedData]
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
},
|
||||
index * props.animationDelay + 100
|
||||
)
|
||||
|
||||
animationTimers.value.push(timer)
|
||||
})
|
||||
|
||||
// 标记动画完成
|
||||
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
||||
const finishTimer = window.setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, totalDelay)
|
||||
animationTimers.value.push(finishTimer)
|
||||
} else {
|
||||
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
||||
nextTick(() => {
|
||||
animatedData.value = copyRealData()
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
isAnimating.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 空数据检查函数
|
||||
const checkIsEmpty = () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend,
|
||||
isEmpty
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: checkIsEmpty,
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
onVisible: () => {
|
||||
// 当图表变为可见时,检查是否为空数据
|
||||
if (!isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
},
|
||||
generateOptions: () => generateChartOptions(false)
|
||||
})
|
||||
|
||||
// 图表渲染函数(优化:防止动画期间重复触发)
|
||||
const renderChart = () => {
|
||||
if (!isAnimating.value && !isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true })
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAnimationTimers()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- 雷达图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RadarChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtRadarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<RadarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
indicator: () => [],
|
||||
data: () => [],
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
||||
},
|
||||
watchSources: [() => props.data, () => props.indicator, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
return {
|
||||
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
|
||||
radar: {
|
||||
indicator: props.indicator,
|
||||
center: ['50%', '50%'],
|
||||
radius: '70%',
|
||||
axisName: {
|
||||
color: isDark.value ? '#ccc' : '#666',
|
||||
fontSize: 12
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: isDark.value
|
||||
? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
|
||||
: ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)']
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
data: props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
areaStyle: {
|
||||
color: props.colors[index % props.colors.length],
|
||||
opacity: 0.1
|
||||
},
|
||||
emphasis: {
|
||||
areaStyle: {
|
||||
opacity: 0.25
|
||||
},
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
}
|
||||
})),
|
||||
...getAnimationConfig(200, 1800)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- 环形图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RingChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtRingChart' })
|
||||
|
||||
const props = withDefaults(defineProps<RingChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
radius: () => ['50%', '80%'],
|
||||
borderRadius: 10,
|
||||
centerText: '',
|
||||
showLabel: false,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'right'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
|
||||
useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value === 0)
|
||||
},
|
||||
watchSources: [() => props.data, () => props.centerText],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 根据图例位置计算环形图中心位置
|
||||
const getCenterPosition = (): [string, string] => {
|
||||
if (!props.showLegend) return ['50%', '50%']
|
||||
|
||||
switch (props.legendPosition) {
|
||||
case 'left':
|
||||
return ['60%', '50%']
|
||||
case 'right':
|
||||
return ['40%', '50%']
|
||||
case 'top':
|
||||
return ['50%', '60%']
|
||||
case 'bottom':
|
||||
return ['50%', '40%']
|
||||
default:
|
||||
return ['50%', '50%']
|
||||
}
|
||||
}
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
})
|
||||
: undefined,
|
||||
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
|
||||
series: [
|
||||
{
|
||||
name: '数据占比',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
center: getCenterPosition(),
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: props.borderRadius,
|
||||
borderColor: isDark.value ? '#2c2c2c' : '#fff',
|
||||
borderWidth: 0
|
||||
},
|
||||
label: {
|
||||
show: props.showLabel,
|
||||
formatter: '{b}\n{d}%',
|
||||
position: 'outside',
|
||||
color: isDark.value ? '#ccc' : '#999',
|
||||
fontSize: 12
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: props.showLabel,
|
||||
length: 15,
|
||||
length2: 25,
|
||||
smooth: true
|
||||
},
|
||||
data: props.data,
|
||||
color: props.colors,
|
||||
...getAnimationConfig(),
|
||||
animationType: 'expansion'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 添加中心文字
|
||||
if (props.centerText) {
|
||||
const centerPos = getCenterPosition()
|
||||
option.title = {
|
||||
text: props.centerText,
|
||||
left: centerPos[0],
|
||||
top: centerPos[1],
|
||||
textAlign: 'center',
|
||||
textVerticalAlign: 'middle',
|
||||
textStyle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: isDark.value ? '#999' : '#ADB0BC'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!-- 散点图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { ScatterChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtScatterChart' })
|
||||
|
||||
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
||||
symbolSize: 14,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
||||
},
|
||||
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: (params: { value: [number, number] }) => {
|
||||
const [x, y] = params.value
|
||||
return `X: ${x}<br/>Y: ${y}`
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: props.data,
|
||||
symbolSize: props.symbolSize,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
shadowBlur: 6,
|
||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 12,
|
||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'
|
||||
},
|
||||
scale: true
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</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,389 @@
|
||||
<!-- 导出 Excel 文件 -->
|
||||
<template>
|
||||
<ElButton
|
||||
:type="type"
|
||||
:size="size"
|
||||
:loading="isExporting"
|
||||
:disabled="disabled || !hasData"
|
||||
v-ripple
|
||||
@click="handleExport"
|
||||
>
|
||||
<template #loading>
|
||||
<ElIcon class="is-loading">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
{{ loadingText }}
|
||||
</template>
|
||||
<slot>{{ buttonText }}</slot>
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import type { ButtonType } from 'element-plus'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
|
||||
defineOptions({ name: 'ArtExcelExport' })
|
||||
|
||||
/** 导出数据类型 */
|
||||
type ExportValue = string | number | boolean | null | undefined | Date
|
||||
|
||||
interface ExportData {
|
||||
[key: string]: ExportValue
|
||||
}
|
||||
|
||||
/** 列配置 */
|
||||
interface ColumnConfig {
|
||||
/** 列标题 */
|
||||
title: string
|
||||
/** 列宽度 */
|
||||
width?: number
|
||||
/** 数据格式化函数 */
|
||||
formatter?: (value: ExportValue, row: ExportData, index: number) => string
|
||||
}
|
||||
|
||||
/** 导出配置选项 */
|
||||
interface ExportOptions {
|
||||
/** 数据源 */
|
||||
data: ExportData[]
|
||||
/** 文件名(不含扩展名) */
|
||||
filename?: string
|
||||
/** 工作表名称 */
|
||||
sheetName?: string
|
||||
/** 按钮类型 */
|
||||
type?: ButtonType
|
||||
/** 按钮尺寸 */
|
||||
size?: 'large' | 'default' | 'small'
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 按钮文本 */
|
||||
buttonText?: string
|
||||
/** 加载中文本 */
|
||||
loadingText?: string
|
||||
/** 是否自动添加序号列 */
|
||||
autoIndex?: boolean
|
||||
/** 序号列标题 */
|
||||
indexColumnTitle?: string
|
||||
/** 列配置映射 */
|
||||
columns?: Record<string, ColumnConfig>
|
||||
/** 表头映射(简化版本,向后兼容) */
|
||||
headers?: Record<string, string>
|
||||
/** 最大导出行数 */
|
||||
maxRows?: number
|
||||
/** 是否显示成功消息 */
|
||||
showSuccessMessage?: boolean
|
||||
/** 是否显示错误消息 */
|
||||
showErrorMessage?: boolean
|
||||
/** 工作簿配置 */
|
||||
workbookOptions?: {
|
||||
/** 创建者 */
|
||||
creator?: string
|
||||
/** 最后修改者 */
|
||||
lastModifiedBy?: string
|
||||
/** 创建时间 */
|
||||
created?: Date
|
||||
/** 修改时间 */
|
||||
modified?: Date
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetName: 'Sheet1',
|
||||
type: 'primary',
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
buttonText: '导出 Excel',
|
||||
loadingText: '导出中...',
|
||||
autoIndex: false,
|
||||
indexColumnTitle: '序号',
|
||||
columns: () => ({}),
|
||||
headers: () => ({}),
|
||||
maxRows: 100000,
|
||||
showSuccessMessage: true,
|
||||
showErrorMessage: true,
|
||||
workbookOptions: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'before-export': [data: ExportData[]]
|
||||
'export-success': [filename: string, rowCount: number]
|
||||
'export-error': [error: ExportError]
|
||||
'export-progress': [progress: number]
|
||||
}>()
|
||||
|
||||
/** 导出错误类型 */
|
||||
class ExportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ExportError'
|
||||
}
|
||||
}
|
||||
|
||||
const isExporting = ref(false)
|
||||
|
||||
/** 是否有数据可导出 */
|
||||
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||
|
||||
/** 验证导出数据 */
|
||||
const validateData = (data: ExportData[]): void => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new ExportError('没有可导出的数据', 'NO_DATA')
|
||||
}
|
||||
|
||||
if (data.length > props.maxRows) {
|
||||
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
|
||||
currentRows: data.length,
|
||||
maxRows: props.maxRows
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化单元格值 */
|
||||
const formatCellValue = (
|
||||
value: ExportValue,
|
||||
key: string,
|
||||
row: ExportData,
|
||||
index: number
|
||||
): string => {
|
||||
// 使用列配置的格式化函数
|
||||
const column = props.columns[key]
|
||||
if (column?.formatter) {
|
||||
return column.formatter(value, row, index)
|
||||
}
|
||||
|
||||
// 处理特殊值
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 处理数据 */
|
||||
const processData = (data: ExportData[]): Record<string, string>[] => {
|
||||
const processedData = data.map((item, index) => {
|
||||
const processedItem: Record<string, string> = {}
|
||||
|
||||
// 添加序号列
|
||||
if (props.autoIndex) {
|
||||
processedItem[props.indexColumnTitle] = String(index + 1)
|
||||
}
|
||||
|
||||
// 处理数据列
|
||||
Object.entries(item).forEach(([key, value]) => {
|
||||
// 获取列标题
|
||||
let columnTitle = key
|
||||
if (props.columns[key]?.title) {
|
||||
columnTitle = props.columns[key].title
|
||||
} else if (props.headers[key]) {
|
||||
columnTitle = props.headers[key]
|
||||
}
|
||||
|
||||
// 格式化值
|
||||
processedItem[columnTitle] = formatCellValue(value, key, item, index)
|
||||
})
|
||||
|
||||
return processedItem
|
||||
})
|
||||
|
||||
return processedData
|
||||
}
|
||||
|
||||
/** 计算列宽度 */
|
||||
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
|
||||
if (data.length === 0) return []
|
||||
|
||||
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
|
||||
const columns = Object.keys(data[0])
|
||||
|
||||
return columns.map((column) => {
|
||||
// 使用配置的列宽度
|
||||
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width
|
||||
|
||||
if (configWidth) {
|
||||
return { wch: configWidth }
|
||||
}
|
||||
|
||||
// 自动计算列宽度
|
||||
const maxLength = Math.max(
|
||||
column.length, // 标题长度
|
||||
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
|
||||
)
|
||||
|
||||
// 限制最小和最大宽度
|
||||
const width = Math.min(Math.max(maxLength + 2, 8), 50)
|
||||
return { wch: width }
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出到 Excel */
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
emit('export-progress', 10)
|
||||
|
||||
// 处理数据
|
||||
const processedData = processData(data)
|
||||
emit('export-progress', 30)
|
||||
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new()
|
||||
|
||||
// 设置工作簿属性
|
||||
if (props.workbookOptions) {
|
||||
workbook.Props = {
|
||||
Title: filename,
|
||||
Subject: '数据导出',
|
||||
Author: props.workbookOptions.creator || '甘肃省房地产价格评估行业协会',
|
||||
Manager: props.workbookOptions.lastModifiedBy || '',
|
||||
Company: '系统导出',
|
||||
Category: '数据',
|
||||
Keywords: 'excel,export,data',
|
||||
Comments: '由系统自动生成',
|
||||
CreatedDate: props.workbookOptions.created || new Date(),
|
||||
ModifiedDate: props.workbookOptions.modified || new Date()
|
||||
}
|
||||
}
|
||||
|
||||
emit('export-progress', 50)
|
||||
|
||||
// 创建工作表
|
||||
const worksheet = XLSX.utils.json_to_sheet(processedData)
|
||||
|
||||
// 设置列宽度
|
||||
worksheet['!cols'] = calculateColumnWidths(processedData)
|
||||
|
||||
emit('export-progress', 70)
|
||||
|
||||
// 添加工作表到工作簿
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
||||
|
||||
emit('export-progress', 85)
|
||||
|
||||
// 生成 Excel 文件
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'array',
|
||||
compression: true
|
||||
})
|
||||
|
||||
// 创建 Blob 并下载
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
emit('export-progress', 95)
|
||||
|
||||
// 使用时间戳确保文件名唯一
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const finalFilename = `${filename}_${timestamp}.xlsx`
|
||||
|
||||
FileSaver.saveAs(blob, finalFilename)
|
||||
|
||||
emit('export-progress', 100)
|
||||
|
||||
// 等待下载开始
|
||||
await nextTick()
|
||||
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, 'EXPORT_FAILED', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理导出 */
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return
|
||||
|
||||
isExporting.value = true
|
||||
|
||||
try {
|
||||
// 验证数据
|
||||
validateData(props.data)
|
||||
|
||||
// 触发导出前事件
|
||||
emit('before-export', props.data)
|
||||
|
||||
// 执行导出
|
||||
await exportToExcel(props.data, props.filename, props.sheetName)
|
||||
|
||||
// 触发成功事件
|
||||
emit('export-success', props.filename, props.data.length)
|
||||
|
||||
// 显示成功消息
|
||||
if (props.showSuccessMessage) {
|
||||
ElMessage.success({
|
||||
message: `成功导出 ${props.data.length} 条数据`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const exportError =
|
||||
error instanceof ExportError
|
||||
? error
|
||||
: new ExportError(`导出失败: ${(error as Error).message}`, 'UNKNOWN_ERROR', error)
|
||||
|
||||
// 触发错误事件
|
||||
emit('export-error', exportError)
|
||||
|
||||
// 显示错误消息
|
||||
if (props.showErrorMessage) {
|
||||
ElMessage.error({
|
||||
message: exportError.message,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
|
||||
console.error('Excel 导出错误:', exportError)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
emit('export-progress', 0)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
exportData: handleExport,
|
||||
isExporting: readonly(isExporting),
|
||||
hasData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- 导入 Excel 文件 -->
|
||||
<template>
|
||||
<div class="inline-block">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<ElButton type="primary" v-ripple>
|
||||
<slot>导入 Excel</slot>
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ArtExcelImport' })
|
||||
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||
resolve(results as Array<Record<string, unknown>>)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
'import-success': [data: Array<Record<string, unknown>>]
|
||||
'import-error': [error: Error]
|
||||
}>()
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return
|
||||
const results = await importExcel(uploadFile.raw)
|
||||
emit('import-success', results)
|
||||
} catch (error) {
|
||||
emit('import-error', error as Error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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(/ /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(/ /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,244 @@
|
||||
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor-next/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor-next/editor-for-vue'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor-next/editor'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadAdminFile } from '@/api/system-manage'
|
||||
import { isHttpError } from '@/utils/http/error'
|
||||
|
||||
defineOptions({ name: 'ArtWangEditor' })
|
||||
|
||||
type InsertFnType = (url: string, alt: string, href: string) => void
|
||||
|
||||
// Props 定义
|
||||
interface Props {
|
||||
/** 编辑器高度 */
|
||||
height?: string
|
||||
/** 自定义工具栏配置 */
|
||||
toolbarKeys?: string[]
|
||||
/** 插入新工具到指定位置 */
|
||||
insertKeys?: { index: number; keys: string[] }
|
||||
/** 排除的工具栏项 */
|
||||
excludeKeys?: string[]
|
||||
/** 编辑器模式 */
|
||||
mode?: 'default' | 'simple'
|
||||
/** 占位符文本 */
|
||||
placeholder?: string
|
||||
/** 上传配置 */
|
||||
uploadConfig?: {
|
||||
maxFileSize?: number
|
||||
maxNumberOfFiles?: number
|
||||
server?: string
|
||||
// 是否开启自定义上传
|
||||
isCustomUpload?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '500px',
|
||||
mode: 'default',
|
||||
placeholder: '请输入内容...',
|
||||
excludeKeys: () => ['fontFamily'],
|
||||
isCustomUpload: false
|
||||
})
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
|
||||
// 常量配置
|
||||
const DEFAULT_UPLOAD_CONFIG = {
|
||||
maxFileSize: 3 * 1024 * 1024, // 3MB
|
||||
maxNumberOfFiles: 10,
|
||||
fieldName: 'file',
|
||||
allowedFileTypes: ['image/*']
|
||||
} as const
|
||||
|
||||
// 合并上传配置
|
||||
const mergedUploadConfig = computed(() => ({
|
||||
...DEFAULT_UPLOAD_CONFIG,
|
||||
...props.uploadConfig
|
||||
}))
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
||||
const config: Partial<IToolbarConfig> = {}
|
||||
|
||||
// 完全自定义工具栏
|
||||
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
||||
config.toolbarKeys = props.toolbarKeys
|
||||
}
|
||||
|
||||
// 插入新工具
|
||||
if (props.insertKeys) {
|
||||
config.insertKeys = props.insertKeys
|
||||
}
|
||||
|
||||
// 排除工具
|
||||
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
||||
config.excludeKeys = props.excludeKeys
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
const uploadToPublic = async (file: File) => {
|
||||
const saved = await uploadAdminFile(file, 'public', undefined, false)
|
||||
if (!saved?.url) {
|
||||
throw new Error('上传成功但没有返回地址')
|
||||
}
|
||||
return saved.url
|
||||
}
|
||||
|
||||
const showUploadError = (error: unknown, fallback: string) => {
|
||||
console.error(fallback, error)
|
||||
ElMessage.error(isHttpError(error) ? error.message : fallback)
|
||||
}
|
||||
|
||||
// 编辑器配置:图片和视频走公开桶,正文只插入地址
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
fieldName: mergedUploadConfig.value.fieldName,
|
||||
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
||||
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
||||
allowedFileTypes: [...mergedUploadConfig.value.allowedFileTypes],
|
||||
base64LimitSize: 0,
|
||||
async customUpload(file: File, insertFn: InsertFnType) {
|
||||
try {
|
||||
const url = await uploadToPublic(file)
|
||||
insertFn(url, file.name, url)
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
} catch (error) {
|
||||
showUploadError(error, `图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
uploadVideo: {
|
||||
fieldName: 'file',
|
||||
maxFileSize: 50 * 1024 * 1024,
|
||||
async customUpload(file: File, insertFn: (url: string, poster: string) => void) {
|
||||
try {
|
||||
const url = await uploadToPublic(file)
|
||||
insertFn(url, '')
|
||||
ElMessage.success(`视频上传成功 ${EmojiText[200]}`)
|
||||
} catch (error) {
|
||||
showUploadError(error, `视频上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器创建回调
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
|
||||
// 监听全屏事件
|
||||
editor.on('fullScreen', () => {
|
||||
console.log('编辑器进入全屏模式')
|
||||
})
|
||||
|
||||
// 确保在编辑器创建后应用自定义图标
|
||||
applyCustomIcons()
|
||||
}
|
||||
|
||||
// 应用自定义图标(带重试机制)
|
||||
const applyCustomIcons = () => {
|
||||
let retryCount = 0
|
||||
const maxRetries = 10
|
||||
const retryDelay = 100
|
||||
|
||||
const tryApplyIcons = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前编辑器的工具栏容器
|
||||
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
|
||||
if (!editorContainer) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const toolbar = editorContainer.querySelector('.w-e-toolbar')
|
||||
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]')
|
||||
|
||||
if (toolbar && toolbarButtons.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果工具栏还没渲染完成,继续重试
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
} else {
|
||||
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||
requestAnimationFrame(tryApplyIcons)
|
||||
}
|
||||
|
||||
// 暴露编辑器实例和方法
|
||||
defineExpose({
|
||||
/** 获取编辑器实例 */
|
||||
getEditor: () => editorRef.value,
|
||||
/** 设置编辑器内容 */
|
||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||
/** 获取编辑器内容 */
|
||||
getHtml: () => editorRef.value?.getHtml(),
|
||||
/** 清空编辑器 */
|
||||
clear: () => editorRef.value?.clear(),
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => editorRef.value?.focus()
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 图标替换已在 onCreateEditor 中处理
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
$box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
|
||||
// 全屏容器 z-index 调整
|
||||
.w-e-full-screen-container,
|
||||
.w-e-modal,
|
||||
.w-e-drop-panel,
|
||||
.w-e-select-list {
|
||||
z-index: 4000 !important;
|
||||
}
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--default-border);
|
||||
}
|
||||
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
|
||||
/* 表格选中时候的边框颜色 */
|
||||
--w-e-textarea-selected-border-color: #ddd;
|
||||
|
||||
/* 表格头背景颜色 */
|
||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item svg {
|
||||
fill: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.w-e-bar-item button {
|
||||
color: var(--art-gray-800);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
|
||||
.w-e-bar-item {
|
||||
button {
|
||||
width: 100%;
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-50);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
|
||||
.w-e-text-container {
|
||||
[data-slate-editor] {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
ul ul ul {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--art-gray-200);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||