feat: 项目结构重构优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<!-- 悬浮按钮 -->
|
||||
<div class="ai-assistant">
|
||||
<!-- AI 助手图标按钮 -->
|
||||
@@ -343,7 +343,7 @@ const handleExecute = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 路由配置映射表(支持扩展)
|
||||
// 路由配置映射表
|
||||
const routeConfig = [
|
||||
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
|
||||
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
|
||||
@@ -387,13 +387,13 @@ const extractKeywordFromCommand = (cmd: string): string => {
|
||||
const keywordsPattern = allKeywords.join("|");
|
||||
|
||||
const patterns = [
|
||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,,。]+?)(?:的|信息|详情)?`, "i"),
|
||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,。]+?)(?:的|信息|详情)?`, "i"),
|
||||
new RegExp(
|
||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||
"i"
|
||||
),
|
||||
new RegExp(`([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||
new RegExp(`([^\\s,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
|
||||
class="color-gray-400"
|
||||
>
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
@@ -18,7 +18,7 @@
|
||||
import { RouteLocationMatched } from "vue-router";
|
||||
import { compile } from "path-to-regexp";
|
||||
import router from "@/router";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
import { translateRouteTitle } from "@/lang/utils";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const pathCompile = (path: string) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<template>
|
||||
<div
|
||||
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
|
||||
>
|
||||
<!-- 表格工具栏 -->
|
||||
<!-- 表格工具 -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
|
||||
<!-- 左侧工具栏 -->
|
||||
<!-- 左侧工具 -->
|
||||
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
|
||||
<el-button
|
||||
@@ -17,7 +17,7 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<!-- 右侧工具 -->
|
||||
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
@@ -62,7 +62,7 @@
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="index"
|
||||
:initial-index="Number(index)"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<!-- 根据行的selectList属性返回对应列表 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
@@ -92,7 +92,7 @@
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<!-- 生成开关组 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
@@ -111,7 +111,7 @@
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成输入框组件 -->
|
||||
<!-- 生成输入框组 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
@@ -125,7 +125,7 @@
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
{{ `${col.priceFormat ?? ""}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
@@ -220,7 +220,7 @@
|
||||
<el-form-item label="工作表名" prop="sheetname">
|
||||
<el-input v-model="exportsFormData.sheetname" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据源" prop="origin">
|
||||
<el-form-item label="数据来源" prop="origin">
|
||||
<el-select v-model="exportsFormData.origin">
|
||||
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
|
||||
<el-option
|
||||
@@ -247,8 +247,8 @@
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<template #footer>
|
||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
||||
<el-button type="primary" @click="handleExportsSubmit">确定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -270,7 +270,7 @@
|
||||
:model="importFormData"
|
||||
:rules="importFormRules"
|
||||
>
|
||||
<el-form-item label="文件名" prop="files">
|
||||
<el-form-item label="文件" prop="files">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="importFormData.files"
|
||||
@@ -283,7 +283,7 @@
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
<span>将文件拖到此处,或</span>
|
||||
<span>将文件拖到此处,或点击上传</span>
|
||||
<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
@@ -312,9 +312,9 @@
|
||||
:disabled="importFormData.files.length === 0"
|
||||
@click="handleImportSubmit"
|
||||
>
|
||||
确 定
|
||||
确定
|
||||
</el-button>
|
||||
<el-button @click="handleCloseImportModal">取 消</el-button>
|
||||
<el-button @click="handleCloseImportModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -427,7 +427,7 @@ const toolbarRightBtn = computed(() => {
|
||||
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
|
||||
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
|
||||
|
||||
// 表格列
|
||||
// 表格相关
|
||||
const cols = ref(
|
||||
props.contentConfig.cols.map((col) => {
|
||||
if (col.initFn) {
|
||||
@@ -517,7 +517,7 @@ function handleDelete(id?: number | string) {
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
removeIds.value = [];
|
||||
//清空选中项
|
||||
// 清空选中项
|
||||
tableRef.value?.clearSelection();
|
||||
handleRefresh(true);
|
||||
})
|
||||
@@ -551,7 +551,7 @@ const exportsFormData = reactive({
|
||||
});
|
||||
const exportsFormRules: FormRules = {
|
||||
fields: [{ required: true, message: "请选择字段" }],
|
||||
origin: [{ required: true, message: "请选择数据源" }],
|
||||
origin: [{ required: true, message: "请选择数据来源" }],
|
||||
};
|
||||
// 打开导出弹窗
|
||||
function handleOpenExportsModal() {
|
||||
@@ -709,7 +709,7 @@ function handleImports() {
|
||||
fileReader.onload = (ev) => {
|
||||
if (ev.target !== null && ev.target.result !== null) {
|
||||
const result = ev.target.result as ArrayBuffer;
|
||||
// 从 buffer中加载数据解析
|
||||
// 从 buffer 中加载并解析数据
|
||||
workbook.xlsx
|
||||
.load(result)
|
||||
.then((workbook) => {
|
||||
@@ -753,7 +753,7 @@ function handleImports() {
|
||||
};
|
||||
}
|
||||
|
||||
// 操作栏
|
||||
// 操作人"
|
||||
function handleToolbar(name: string) {
|
||||
switch (name) {
|
||||
case "refresh":
|
||||
@@ -786,7 +786,7 @@ function handleToolbar(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 操作列
|
||||
// 操作人"
|
||||
function handleOperate(data: IOperateData) {
|
||||
switch (data.name) {
|
||||
case "delete":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<!-- drawer -->
|
||||
<template v-if="modalConfig.component === 'drawer'">
|
||||
@@ -59,8 +59,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -124,8 +124,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -144,7 +144,7 @@ defineSlots<{ [key: string]: (_args: any) => any }>();
|
||||
const props = defineProps<{ modalConfig: IModalConfig }>();
|
||||
// 自定义事件
|
||||
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
|
||||
// 组件映射表
|
||||
// 组件映射
|
||||
|
||||
const componentMap = new Map<IComponentType, any>([
|
||||
// @ts-ignore
|
||||
@@ -173,14 +173,14 @@ const childrenMap = new Map<IComponentType, any>([
|
||||
]);
|
||||
|
||||
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
|
||||
const modalVisible = ref(false); // 弹窗显示状态
|
||||
const modalVisible = ref(false); // 弹窗显示状态"
|
||||
const formRef = ref<FormInstance>(); // 表单实例
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项"
|
||||
const formData = reactive<IObject>({}); // 表单数据
|
||||
const formRules: FormRules = {}; // 表单验证规则
|
||||
const formDisable = ref(false); // 表单禁用状态
|
||||
const formDisable = ref(false); // 表单禁用状态"
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获取 tooltip 提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
@@ -189,7 +189,7 @@ const handleClose = () => {
|
||||
modalVisible.value = false;
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
// 设置表单值
|
||||
// 设置表单项
|
||||
const setFormData = (data: IObject) => {
|
||||
for (const key in formData) {
|
||||
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||
@@ -245,11 +245,11 @@ onMounted(() => {
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
setFormData,
|
||||
// 展示/因此 modal
|
||||
// 展示/隐藏 modal
|
||||
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
|
||||
// 获取表单数据
|
||||
getFormData: (key: string) => formData[key] ?? formData,
|
||||
// 设置表单项值
|
||||
// 设置表单项
|
||||
setFormItemData: (key: string, value: any) => (formData[key] = value),
|
||||
// 禁用表单
|
||||
handleDisabled: (disable: boolean) => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 自定义插槽 -->
|
||||
<!-- 自定义æ<EFBFBD>’æ§?-->
|
||||
<slot
|
||||
v-if="item.type === 'custom'"
|
||||
:name="item.slotName"
|
||||
@@ -71,14 +71,14 @@ import { ArrowUp, ArrowDown } from "@element-plus/icons-vue";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
|
||||
// 定义接收的属性
|
||||
// 定义接收的属�
|
||||
const props = defineProps<{ searchConfig: ISearchConfig }>();
|
||||
// 自定义事件
|
||||
// 自定义事�
|
||||
const emit = defineEmits<{
|
||||
queryClick: [queryParams: IObject];
|
||||
resetClick: [queryParams: IObject];
|
||||
}>();
|
||||
// 组件映射表
|
||||
// ç»„ä»¶æ˜ å°„è¡?
|
||||
const componentMap = new Map<ISearchComponent, any>([
|
||||
// @ts-ignore
|
||||
["input", markRaw(ElInput)], // @ts-ignore
|
||||
@@ -105,7 +105,7 @@ const formItems = reactive(props.searchConfig?.formItems ?? []);
|
||||
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
|
||||
// 是å<C2AF>¦å·²å±•å¼€
|
||||
const isExpand = ref(false);
|
||||
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
|
||||
// 表å<EFBFBD>•项展示数é‡<EFBFBD>,若å<EFBFBD>¯å±•开,超出展示数é‡<EFBFBD>的表å<EFBFBD>•项éš<EFBFBD>è—?
|
||||
const showNumber = computed(() =>
|
||||
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
|
||||
);
|
||||
@@ -113,7 +113,7 @@ const showNumber = computed(() =>
|
||||
const cardAttrs = computed<IObject>(() => {
|
||||
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
|
||||
});
|
||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
||||
// 表å<EFBFBD>•组件自定义属性(labelä½<EFBFBD>ç½®ã€<EFBFBD>宽度ã€<EFBFBD>对é½<EFBFBD>æ–¹å¼<EFBFBD>ç‰ï¼?
|
||||
const formAttrs = computed<IForm>(() => {
|
||||
return { inline: true, ...props.searchConfig?.form };
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const isGrid = computed(() =>
|
||||
: "flex flex-wrap gap-x-8 gap-y-4"
|
||||
);
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获å<EFBFBD>–tooltipæ<EFBFBD><EFBFBD>示框属æ€?
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface IContentConfig<T = any> {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// table组件属性
|
||||
table?: Omit<TableProps<any>, "data">;
|
||||
table?: Partial<Omit<TableProps<any>, "data">>;
|
||||
// 分页组件位置(默认:left)
|
||||
pagePosition?: "left" | "right";
|
||||
// pagination组件属性
|
||||
|
||||
66
src/components/CommandPalette/index.vue
Normal file
66
src/components/CommandPalette/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button type="text" icon="Search" aria-label="打开搜索面板" @click="open" />
|
||||
|
||||
<el-dialog v-model="visible" width="640px" :close-on-click-modal="true" @close="close">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索菜单</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
placeholder="输入菜单名称或关键字搜索"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="onSelect"
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<div
|
||||
v-if="results.length === 0 && history.length === 0"
|
||||
class="text-center text-gray-500"
|
||||
>
|
||||
没有搜索历史
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, idx) in results.length ? results : history"
|
||||
:key="item.path + idx"
|
||||
class="p-2 hover:bg-gray-100 cursor-pointer rounded"
|
||||
@click="onGo(item)"
|
||||
>
|
||||
<div>{{ item.title }}</div>
|
||||
<div class="text-sm text-gray-400">{{ item.path }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { useCommandPalette } from "./useCommandPalette";
|
||||
|
||||
const { visible, keyword, results, history, inputRef, open, close, onSearch, onSelect, onGo } =
|
||||
useCommandPalette();
|
||||
|
||||
onMounted(() => {
|
||||
// no-op for now
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 菜单搜索逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, toRaw } from "vue";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
|
||||
/** 搜索项类型 */
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
export function useCommandPalette() {
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 状态
|
||||
const visible = ref(false);
|
||||
const keyword = ref("");
|
||||
const activeIndex = ref(-1);
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const menuItems = ref<SearchItem[]>([]);
|
||||
const results = ref<SearchItem[]>([]);
|
||||
const history = ref<SearchItem[]>([]);
|
||||
|
||||
// 排除的路由
|
||||
const excludedPaths = ["/redirect", "/login", "/401", "/404"];
|
||||
|
||||
// ============================================
|
||||
// 弹窗控制
|
||||
// ============================================
|
||||
|
||||
function open() {
|
||||
keyword.value = "";
|
||||
results.value = [];
|
||||
activeIndex.value = -1;
|
||||
visible.value = true;
|
||||
setTimeout(() => inputRef.value?.focus(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 搜索逻辑
|
||||
// ============================================
|
||||
|
||||
function onSearch() {
|
||||
activeIndex.value = -1;
|
||||
if (!keyword.value.trim()) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
const kw = keyword.value.toLowerCase();
|
||||
results.value = menuItems.value.filter((item) => item.title.toLowerCase().includes(kw));
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (results.value.length > 0 && activeIndex.value >= 0) {
|
||||
onGo(results.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigate(direction: "up" | "down") {
|
||||
if (results.value.length === 0) return;
|
||||
|
||||
if (direction === "up") {
|
||||
activeIndex.value = activeIndex.value <= 0 ? results.value.length - 1 : activeIndex.value - 1;
|
||||
} else {
|
||||
activeIndex.value = activeIndex.value >= results.value.length - 1 ? 0 : activeIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function onGo(item: SearchItem) {
|
||||
close();
|
||||
addHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 历史记录
|
||||
// ============================================
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
history.value = data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
history.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value));
|
||||
}
|
||||
|
||||
function addHistory(item: SearchItem) {
|
||||
// 去重
|
||||
const idx = history.value.findIndex((i) => i.path === item.path);
|
||||
if (idx !== -1) history.value.splice(idx, 1);
|
||||
|
||||
// 添加到开头
|
||||
history.value.unshift(item);
|
||||
|
||||
// 限制数量
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value = history.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function removeHistory(index: number) {
|
||||
history.value.splice(index, 1);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history.value = [];
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 路由解析
|
||||
// ============================================
|
||||
|
||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
||||
|
||||
if (excludedPaths.includes(route.path) || isExternal(route.path)) return;
|
||||
|
||||
if (route.children) {
|
||||
loadRoutes(route.children, path);
|
||||
} else if (route.meta?.title) {
|
||||
menuItems.value.push({
|
||||
title: route.meta.title === "dashboard" ? "首页" : route.meta.title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: route.meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 快捷键
|
||||
// ============================================
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
loadHistory();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
return {
|
||||
visible,
|
||||
keyword,
|
||||
results,
|
||||
history,
|
||||
activeIndex,
|
||||
inputRef,
|
||||
open,
|
||||
close,
|
||||
onSearch,
|
||||
onSelect,
|
||||
onNavigate,
|
||||
onGo,
|
||||
removeHistory,
|
||||
clearHistory,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +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>
|
||||
@@ -90,7 +90,7 @@ const selectedValue = ref<any>(
|
||||
: undefined
|
||||
);
|
||||
|
||||
// 监听 modelValue 和 options 的变化
|
||||
// 监å<EFBFBD>¬ modelValue å’?options çš„å<EFBFBD>˜åŒ?
|
||||
watch(
|
||||
[() => props.modelValue, () => options.value],
|
||||
([newValue, newOptions]) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useDictStore } from "@/store";
|
||||
|
||||
const props = defineProps({
|
||||
code: String, // 字典编码
|
||||
modelValue: [String, Number], // 字典项的值
|
||||
modelValue: [String, Number], // å—典项的å€?
|
||||
size: {
|
||||
type: String,
|
||||
default: "default", // 标签大小
|
||||
@@ -26,10 +26,10 @@ const tagSize = ref<"default" | "large" | "small">(props.size as "default" | "la
|
||||
const dictStore = useDictStore();
|
||||
|
||||
/**
|
||||
* 根据字典项的值获取对应的 label 和 tagType
|
||||
* æ ¹æ<EFBFBD>®å—典项的值获å<EFBFBD>–对应的 label å’?tagType
|
||||
* @param dictCode 字典编码
|
||||
* @param value 字典项的值
|
||||
* @returns 包含 label 和 tagType 的对象
|
||||
* @param value å—典项的å€?
|
||||
* @returns 包å<EFBFBD>« label å’?tagType 的对è±?
|
||||
*/
|
||||
const getLabelAndTagByValue = async (dictCode: string, value: any) => {
|
||||
// 按需加载字典数据
|
||||
@@ -45,7 +45,7 @@ const getLabelAndTagByValue = async (dictCode: string, value: any) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 label 和 tagType
|
||||
* æ›´æ–° label å’?tagType
|
||||
*/
|
||||
const updateLabelAndTag = async () => {
|
||||
if (!props.code || props.modelValue === undefined) return;
|
||||
@@ -1,12 +1,12 @@
|
||||
<!--
|
||||
* 基于 ECharts 的 Vue3 图表组件
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
* 基于 ECharts �Vue3 图表组件
|
||||
* 版æ<EFBFBD>ƒæ‰€æœ?© 2021-present 有æ<EFBFBD>¥å¼€æº<EFBFBD>组ç»?
|
||||
*
|
||||
* å¼€æº<EFBFBD>å<EFBFBD><EFBFBD>议:https://opensource.org/licenses/MIT
|
||||
* 项目地å<EFBFBD>€ï¼šhttps://gitee.com/youlaiorg/vue3-element-admin
|
||||
* å<EFBFBD>‚考:https://echarts.apache.org/handbook/zh/basics/import/#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
* 在使用时,请ä¿<EFBFBD>ç•™æ¤æ³¨é‡Šï¼Œæ„Ÿè°¢æ‚¨å¯¹å¼€æº<EFBFBD>的支æŒ<EFBFBD>ï¼?
|
||||
-->
|
||||
|
||||
<template>
|
||||
@@ -14,13 +14,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
|
||||
// 引入 echarts æ ¸å¿ƒæ¨¡å<EFBFBD>—ï¼Œæ ¸å¿ƒæ¨¡å<EFBFBD>—æ<EFBFBD><EFBFBD>供了 echarts 使用必须è¦<C3A8>的接å<C2A5>£ã€?
|
||||
import * as echarts from "echarts/core";
|
||||
// 引入柱状ã€<C3A3>折线和饼图常用图表
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,
|
||||
// å¼•å…¥æ ‡é¢˜ï¼Œæ<EFBFBD><EFBFBD>示框,直角å<EFBFBD><EFBFBD>æ ‡ç³»ï¼Œæ•°æ<EFBFBD>®é›†ï¼Œå†…置数æ<EFBFBD>®è½¬æ<EFBFBD>¢å™¨ç»„ä»¶ï¼?
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
||||
// 引入 Canvas 渲染器,注æ„<EFBFBD>引入 CanvasRenderer 或è€?SVGRenderer 是必须的一æ?
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
@@ -45,7 +45,7 @@ const props = defineProps<{
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 初始化图表
|
||||
// åˆ<EFBFBD>始化图è¡?
|
||||
const initChart = () => {
|
||||
if (chartRef.value) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
@@ -55,12 +55,12 @@ const initChart = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听尺寸变化,自动调整
|
||||
// 监å<EFBFBD>¬å°ºå¯¸å<EFBFBD>˜åŒ–,自动调æ•?
|
||||
useResizeObserver(chartRef, () => {
|
||||
chartInstance?.resize();
|
||||
});
|
||||
|
||||
// 监听 options 变化,更新图表
|
||||
// 监å<EFBFBD>¬ options å<EFBFBD>˜åŒ–,更新图è¡?
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const hamburgerClass = computed(() => {
|
||||
return "hamburger--white";
|
||||
}
|
||||
|
||||
// 如果是混合布局 && 侧边栏配色方案是经典蓝
|
||||
// 如果是混å<EFBFBD>ˆå¸ƒå±€ && ä¾§è¾¹æ <C3A6>é…<C3A9>色方案是ç»<C3A7>å…¸è“?
|
||||
if (
|
||||
layout.value === LayoutMode.MIX &&
|
||||
settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
|
||||
@@ -159,7 +159,7 @@ onClickOutside(iconSelectRef, () => (popoverVisible.value = false), {
|
||||
});
|
||||
|
||||
/**
|
||||
* 清空已选图标
|
||||
* 清空已选图æ ?
|
||||
*/
|
||||
function clearSelectedIcon() {
|
||||
selectedIcon.value = "";
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from "@/store/modules/app-store";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
import { LanguageEnum } from "@/enums/settings";
|
||||
|
||||
defineProps({
|
||||
@@ -38,7 +38,7 @@ const { locale, t } = useI18n();
|
||||
/**
|
||||
* 处ç<E2809E>†è¯è¨€åˆ‡æ<E280A1>¢
|
||||
*
|
||||
* @param lang 语言(zh-cn、en)
|
||||
* @param lang è¯è¨€ï¼ˆzh-cnã€<C3A3>enï¼?
|
||||
*/
|
||||
function handleLanguageChange(lang: string) {
|
||||
locale.value = lang;
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
<template>
|
||||
<div @click="openSearchModal">
|
||||
<div class="i-svg:search" />
|
||||
<el-dialog
|
||||
v-model="isModalVisible"
|
||||
width="30%"
|
||||
:append-to-body="true"
|
||||
:show-close="false"
|
||||
@close="closeSearchModal"
|
||||
>
|
||||
<template #header>
|
||||
<el-input
|
||||
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>
|
||||
<el-button icon="Search" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
|
||||
<div class="search-result">
|
||||
<!-- 搜索历史 -->
|
||||
<template v-if="searchKeyword === '' && searchHistory.length > 0">
|
||||
<div class="search-history">
|
||||
<div class="search-history__title">
|
||||
搜索历史
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
class="search-history__clear"
|
||||
@click="clearHistory"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</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">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<span class="search-history__name">{{ item.title }}</span>
|
||||
<div class="search-history__action">
|
||||
<el-icon @click.stop="removeHistoryItem(index)"><Close /></el-icon>
|
||||
</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)"
|
||||
>
|
||||
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
||||
<component :is="item.icon.replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
||||
<div v-else class="i-svg:menu" />
|
||||
<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">
|
||||
<div class="i-svg:up" />
|
||||
</div>
|
||||
<div class="key-btn ml-1">
|
||||
<div class="i-svg: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>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import { Clock, Close, Delete } from "@element-plus/icons-vue";
|
||||
|
||||
const HISTORY_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
const permissionStore = usePermissionStore();
|
||||
const isModalVisible = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const searchInputRef = ref();
|
||||
const excludedRoutes = ref(["/redirect", "/login", "/401", "/404"]);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
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 loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
||||
if (excludedRoutes.value.includes(route.path) || isExternal(route.path)) return;
|
||||
|
||||
if (route.children) {
|
||||
loadRoutes(route.children, path);
|
||||
} else if (route.meta?.title) {
|
||||
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
|
||||
menuItems.value.push({
|
||||
title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: route.meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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:
|
||||
inset 0 -2px 0 0 var(--el-border-color),
|
||||
inset 0 0 1px 1px var(--el-color-white),
|
||||
0 1px 2px rgba(30, 35, 90, 0.2);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
left: 1px;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0));
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
// 暗黑模式适配
|
||||
html.dark {
|
||||
.key-btn::before {
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click">
|
||||
<el-badge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
|
||||
<el-badge v-if="list.length > 0" :value="list.length" :max="99">
|
||||
<div class="i-svg:bell" />
|
||||
</el-badge>
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-5">
|
||||
<template v-if="noticeList.length > 0">
|
||||
<div v-for="(item, index) in noticeList" :key="index" class="w-500px py-3">
|
||||
<template v-if="list.length > 0">
|
||||
<div v-for="item in list" :key="item.id" class="w-500px py-3">
|
||||
<div class="flex-y-center">
|
||||
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
||||
<DictTag v-model="item.type" code="notice_type" size="small" />
|
||||
<el-text
|
||||
size="small"
|
||||
class="w-200px cursor-pointer !ml-2 !flex-1"
|
||||
truncated
|
||||
@click="handleReadNotice(item.id)"
|
||||
@click="read(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-text>
|
||||
@@ -28,18 +28,13 @@
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="flex-x-between">
|
||||
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
|
||||
<el-link type="primary" underline="never" @click="goMore">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<el-icon class="text-xs">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="noticeList.length > 0"
|
||||
type="primary"
|
||||
underline="never"
|
||||
@click="handleMarkAllAsRead"
|
||||
>
|
||||
<el-link v-if="list.length > 0" type="primary" underline="never" @click="readAll">
|
||||
<span class="text-xs">全部已读</span>
|
||||
</el-link>
|
||||
</div>
|
||||
@@ -54,58 +49,34 @@
|
||||
</el-dropdown>
|
||||
|
||||
<el-dialog
|
||||
v-model="noticeDialogVisible"
|
||||
:title="noticeDetail?.title ?? '通知详情'"
|
||||
v-model="dialogVisible"
|
||||
:title="detail?.title ?? '通知详情'"
|
||||
width="800px"
|
||||
custom-class="notification-detail"
|
||||
>
|
||||
<div v-if="noticeDetail" class="p-x-20px">
|
||||
<div v-if="detail" class="p-x-20px">
|
||||
<div class="flex-y-center mb-16px text-13px text-color-secondary">
|
||||
<span class="flex-y-center">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ noticeDetail.publisherName }}
|
||||
{{ detail.publisherName }}
|
||||
</span>
|
||||
<span class="ml-2 flex-y-center">
|
||||
<el-icon><Timer /></el-icon>
|
||||
{{ noticeDetail.publishTime }}
|
||||
{{ detail.publishTime }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
|
||||
<div v-html="noticeDetail.content"></div>
|
||||
<div v-html="detail.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNotificationCenter } from "@/composables/notice/useNotificationCenter";
|
||||
import { useNotice } from "./useNotice";
|
||||
|
||||
const {
|
||||
noticeList,
|
||||
noticeDialogVisible,
|
||||
noticeDetail,
|
||||
fetchMyNotices,
|
||||
readNotice,
|
||||
markAllAsRead,
|
||||
viewMore,
|
||||
} = useNotificationCenter();
|
||||
|
||||
function handleReadNotice(id: string) {
|
||||
readNotice(id);
|
||||
}
|
||||
|
||||
function handleViewMoreNotice() {
|
||||
viewMore();
|
||||
}
|
||||
|
||||
function handleMarkAllAsRead() {
|
||||
markAllAsRead();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMyNotices();
|
||||
});
|
||||
const { list, detail, dialogVisible, read, readAll, goMore } = useNotice();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
114
src/components/NoticeDropdown/useNotice.ts
Normal file
114
src/components/NoticeDropdown/useNotice.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 通知中心逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import type { NoticePageVo, NoticeDetailVo, NoticePageQuery } from "@/types/api";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useStomp } from "@/composables";
|
||||
import router from "@/router";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function useNotice() {
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
|
||||
// 状态
|
||||
const list = ref<NoticePageVo[]>([]);
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
let subscribed = false;
|
||||
|
||||
// ============================================
|
||||
// 数据获取
|
||||
// ============================================
|
||||
|
||||
async function fetchList(params?: Partial<NoticePageQuery>) {
|
||||
const query: NoticePageQuery = {
|
||||
pageNum: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
isRead: 0,
|
||||
...params,
|
||||
} as NoticePageQuery;
|
||||
const page = await NoticeAPI.getMyNoticePage(query);
|
||||
list.value = page.list || [];
|
||||
}
|
||||
|
||||
async function read(id: string) {
|
||||
detail.value = await NoticeAPI.getDetail(id);
|
||||
dialogVisible.value = true;
|
||||
|
||||
// 从列表中移除已读项
|
||||
const idx = list.value.findIndex((item: NoticePageVo) => item.id === id);
|
||||
if (idx >= 0) list.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
await NoticeAPI.readAll();
|
||||
list.value = [];
|
||||
}
|
||||
|
||||
function goMore() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket 订阅
|
||||
// ============================================
|
||||
|
||||
function setupSubscription() {
|
||||
if (subscribed || !isConnected.value) return;
|
||||
|
||||
subscribe("/user/queue/message", (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body || "{}");
|
||||
if (!data.id) return;
|
||||
|
||||
// 避免重复
|
||||
if (list.value.some((item: NoticePageVo) => item.id === data.id)) return;
|
||||
|
||||
list.value.unshift({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
publishTime: data.publishTime,
|
||||
} as NoticePageVo);
|
||||
|
||||
ElNotification({
|
||||
title: "您收到一条新的通知消息!",
|
||||
message: data.title,
|
||||
type: "success",
|
||||
position: "bottom-right",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("解析通知消息失败", e);
|
||||
}
|
||||
});
|
||||
|
||||
subscribed = true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
setupSubscription();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
subscribed = false;
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
detail,
|
||||
dialogVisible,
|
||||
fetchList,
|
||||
read,
|
||||
readAll,
|
||||
goMore,
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const count = ref(0);
|
||||
const operationWidth = ref(props.minWidth || 80);
|
||||
|
||||
// 计算操作列宽度
|
||||
// 霈∠<EFBFBD><EFBFBD>滢<EFBFBD><EFBFBD>堒捐摨?
|
||||
const calculateWidth = () => {
|
||||
count.value++;
|
||||
|
||||
@@ -46,7 +46,7 @@ const calculateWidth = () => {
|
||||
count.value = 0;
|
||||
};
|
||||
|
||||
// 计算最终宽度
|
||||
// 霈∠<EFBFBD><EFBFBD><EFBFBD>蝏<EFBFBD>捐摨?
|
||||
const finalWidth = computed(() => {
|
||||
return props.width || operationWidth.value || props.minWidth;
|
||||
});
|
||||
@@ -54,32 +54,32 @@ const finalWidth = computed(() => {
|
||||
// <20>芷<EFBFBD><E88AB7><EFBFBD>摰賢漲<E8B3A2><E6BCB2>誘
|
||||
const vAutoWidth = {
|
||||
mounted() {
|
||||
// 初次挂载的时候计算一次
|
||||
// <EFBFBD>脲活<EFBFBD><EFBFBD>蝸<EFBFBD><EFBFBD>𧒄<EFBFBD>躰恣蝞𦯀<EFBFBD>甈?
|
||||
calculateWidth();
|
||||
},
|
||||
updated() {
|
||||
// 数据更新时重新计算一次
|
||||
// <EFBFBD>唳旿<EFBFBD>湔鰵<EFBFBD>園<EFBFBD><EFBFBD>啗恣蝞𦯀<EFBFBD>甈?
|
||||
calculateWidth();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取按钮数量和宽带来获取操作组的最大宽度
|
||||
* 注意使用时需要使用 `class="operation-buttons"` 的标签包裹操作按钮
|
||||
* @returns {number} 返回操作组的最大宽度
|
||||
* <EFBFBD>瑕<EFBFBD><EFBFBD>厰僼<EFBFBD>圈<EFBFBD><EFBFBD><EFBFBD>捐撣行䔉<EFBFBD>瑕<EFBFBD><EFBFBD>滢<EFBFBD>蝏<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
* 瘜冽<EFBFBD>雿輻鍂<EFBFBD>園<EFBFBD>閬<EFBFBD>蝙<EFBFBD>?`class="operation-buttons"` <EFBFBD><EFBFBD><EFBFBD>蝑曉<EFBFBD>鋆寞<EFBFBD>雿𨀣<EFBFBD><EFBFBD>?
|
||||
* @returns {number} 餈𥪜<EFBFBD><EFBFBD>滢<EFBFBD>蝏<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
*/
|
||||
const getOperationMaxWidth = () => {
|
||||
const el = document.getElementsByClassName("operation-buttons");
|
||||
|
||||
// 取操作组的最大宽度
|
||||
// <EFBFBD>𡝗<EFBFBD>雿𦦵<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
let maxWidth = 0;
|
||||
let totalWidth: any = 0;
|
||||
Array.prototype.forEach.call(el, (item) => {
|
||||
// <20>瑕<EFBFBD>瘥譍葵item<65><6D>om
|
||||
const buttons = item.querySelectorAll(".el-button");
|
||||
// 获取每行按钮的总宽度
|
||||
// <EFBFBD>瑕<EFBFBD>瘥讛<EFBFBD><EFBFBD>厰僼<EFBFBD><EFBFBD><EFBFBD>餃捐摨?
|
||||
totalWidth = Array.from(buttons).reduce((acc, button: any) => {
|
||||
return acc + button.scrollWidth + 22; // 每个按钮的宽度加上预留宽度
|
||||
return acc + button.scrollWidth + 22; // 瘥譍葵<EFBFBD>厰僼<EFBFBD><EFBFBD>捐摨血<EFBFBD>銝𢠃<EFBFBD><EFBFBD>坔捐摨?
|
||||
}, 0);
|
||||
|
||||
// <20>瑕<EFBFBD><E79195><EFBFBD>憭抒<E686AD>摰賢漲
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ComponentSize } from "@/enums/settings";
|
||||
import { useAppStore } from "@/store/modules/app-store";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
|
||||
const { t } = useI18n();
|
||||
const sizeOptions = computed(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div ref="tableSelectRef" :style="'width:' + width">
|
||||
<el-popover
|
||||
:visible="popoverVisible"
|
||||
@@ -30,13 +30,13 @@
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 弹出框内容 -->
|
||||
<!-- 弹出框内 -->
|
||||
<div ref="popoverContentRef">
|
||||
<!-- 表单 -->
|
||||
<el-form ref="formRef" :model="queryParams" :inline="true">
|
||||
<template v-for="item in selectConfig.formItems" :key="item.prop">
|
||||
<el-form-item :label="item.label" :prop="item.prop">
|
||||
<!-- Input 输入框 -->
|
||||
<!-- Input 输入 -->
|
||||
<template v-if="item.type === 'input'">
|
||||
<template v-if="item.attrs?.type === 'number'">
|
||||
<el-input
|
||||
@@ -53,7 +53,7 @@
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Select 选择器 -->
|
||||
<!-- Select 选择 -->
|
||||
<template v-else-if="item.type === 'select'">
|
||||
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
|
||||
<template v-for="option in item.options" :key="option.value">
|
||||
@@ -65,11 +65,11 @@
|
||||
<template v-else-if="item.type === 'tree-select'">
|
||||
<el-tree-select v-model="queryParams[item.prop]" v-bind="item.attrs" />
|
||||
</template>
|
||||
<!-- DatePicker 日期选择器 -->
|
||||
<!-- DatePicker 日期选择 -->
|
||||
<template v-else-if="item.type === 'date-picker'">
|
||||
<el-date-picker v-model="queryParams[item.prop]" v-bind="item.attrs" />
|
||||
</template>
|
||||
<!-- Input 输入框 -->
|
||||
<!-- Input 输入 -->
|
||||
<template v-else>
|
||||
<template v-if="item.attrs?.type === 'number'">
|
||||
<el-input
|
||||
@@ -133,8 +133,8 @@
|
||||
<el-button type="primary" size="small" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleClear">清 空</el-button>
|
||||
<el-button size="small" @click="handleClose">关 闭</el-button>
|
||||
<el-button size="small" @click="handleClear">清空</el-button>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
@@ -156,15 +156,15 @@ export interface ISelectConfig<T = any> {
|
||||
placeholder?: string;
|
||||
// popover组件属性
|
||||
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
|
||||
// 列表的网络请求函数(需返回promise)
|
||||
// 列表的网络请求函数 (需返回 Promise)
|
||||
indexAction: (_queryParams: T) => Promise<any>;
|
||||
// 主键名(跨页选择必填,默认为id)
|
||||
// 主键 (跨页选择必填, 默认为 id)
|
||||
pk?: string;
|
||||
// 多选
|
||||
// 是否多选
|
||||
multiple?: boolean;
|
||||
// 表单项
|
||||
formItems: Array<{
|
||||
// 组件类型(如input,select等)
|
||||
// 组件类型(如 input, select 等)
|
||||
type?: "input" | "select" | "tree-select" | "date-picker";
|
||||
// 标签文本
|
||||
label: string;
|
||||
@@ -282,7 +282,7 @@ for (const item of props.selectConfig.tableColumns) {
|
||||
// 选择
|
||||
const selectedItems = ref<IObject[]>([]);
|
||||
const confirmText = computed(() => {
|
||||
return selectedItems.value.length > 0 ? `已选(${selectedItems.value.length})` : "确 定";
|
||||
return selectedItems.value.length > 0 ? `已选${selectedItems.value.length}条` : "请选择";
|
||||
});
|
||||
function handleSelect(selection: any[]) {
|
||||
if (isMultiple || selection.length === 0) {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { useTenantStoreHook } from "@/store/modules/tenant-store";
|
||||
import { useTenantStoreHook } from "@/store/modules/tenant";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change", tenantId: number): void;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!--
|
||||
<!--
|
||||
TextScroll 组件 - 文本滚动公告
|
||||
|
||||
功能:
|
||||
功能:
|
||||
- 支持水平方向文本滚动
|
||||
- 提供多种预设样式(默认、成功、警告、危险、信息)
|
||||
- 支持自定义滚动速度和方向
|
||||
- 支持自定义滚动速度和方式
|
||||
- 可选的打字机输入效果
|
||||
- 鼠标悬停时暂停滚动
|
||||
- 可选的关闭按钮
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="left-icon">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</div>
|
||||
<!-- 滚动内容包装器 -->
|
||||
<!-- 滚动内容容器 -->
|
||||
<div class="scroll-wrapper">
|
||||
<div
|
||||
ref="scrollContent"
|
||||
@@ -48,7 +48,7 @@ const emit = defineEmits(["close"]);
|
||||
interface Props {
|
||||
/** 滚动文本内容(必填) */
|
||||
text: string;
|
||||
/** 滚动速度,数值越小滚动越慢 */
|
||||
/** 滚动速度,数值越小滚动越快 */
|
||||
speed?: number;
|
||||
/** 滚动方向:左侧或右侧 */
|
||||
direction?: "left" | "right";
|
||||
@@ -82,7 +82,7 @@ const scrollContent = ref<HTMLElement | null>(null);
|
||||
const animationDuration = ref(0);
|
||||
|
||||
/**
|
||||
* 打字机效果相关状态
|
||||
* 打字机效果相关状态"
|
||||
*/
|
||||
// 当前已显示的文本内容
|
||||
const currentText = ref("");
|
||||
@@ -143,7 +143,7 @@ const handleRightIconClick = () => {
|
||||
emit("close");
|
||||
// 获取当前组件的DOM元素
|
||||
if (containerRef.value) {
|
||||
// 从DOM中移除元素
|
||||
// 从 DOM 中移除元素
|
||||
containerRef.value.remove();
|
||||
}
|
||||
};
|
||||
@@ -155,7 +155,7 @@ const handleRightIconClick = () => {
|
||||
const startTypewriter = () => {
|
||||
let index = 0;
|
||||
currentText.value = "";
|
||||
isTypewriterComplete.value = false; // 重置状态
|
||||
isTypewriterComplete.value = false; // 重置状态"
|
||||
|
||||
// 递归函数,逐字添加文本
|
||||
const type = () => {
|
||||
@@ -166,7 +166,7 @@ const startTypewriter = () => {
|
||||
// 设置下一个字符的延迟
|
||||
typewriterTimer = setTimeout(type, props.typewriterSpeed);
|
||||
} else {
|
||||
// 所有字符都已添加,设置完成状态
|
||||
// 所有字符都已添加,设置完成状态"
|
||||
isTypewriterComplete.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,6 @@ const theneList = [
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
settingsStore.updateTheme(theme);
|
||||
settingsStore.theme = theme;
|
||||
};
|
||||
</script>
|
||||
@@ -53,11 +53,12 @@ import {
|
||||
UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -121,7 +122,7 @@ const modelValue = defineModel("modelValue", {
|
||||
|
||||
const fileList = ref([] as UploadFile[]);
|
||||
|
||||
// 监听 modelValue 转换用于显示的 fileList
|
||||
// 监听 modelValue 转换用于显示<EFBFBD>?fileList
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
@@ -141,7 +142,7 @@ watch(
|
||||
);
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 限制文件大小
|
||||
@@ -185,7 +186,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning(`最多只能上传${props.limit}个文件`);
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 个文件");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +194,7 @@ function handleExceed() {
|
||||
*/
|
||||
const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles) => {
|
||||
ElMessage.success("上传成功");
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完成
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完<EFBFBD>?
|
||||
if (
|
||||
files.every((file: UploadFile) => {
|
||||
return file.status === "success" || file.status === "fail";
|
||||
@@ -202,7 +203,7 @@ const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles
|
||||
const fileInfos = [] as FileInfo[];
|
||||
files.map((file: UploadFile) => {
|
||||
if (file.status === "success") {
|
||||
//只取携带response的才是刚上传的
|
||||
//只取携带response的才是刚上传<EFBFBD>?
|
||||
const res = file.response as FileInfo;
|
||||
if (res) {
|
||||
fileInfos.push({ name: res.name, url: res.url } as FileInfo);
|
||||
@@ -250,7 +251,7 @@ function handleDownload(file: UploadUserFile) {
|
||||
|
||||
/** 获取一个不重复的id */
|
||||
function getUid(): number {
|
||||
// 时间戳左移13位(相当于乘以8192) + 4位随机数
|
||||
// 时间戳左<EFBFBD>?3位(相当于乘<EFBFBD>?192<EFBFBD>?+ 4位随机数
|
||||
return (Date.now() << 13) | Math.floor(Math.random() * 8192);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
@@ -67,7 +68,7 @@ const props = defineProps({
|
||||
default: 10,
|
||||
},
|
||||
/**
|
||||
* 单个文件的最大允许大小
|
||||
* 单个文件的最大允许大<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -78,12 +79,12 @@ const props = defineProps({
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
default: "image/*", // 默认支持所有图片格式 ,如果需要指定格式,格式如下:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
default: "image/*", // 默认支持所有图片格式,如果需要指定格式,格式如下:.png,.jpg,.jpeg,.gif,.bmp
|
||||
},
|
||||
});
|
||||
|
||||
const previewVisible = ref(false); // 是否显示预览
|
||||
const previewImageIndex = ref(0); // 预览图片的索引
|
||||
const previewImageIndex = ref(0); // 预览图片的索<EFBFBD>?
|
||||
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<string[]>,
|
||||
@@ -107,28 +108,28 @@ function handleRemove(imageUrl: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning("最多只能上传" + props.limit + "张图片");
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 张图片");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,11 +33,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -53,7 +54,7 @@ const props = defineProps({
|
||||
default: "file",
|
||||
},
|
||||
/**
|
||||
* 最大文件大小(单位:M)
|
||||
* 最大文件大小(单位:M<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -61,7 +62,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
* 上传图片格式,默认支持所有图<EFBFBD>?image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
@@ -69,7 +70,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样式
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样<EFBFBD>?
|
||||
*/
|
||||
style: {
|
||||
type: Object,
|
||||
@@ -91,25 +92,25 @@ const modelValue = defineModel("modelValue", {
|
||||
* 限制用户上传文件的格式和大小
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
// 检查文件格式是否符<EFBFBD>?accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<!--
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次封装
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次å°<EFBFBD>è£?
|
||||
* 版æ<EFBFBD>ƒæ‰€æœ?© 2021-present 有æ<EFBFBD>¥å¼€æº<EFBFBD>组ç»?
|
||||
*
|
||||
* å¼€æº<EFBFBD>å<EFBFBD><EFBFBD>议:https://opensource.org/licenses/MIT
|
||||
* 项目地å<EFBFBD>€ï¼šhttps://gitee.com/youlaiorg/vue3-element-admin
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
* 在使用时,请ä¿<EFBFBD>ç•™æ¤æ³¨é‡Šï¼Œæ„Ÿè°¢æ‚¨å¯¹å¼€æº<EFBFBD>的支æŒ<EFBFBD>ï¼?
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div style="z-index: 999; border: 1px solid var(--el-border-color)">
|
||||
<!-- 工具栏 -->
|
||||
<!-- 工具æ ?-->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
mode="simple"
|
||||
:default-config="toolbarConfig"
|
||||
style="border-bottom: 1px solid var(--el-border-color)"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<!-- 编辑�-->
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
@@ -51,15 +51,15 @@ const modelValue = defineModel("modelValue", {
|
||||
required: false,
|
||||
});
|
||||
|
||||
// 编辑器实例,必须用 shallowRef,重要!
|
||||
// 编辑器实例,必须ç”?shallowRef,é‡<C3A9>è¦<C3A8>ï¼<C3AF>
|
||||
const editorRef = shallowRef();
|
||||
|
||||
// 工具栏配置
|
||||
// 工具æ <EFBFBD>é…<EFBFBD>ç½?
|
||||
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
|
||||
|
||||
// 编辑器配置
|
||||
// 编辑器é…<EFBFBD>ç½?
|
||||
const editorConfig = ref<Partial<IEditorConfig>>({
|
||||
placeholder: "请输入内容...",
|
||||
placeholder: "请输入内�..",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
customUpload(file: File, insertFn: InsertFnType) {
|
||||
|
||||
Reference in New Issue
Block a user