mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 12:52:26 +00:00
refactor(components): 移除多个未使用的组件并重构MarkdownRenderer
- 删除CommonWrapper、Fullscreen、AppLink等未使用的组件 - 将MarkdownRenderer组件移动到others目录并重命名为fa-markdown-renderer - 优化Markdown渲染器的样式和功能实现
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<component :is="linkType" v-bind="linkProps(to)">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "AppLink",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { isExternal } from "@utils/index";
|
||||
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isExternalLink = computed(() => {
|
||||
return isExternal(props.to.path || "");
|
||||
});
|
||||
|
||||
const linkType = computed(() => (isExternalLink.value ? "a" : "router-link"));
|
||||
|
||||
const linkProps = (to: any) => {
|
||||
if (isExternalLink.value) {
|
||||
return {
|
||||
href: to.path,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
};
|
||||
}
|
||||
return { to };
|
||||
};
|
||||
</script>
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<ElBreadcrumb class="flex-y-center">
|
||||
<ElBreadcrumbItem v-for="(item, index) in breadcrumbs" :key="item.path">
|
||||
<span
|
||||
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
|
||||
class="color-gray-400"
|
||||
>
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
</a>
|
||||
</ElBreadcrumbItem>
|
||||
</ElBreadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteLocationMatched } from "vue-router";
|
||||
import { compile } from "path-to-regexp";
|
||||
import { router } from "@/router";
|
||||
import { translateRouteTitle } from "@utils/i18n";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const pathCompile = (path: string) => {
|
||||
const { params } = currentRoute;
|
||||
const toPath = compile(path);
|
||||
return toPath(params);
|
||||
};
|
||||
|
||||
const breadcrumbs = ref<Array<RouteLocationMatched>>([]);
|
||||
|
||||
function getBreadcrumb() {
|
||||
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
|
||||
const first = matched[0];
|
||||
if (!isDashboard(first)) {
|
||||
matched = [{ path: "/home", meta: { title: "menus.home.title" } } as any].concat(matched);
|
||||
}
|
||||
breadcrumbs.value = matched.filter((item) => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
|
||||
});
|
||||
}
|
||||
|
||||
function isDashboard(route: RouteLocationMatched) {
|
||||
const name = route && route.name;
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
const n = name.toString().trim().toLowerCase();
|
||||
return n === "home" || n === "workplace" || n === "dashboard" || n.startsWith("dashboard");
|
||||
}
|
||||
|
||||
function handleLink(item: any) {
|
||||
const { redirect, path } = item;
|
||||
if (redirect) {
|
||||
router.push(redirect).catch((err) => {
|
||||
console.warn(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push(pathCompile(path)).catch((err) => {
|
||||
console.warn(err);
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentRoute.path,
|
||||
(path) => {
|
||||
if (path.startsWith("/redirect/")) {
|
||||
return;
|
||||
}
|
||||
getBreadcrumb();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
getBreadcrumb();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 覆盖 element-plus 的样式
|
||||
.el-breadcrumb__inner,
|
||||
.el-breadcrumb__inner a {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div cursor-pointer flex-center rounded class="el" :class="padding">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: "p-2",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.el {
|
||||
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!-- 自定义 iframe 组件 -->
|
||||
<template>
|
||||
<div v-loading="loading" :style="'height:' + height">
|
||||
<iframe :src="url" frameborder="0" width="100%" height="100%" scrolling="auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const height = ref(document.documentElement.clientHeight - 94.5 + "px;");
|
||||
const loading = ref(true);
|
||||
const url = computed(() => props.src);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 300);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** 关闭tag标签 */
|
||||
.app-container {
|
||||
/* 50px = navbar = 50px */
|
||||
height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
/** 开启tag标签 */
|
||||
.hasTagsView {
|
||||
.app-container {
|
||||
/* 84px = navbar + tags-view = 50px + 34px */
|
||||
height: calc(100vh - 84px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<!-- 全屏切换按钮 -->
|
||||
<template>
|
||||
<div @click="toggle">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon(isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
|
||||
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<!-- 折叠按钮 -->
|
||||
<template>
|
||||
<div class="hamburger-wrapper" @click="toggleClick">
|
||||
<ArtSvgIcon
|
||||
:icon="resolveIconForArtSvgIcon('collapse')"
|
||||
:class="[{ hamburger: true, 'is-active': isActive }]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
|
||||
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
|
||||
|
||||
defineProps({
|
||||
isActive: { type: Boolean, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggleClick"]);
|
||||
|
||||
function toggleClick() {
|
||||
emit("toggleClick");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hamburger-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.hamburger {
|
||||
vertical-align: middle;
|
||||
color: var(--el-text-color-secondary);
|
||||
transform: scaleX(-1);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<ElScrollbar>
|
||||
<div class="flex-y-center gap-2">
|
||||
<ElTag
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
closable
|
||||
:disable-transitions="false"
|
||||
v-bind="config.tagAttrs"
|
||||
@close="handleClose(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</ElTag>
|
||||
<ElInput
|
||||
v-if="inputVisible"
|
||||
ref="inputRef"
|
||||
v-model.trim="inputValue"
|
||||
style="min-width: 100px"
|
||||
@keyup.enter.stop.prevent="handleInputConfirm"
|
||||
@blur.stop.prevent="handleInputConfirm"
|
||||
/>
|
||||
<ElButton v-else v-bind="config.buttonAttrs" @click="showInput">
|
||||
{{ config.buttonAttrs.btnText ? config.buttonAttrs.btnText : "+ New Tag" }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { InputInstance } from "element-plus";
|
||||
|
||||
const inputValue = ref("");
|
||||
const inputVisible = ref(false);
|
||||
const inputRef = ref<InputInstance>();
|
||||
|
||||
// 定义 model,用于与父组件的 v-model绑定
|
||||
const tags = defineModel<string[]>();
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object as () => {
|
||||
buttonAttrs: Record<string, any>;
|
||||
inputAttrs: Record<string, any>;
|
||||
tagAttrs: Record<string, any>;
|
||||
},
|
||||
default: () => ({
|
||||
buttonAttrs: {},
|
||||
inputAttrs: {},
|
||||
tagAttrs: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = (tag: string) => {
|
||||
if (tags.value) {
|
||||
const newTags = tags.value.filter((t) => t !== tag);
|
||||
tags.value = [...newTags];
|
||||
}
|
||||
};
|
||||
|
||||
const showInput = () => {
|
||||
inputVisible.value = true;
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
};
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue.value) {
|
||||
const newTags = [...(tags.value || []), inputValue.value];
|
||||
tags.value = newTags;
|
||||
}
|
||||
inputVisible.value = false;
|
||||
inputValue.value = "";
|
||||
};
|
||||
</script>
|
||||
@@ -1,54 +0,0 @@
|
||||
<!-- 语言切换 -->
|
||||
<template>
|
||||
<ElDropdown trigger="click" @command="handleLanguageChange">
|
||||
<div class="navbar-lang-trigger flex-cc">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('language')" :class="size" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="item in langOptions"
|
||||
:key="item.value"
|
||||
:disabled="appStore.language === item.value"
|
||||
:command="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
|
||||
import { useAppStore } from "@stores/modules/app.store";
|
||||
import { LanguageEnum } from "@/enums/settings/locale.enum";
|
||||
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
|
||||
|
||||
defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const langOptions = [
|
||||
{ label: "中文", value: LanguageEnum.ZH_CN },
|
||||
{ label: "English", value: LanguageEnum.EN },
|
||||
];
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
/**
|
||||
* 处理语言切换
|
||||
*
|
||||
* @param lang 语言(zh-cn、en)
|
||||
*/
|
||||
function handleLanguageChange(lang: string) {
|
||||
locale.value = lang;
|
||||
appStore.changeLanguage(lang);
|
||||
|
||||
ElMessage.success(t("langSelect.message.success"));
|
||||
}
|
||||
</script>
|
||||
@@ -1,585 +0,0 @@
|
||||
<template>
|
||||
<div @click="openSearchModal">
|
||||
<div class="command-palette-trigger" role="button" tabindex="0" aria-label="打开搜索面板">
|
||||
<div class="command-palette-trigger__left">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('search')" />
|
||||
<span class="command-palette-trigger__text">搜索菜单</span>
|
||||
</div>
|
||||
<kbd class="command-palette-trigger__kbd">Ctrl K</kbd>
|
||||
</div>
|
||||
<ElDialog
|
||||
v-model="isModalVisible"
|
||||
width="30%"
|
||||
:append-to-body="true"
|
||||
:show-close="false"
|
||||
@close="closeSearchModal"
|
||||
>
|
||||
<template #header>
|
||||
<ElInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchKeyword"
|
||||
size="large"
|
||||
placeholder="输入菜单名称关键字搜索"
|
||||
clearable
|
||||
@keyup.enter="selectActiveResult"
|
||||
@input="updateSearchResults"
|
||||
@keydown.up.prevent="navigateResults('up')"
|
||||
@keydown.down.prevent="navigateResults('down')"
|
||||
@keydown.esc="closeSearchModal"
|
||||
>
|
||||
<template #prepend>
|
||||
<ElButton icon="Search" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</template>
|
||||
|
||||
<div class="search-result">
|
||||
<!-- 搜索历史 -->
|
||||
<template v-if="searchKeyword === '' && searchHistory.length > 0">
|
||||
<div class="search-history">
|
||||
<div class="search-history__title">
|
||||
搜索历史
|
||||
<ElButton
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
class="search-history__clear"
|
||||
@click="clearHistory"
|
||||
>
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
<ul class="search-history__list">
|
||||
<li
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="search-history__item"
|
||||
@click="navigateToRoute(item)"
|
||||
>
|
||||
<div class="search-history__icon">
|
||||
<ElIcon><Clock /></ElIcon>
|
||||
</div>
|
||||
<span class="search-history__name">{{ item.title }}</span>
|
||||
<div class="search-history__action">
|
||||
<ElIcon @click.stop="removeHistoryItem(index)"><Close /></ElIcon>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<template v-else>
|
||||
<ul v-if="displayResults.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in displayResults"
|
||||
:key="item.path"
|
||||
:class="[
|
||||
'search-result__item',
|
||||
{
|
||||
'search-result__item--active': index === activeIndex,
|
||||
},
|
||||
]"
|
||||
@click="navigateToRoute(item)"
|
||||
>
|
||||
<!-- 与 MenuRouteIcon / 旧版 MenuItemContent 一致:EP + 自定义 SVG + Iconify -->
|
||||
<MenuRouteIcon :icon="item.icon || 'menu'" class="flex-shrink-0" />
|
||||
<span class="ml-2">{{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- 无搜索历史显示 -->
|
||||
<div v-if="searchKeyword === '' && searchHistory.length === 0" class="no-history">
|
||||
<p class="no-history__text">没有搜索历史</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="ctrl-k-hint">
|
||||
<span class="ctrl-k-text">Ctrl+K 快速打开</span>
|
||||
</div>
|
||||
<div class="shortcuts-group">
|
||||
<div class="key-box">
|
||||
<div class="key-btn">选择</div>
|
||||
</div>
|
||||
<div class="arrow-box">
|
||||
<div class="arrow-up-down">
|
||||
<div class="key-btn">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('up')" />
|
||||
</div>
|
||||
<div class="key-btn ml-1">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('down')" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="key-text">切换</span>
|
||||
</div>
|
||||
<div class="key-box">
|
||||
<div class="key-btn esc-btn">ESC</div>
|
||||
<span class="key-text">关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
|
||||
import MenuRouteIcon from "@/components/MenuRouteIcon/index.vue";
|
||||
import { router } from "@/router";
|
||||
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
|
||||
import { useMenuStore } from "@stores";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { isExternal } from "@utils";
|
||||
import { LocationQueryRaw } from "vue-router";
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||
|
||||
const { Clock, Close, Delete } = ElementPlusIconsVue;
|
||||
|
||||
const HISTORY_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
const isModalVisible = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const searchInputRef = ref();
|
||||
const excludedRoutes = ref(["/redirect", "/login", "/401", "/404", "/500", "/:pathMatch(.*)*"]);
|
||||
const menuItems = ref<SearchItem[]>([]);
|
||||
const searchResults = ref<SearchItem[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
const searchHistory = ref<SearchItem[]>([]);
|
||||
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
// 从本地存储加载搜索历史
|
||||
function loadSearchHistory() {
|
||||
const historyStr = localStorage.getItem(HISTORY_KEY);
|
||||
if (historyStr) {
|
||||
try {
|
||||
searchHistory.value = JSON.parse(historyStr);
|
||||
} catch {
|
||||
searchHistory.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存搜索历史到本地存储
|
||||
function saveSearchHistory() {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
||||
}
|
||||
|
||||
// 添加项目到搜索历史
|
||||
function addToHistory(item: SearchItem) {
|
||||
// 检查是否已存在
|
||||
const index = searchHistory.value.findIndex((i) => i.path === item.path);
|
||||
|
||||
// 如果存在则移除
|
||||
if (index !== -1) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 添加到历史开头
|
||||
searchHistory.value.unshift(item);
|
||||
|
||||
// 限制历史记录数量
|
||||
if (searchHistory.value.length > MAX_HISTORY) {
|
||||
searchHistory.value = searchHistory.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 移除历史记录项
|
||||
function removeHistoryItem(index: number) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
// 注册全局快捷键
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// 判断是否为Ctrl+K组合键
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
openSearchModal();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => menuStore.menuList,
|
||||
(list) => {
|
||||
menuItems.value = [];
|
||||
loadRoutesFromMenu(list ?? []);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// 添加键盘事件监听
|
||||
onMounted(() => {
|
||||
loadSearchHistory();
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 移除键盘事件监听
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 打开搜索模态框
|
||||
function openSearchModal() {
|
||||
searchKeyword.value = "";
|
||||
activeIndex.value = -1;
|
||||
isModalVisible.value = true;
|
||||
setTimeout(() => {
|
||||
searchInputRef.value.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 关闭搜索模态框
|
||||
function closeSearchModal() {
|
||||
isModalVisible.value = false;
|
||||
}
|
||||
|
||||
// 更新搜索结果
|
||||
function updateSearchResults() {
|
||||
activeIndex.value = -1;
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
searchResults.value = menuItems.value.filter((item) =>
|
||||
item.title.toLowerCase().includes(keyword)
|
||||
);
|
||||
} else {
|
||||
searchResults.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 显示搜索结果
|
||||
const displayResults = computed(() => searchResults.value);
|
||||
|
||||
// 执行搜索
|
||||
function selectActiveResult() {
|
||||
if (displayResults.value.length > 0 && activeIndex.value >= 0) {
|
||||
navigateToRoute(displayResults.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
// 导航搜索结果
|
||||
function navigateResults(direction: string) {
|
||||
if (displayResults.value.length === 0) return;
|
||||
|
||||
if (direction === "up") {
|
||||
activeIndex.value =
|
||||
activeIndex.value <= 0 ? displayResults.value.length - 1 : activeIndex.value - 1;
|
||||
} else if (direction === "down") {
|
||||
activeIndex.value =
|
||||
activeIndex.value >= displayResults.value.length - 1 ? 0 : activeIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到
|
||||
function navigateToRoute(item: SearchItem) {
|
||||
closeSearchModal();
|
||||
// 添加到历史记录
|
||||
addToHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
function loadRoutesFromMenu(routes: AppRouteRecord[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const rawPath = route.path ?? "";
|
||||
const path = rawPath.startsWith("/")
|
||||
? rawPath
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${rawPath}`;
|
||||
|
||||
const meta = route.meta;
|
||||
const hidden = meta?.hidden === true || meta?.isHide === true;
|
||||
if (excludedRoutes.value.includes(route.path ?? "") || isExternal(path) || hidden) return;
|
||||
|
||||
if (route.children?.length) {
|
||||
if (meta?.title) {
|
||||
const title = meta.title === "dashboard" ? "首页" : meta.title;
|
||||
const params = (meta as { params?: unknown }).params;
|
||||
menuItems.value.push({
|
||||
title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: params ? JSON.parse(JSON.stringify(toRaw(params))) : undefined,
|
||||
});
|
||||
}
|
||||
loadRoutesFromMenu(route.children, path);
|
||||
} else if (meta?.title) {
|
||||
const title = meta.title === "dashboard" ? "首页" : meta.title;
|
||||
const params = (meta as { params?: unknown }).params;
|
||||
menuItems.value.push({
|
||||
title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: params ? JSON.parse(JSON.stringify(toRaw(params))) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.command-palette-trigger {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
user-select: none;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.command-palette-trigger__left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger__left :deep(.art-svg-icon) {
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
|
||||
.command-palette-trigger__text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-trigger__kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.command-palette-trigger:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.command-palette-trigger:hover {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.search-result {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-menu-hover-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索历史样式 */
|
||||
.search-history {
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 34px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__clear {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__action {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.search-history__action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 没有搜索历史时的样式 */
|
||||
.no-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
|
||||
&__text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-up-down {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 3px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.esc-btn {
|
||||
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.ctrl-k-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctrl-k-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
// 适配Element Plus对话框
|
||||
:deep(.el-dialog__footer) {
|
||||
box-sizing: border-box;
|
||||
padding-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,130 +0,0 @@
|
||||
<!-- 顶部通知公告 -->
|
||||
<template>
|
||||
<ElDropdown trigger="click">
|
||||
<ElBadge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
|
||||
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('bell')" />
|
||||
</ElBadge>
|
||||
|
||||
<ArtSvgIcon v-else :icon="resolveIconForArtSvgIcon('bell')" />
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-5">
|
||||
<template v-if="noticeList.length > 0">
|
||||
<div v-for="(item, index) in noticeList" :key="index" class="w-400px py-3">
|
||||
<div class="flex-y-center">
|
||||
<ElTag :type="item.notice_type === '1' ? 'primary' : 'warning'">
|
||||
{{ item.notice_type === "1" ? "通知" : "公告" }}
|
||||
</ElTag>
|
||||
|
||||
<!-- truncated: 超出部分省略 -->
|
||||
<ElText size="small" class="w-200px cursor-pointer !ml-2 !flex-1" truncated>
|
||||
{{ item.notice_content }}
|
||||
</ElText>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="text-xs text-gray">
|
||||
{{ item.created_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElDivider />
|
||||
|
||||
<div class="flex-x-between">
|
||||
<ElLink type="primary" underline="never" @click="handleViewMoreNotice">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<ElIcon class="text-xs">
|
||||
<ArrowRight />
|
||||
</ElIcon>
|
||||
</ElLink>
|
||||
<ElLink
|
||||
v-if="noticeList.length > 0"
|
||||
type="primary"
|
||||
underline="never"
|
||||
@click="handleMarkAllAsRead"
|
||||
>
|
||||
<span class="text-xs">全部已读</span>
|
||||
</ElLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-center h-150px w-350px">
|
||||
<ElEmpty :image-size="50" description="暂无消息" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<ElDialog
|
||||
v-model="noticeDialogVisible"
|
||||
:title="noticeDetail?.notice_title ?? '通知详情'"
|
||||
width="800px"
|
||||
custom-class="notification-detail"
|
||||
>
|
||||
<div v-if="noticeDetail" class="p-x-20px">
|
||||
<div class="flex-y-center mb-16px text-13px text-color-secondary">
|
||||
<span class="flex-y-center">
|
||||
<ElIcon>
|
||||
<User />
|
||||
</ElIcon>
|
||||
{{ noticeDetail.created_by?.name }}
|
||||
</span>
|
||||
<span class="ml-2 flex-y-center">
|
||||
<ElIcon>
|
||||
<Timer />
|
||||
</ElIcon>
|
||||
{{ noticeDetail.created_time }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
|
||||
<div v-html="noticeDetail.notice_content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
|
||||
import NoticeAPI, { NoticeTable } from "@/api/module_system/notice";
|
||||
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
|
||||
import { ArrowRight, Timer, User } from "@element-plus/icons-vue";
|
||||
import { router } from "@/router";
|
||||
import { useNoticeStore } from "@stores";
|
||||
|
||||
const noticeStore = useNoticeStore();
|
||||
|
||||
const noticeList = ref<NoticeTable[]>([]);
|
||||
const noticeDialogVisible = ref(false);
|
||||
const noticeDetail = ref<NoticeTable | null>(null);
|
||||
|
||||
/**
|
||||
* 获取我的通知公告
|
||||
*/
|
||||
async function featchMyNotice() {
|
||||
await noticeStore.getNotice();
|
||||
noticeList.value = noticeStore.noticeList;
|
||||
}
|
||||
|
||||
// 查看更多
|
||||
function handleViewMoreNotice() {
|
||||
router.push({ name: "Notice" });
|
||||
}
|
||||
|
||||
// 全部已读:将这些公告禁用(status=false),刷新后不再出现
|
||||
function handleMarkAllAsRead() {
|
||||
const ids = noticeList.value
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
NoticeAPI.batchNotice({ ids, status: "1" }).then(async () => {
|
||||
await noticeStore.getNotice();
|
||||
noticeList.value = noticeStore.noticeList;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
featchMyNotice();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<!-- 操作列自适应宽度 -->
|
||||
<template>
|
||||
<ElTableColumn
|
||||
:label="label"
|
||||
:fixed="fixed"
|
||||
:align="align"
|
||||
:show-overflow-tooltip="showOverflowTooltip"
|
||||
:width="finalWidth"
|
||||
>
|
||||
<template #default="{ row, column, $index }">
|
||||
<div v-auto-width class="operation-buttons">
|
||||
<slot v-bind="{ row, column, $index }"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
listDataLength: number;
|
||||
prop?: string;
|
||||
label?: string;
|
||||
fixed?: string | boolean;
|
||||
align?: string;
|
||||
width?: number | string;
|
||||
showOverflowTooltip?: boolean;
|
||||
minWidth?: number | string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: "操作",
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
minWidth: 80,
|
||||
});
|
||||
|
||||
const count = ref(0);
|
||||
const operationWidth = ref(Number(props.minWidth) || 80);
|
||||
|
||||
// 计算操作列宽度
|
||||
const calculateWidth = () => {
|
||||
count.value++;
|
||||
|
||||
if (count.value !== props.listDataLength) return;
|
||||
const maxWidth = getOperationMaxWidth();
|
||||
operationWidth.value = Math.max(maxWidth, Number(props.minWidth));
|
||||
count.value = 0;
|
||||
};
|
||||
|
||||
// 计算最终宽度
|
||||
const finalWidth = computed(() => {
|
||||
const widthNum = typeof props.width === "number" ? props.width : Number(props.width);
|
||||
const minWidthNum = typeof props.minWidth === "number" ? props.minWidth : Number(props.minWidth);
|
||||
return widthNum || operationWidth.value || minWidthNum;
|
||||
});
|
||||
|
||||
// 自适应宽度指令
|
||||
const vAutoWidth = {
|
||||
mounted() {
|
||||
// 初次挂载的时候计算一次
|
||||
calculateWidth();
|
||||
},
|
||||
updated() {
|
||||
// 数据更新时重新计算一次
|
||||
calculateWidth();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取按钮数量和宽带来获取操作组的最大宽度
|
||||
* 注意使用时需要使用 `class="operation-buttons"` 的标签包裹操作按钮
|
||||
* @returns {number} 返回操作组的最大宽度
|
||||
*/
|
||||
const getOperationMaxWidth = () => {
|
||||
const el = document.getElementsByClassName("operation-buttons");
|
||||
|
||||
// 取操作组的最大宽度
|
||||
let maxWidth = 0;
|
||||
let totalWidth: any = 0;
|
||||
Array.prototype.forEach.call(el, (item) => {
|
||||
// 获取每个item的dom
|
||||
const buttons = item.querySelectorAll(".el-button");
|
||||
// 获取每行按钮的总宽度
|
||||
totalWidth = Array.from(buttons).reduce((acc, button: any) => {
|
||||
return acc + button.scrollWidth + 14; // 每个按钮的宽度加上预留宽度
|
||||
}, 0);
|
||||
|
||||
// 获取最大的宽度
|
||||
if (totalWidth > maxWidth) maxWidth = totalWidth;
|
||||
});
|
||||
|
||||
return maxWidth;
|
||||
};
|
||||
</script>
|
||||
@@ -1,427 +0,0 @@
|
||||
<!--
|
||||
TextScroll 组件 - 文本滚动公告
|
||||
|
||||
功能:
|
||||
- 支持水平方向文本滚动
|
||||
- 提供多种预设样式(默认、成功、警告、危险、信息)
|
||||
- 支持自定义滚动速度和方向
|
||||
- 可选的打字机输入效果
|
||||
- 鼠标悬停时暂停滚动
|
||||
- 可选的关闭按钮
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="text-scroll-container"
|
||||
:class="[`text-scroll--${props.type}`]"
|
||||
:typewriter="props.typewriter ? 'true' : undefined"
|
||||
>
|
||||
<!-- 左侧图标 -->
|
||||
<div class="left-icon">
|
||||
<ElIcon><Bell /></ElIcon>
|
||||
</div>
|
||||
<!-- 滚动内容包装器 -->
|
||||
<div class="scroll-wrapper">
|
||||
<div
|
||||
ref="scrollContent"
|
||||
class="text-scroll-content"
|
||||
:class="{ scrolling: shouldScroll }"
|
||||
:style="scrollStyle"
|
||||
>
|
||||
<!-- 滚动内容,复制两份以实现无缝滚动 -->
|
||||
<div class="scroll-item" v-html="sanitizedContent" />
|
||||
<div class="scroll-item" v-html="sanitizedContent" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 可选的关闭按钮 -->
|
||||
<div v-if="showClose" class="right-icon" @click="handleRightIconClick">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 使用案例 -->
|
||||
<!-- <div class="app-container">
|
||||
<TextScroll text="这是一条基础的滚动公告,默认向左滚动。" typewriter />
|
||||
|
||||
<TextScroll type="success" text="这是一条成功类型的滚动公告" typewriter />
|
||||
|
||||
<TextScroll type="warning" text="这是一条警告类型的滚动公告" />
|
||||
|
||||
<TextScroll type="danger" text="这是一条危险类型的滚动公告" />
|
||||
|
||||
<TextScroll type="info" text="这是一条信息类型的滚动公告" />
|
||||
|
||||
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" show-close />
|
||||
</div> -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from "@vueuse/core";
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
interface Props {
|
||||
/** 滚动文本内容(必填) */
|
||||
text: string;
|
||||
/** 滚动速度,数值越小滚动越慢 */
|
||||
speed?: number;
|
||||
/** 滚动方向:左侧或右侧 */
|
||||
direction?: "left" | "right";
|
||||
/** 样式类型 */
|
||||
type?: "default" | "success" | "warning" | "danger" | "info";
|
||||
/** 是否显示关闭按钮 */
|
||||
showClose?: boolean;
|
||||
/** 是否启用打字机效果 */
|
||||
typewriter?: boolean;
|
||||
/** 打字机效果的速度,数值越小打字越快 */
|
||||
typewriterSpeed?: number;
|
||||
}
|
||||
|
||||
// 定义组件属性及默认值
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
speed: 70,
|
||||
direction: "left",
|
||||
type: "default",
|
||||
showClose: false,
|
||||
typewriter: false,
|
||||
typewriterSpeed: 100,
|
||||
});
|
||||
|
||||
// 容器元素引用
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
// 使用 vueuse 的 useElementHover 检测鼠标悬停状态
|
||||
const isHovered = useElementHover(containerRef);
|
||||
// 滚动内容元素引用
|
||||
const scrollContent = ref<HTMLElement | null>(null);
|
||||
// 动画持续时间(秒)
|
||||
const animationDuration = ref(0);
|
||||
|
||||
/**
|
||||
* 打字机效果相关状态
|
||||
*/
|
||||
// 当前已显示的文本内容
|
||||
const currentText = ref("");
|
||||
// 打字机定时器引用,用于清理
|
||||
let typewriterTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// 打字机效果是否已完成
|
||||
const isTypewriterComplete = ref(false);
|
||||
|
||||
/**
|
||||
* 计算是否应该滚动
|
||||
* 条件:
|
||||
* 1. 鼠标未悬停在组件上
|
||||
* 2. 如果启用了打字机效果,则需要等待打字效果完成
|
||||
*/
|
||||
const shouldScroll = computed(() => {
|
||||
if (props.typewriter) {
|
||||
return !isHovered.value && isTypewriterComplete.value;
|
||||
}
|
||||
return !isHovered.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算最终显示的内容
|
||||
* 如果启用了打字机效果,则显示当前已打出的文本
|
||||
* 否则直接显示完整文本
|
||||
* 注意:内容支持 HTML,使用时需注意 XSS 风险
|
||||
*/
|
||||
const sanitizedContent = computed(() => (props.typewriter ? currentText.value : props.text));
|
||||
|
||||
/**
|
||||
* 计算滚动样式
|
||||
* 包括动画持续时间、播放状态和方向
|
||||
* 这些值通过 CSS 变量传递给样式
|
||||
*/
|
||||
const scrollStyle = computed(() => ({
|
||||
"--animation-duration": `${animationDuration.value}s`,
|
||||
"--animation-play-state": shouldScroll.value ? "running" : "paused",
|
||||
"--animation-direction": props.direction === "left" ? "normal" : "reverse",
|
||||
}));
|
||||
|
||||
/**
|
||||
* 计算动画持续时间
|
||||
* 根据内容宽度和设定的速度计算出合适的动画持续时间
|
||||
* 内容越长或速度值越小,动画持续时间越长
|
||||
*/
|
||||
const calculateDuration = () => {
|
||||
if (scrollContent.value) {
|
||||
const contentWidth = scrollContent.value.scrollWidth / 2;
|
||||
animationDuration.value = contentWidth / props.speed;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理关闭按钮点击事件
|
||||
* 触发 close 事件,并直接销毁当前组件
|
||||
*/
|
||||
const handleRightIconClick = () => {
|
||||
emit("close");
|
||||
// 获取当前组件的DOM元素
|
||||
if (containerRef.value) {
|
||||
// 从DOM中移除元素
|
||||
containerRef.value.remove();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动打字机效果
|
||||
* 逐字显示文本内容,完成后设置状态以开始滚动
|
||||
*/
|
||||
const startTypewriter = () => {
|
||||
let index = 0;
|
||||
currentText.value = "";
|
||||
isTypewriterComplete.value = false; // 重置状态
|
||||
|
||||
// 递归函数,逐字添加文本
|
||||
const type = () => {
|
||||
if (index < props.text.length) {
|
||||
// 添加一个字符
|
||||
currentText.value += props.text[index];
|
||||
index++;
|
||||
// 设置下一个字符的延迟
|
||||
typewriterTimer = setTimeout(type, props.typewriterSpeed);
|
||||
} else {
|
||||
// 所有字符都已添加,设置完成状态
|
||||
isTypewriterComplete.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始打字过程
|
||||
type();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 计算初始动画持续时间
|
||||
calculateDuration();
|
||||
// 监听窗口大小变化,重新计算动画持续时间
|
||||
window.addEventListener("resize", calculateDuration);
|
||||
|
||||
// 如果启用了打字机效果,开始打字
|
||||
if (props.typewriter) {
|
||||
startTypewriter();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
window.removeEventListener("resize", calculateDuration);
|
||||
// 清除打字机定时器
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听文本内容变化
|
||||
* 当文本内容变化时,如果启用了打字机效果,重新开始打字
|
||||
*/
|
||||
watch(
|
||||
() => props.text,
|
||||
() => {
|
||||
if (props.typewriter) {
|
||||
// 清除现有定时器
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer);
|
||||
}
|
||||
// 重新开始打字效果
|
||||
startTypewriter();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.text-scroll-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border: 1px solid var(--main-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.left-icon,
|
||||
.right-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
.left-icon {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-icon {
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 34px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-scroll-content {
|
||||
display: flex;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
white-space: nowrap;
|
||||
animation: scroll linear infinite;
|
||||
animation-duration: var(--animation-duration);
|
||||
animation-direction: var(--animation-direction);
|
||||
animation-play-state: var(--animation-play-state);
|
||||
|
||||
.scroll-item {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary-light-2) !important;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
|
||||
:deep(a) {
|
||||
color: var(--el-color-danger) !important;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加类型样式
|
||||
&.text-scroll--default {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary);
|
||||
|
||||
.right-icon,
|
||||
.left-icon i {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--success {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
border-color: var(--el-color-success);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-success) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--warning {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
border-color: var(--el-color-warning);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-warning) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--danger {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
border-color: var(--el-color-danger);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--info {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
border-color: var(--el-color-info);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-info) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加打字机效果的光标样式
|
||||
.text-scroll-content .scroll-item {
|
||||
&::after {
|
||||
content: "";
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在启用打字机效果时显示光标
|
||||
.text-scroll-container[typewriter] .text-scroll-content .scroll-item::after {
|
||||
content: "|";
|
||||
opacity: 0;
|
||||
animation: cursor 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<ElDropdown trigger="click" @command="handleDarkChange">
|
||||
<ElIcon :size="20">
|
||||
<component :is="settingsStore.theme === ThemeMode.DARK ? Moon : Sunny" />
|
||||
</ElIcon>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="item in theneList"
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:disabled="settingsStore.theme === item.value"
|
||||
>
|
||||
<ElIcon>
|
||||
<component :is="item.component" />
|
||||
</ElIcon>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { ThemeMode } from "@/enums";
|
||||
import { Moon, Sunny, Monitor } from "@element-plus/icons-vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const theneList = [
|
||||
{ label: t("login.light"), value: ThemeMode.LIGHT, component: Sunny },
|
||||
{ label: t("login.dark"), value: ThemeMode.DARK, component: Moon },
|
||||
{ label: t("login.auto"), value: ThemeMode.AUTO, component: Monitor },
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
settingsStore.updateTheme(theme);
|
||||
};
|
||||
</script>
|
||||
@@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<div style="z-index: 999; border: 1px solid var(--el-border-color)">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
mode="mode"
|
||||
:default-config="toolbarConfig"
|
||||
style="border-bottom: 1px solid var(--el-border-color)"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:style="{ minHeight: height, overflowY: 'hidden' }"
|
||||
:default-config="editorConfig"
|
||||
mode="mode"
|
||||
@on-created="handleCreated"
|
||||
@on-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@wangeditor-next/editor/dist/css/style.css";
|
||||
import { Toolbar, Editor } from "@wangeditor-next/editor-for-vue";
|
||||
import { IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
|
||||
import ResourceAPI from "@/api/module_monitor/resource";
|
||||
|
||||
// 上传图片回调函数类型
|
||||
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
||||
|
||||
const props = defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: "200px",
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoFocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
scroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
// 双向绑定
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: String,
|
||||
required: false,
|
||||
});
|
||||
|
||||
// 编辑器实例,必须用 shallowRef,重要!
|
||||
const editorRef = shallowRef();
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
|
||||
|
||||
// 编辑器配置 - 根据 props 动态生成配置
|
||||
const editorConfig = ref<Partial<IEditorConfig>>({
|
||||
placeholder: "请输入内容...",
|
||||
readOnly: props.readonly,
|
||||
autoFocus: props.autoFocus,
|
||||
scroll: props.scroll,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: InsertFnType) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await ResourceAPI.uploadFile(formData);
|
||||
const data = res.data.data;
|
||||
|
||||
insertFn(data.file_url, data.filename, data.file_url);
|
||||
} catch (error: any) {
|
||||
console.error("图片上传失败:", error);
|
||||
ElMessage.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录 editor 实例,重要!
|
||||
const handleCreated = (editor: any) => {
|
||||
editorRef.value = editor;
|
||||
};
|
||||
|
||||
// 处理内容变化 - 获取纯文本而不是HTML
|
||||
const handleChange = (editor: any) => {
|
||||
editorRef.value = editor;
|
||||
if (editorRef.value) {
|
||||
const text = editorRef.value.getText();
|
||||
modelValue.value = text;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器,重要!
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user