mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 00:05:17 +00:00
chore: 完成多批次代码优化与重构
- 重构工作流模块目录结构,迁移代码文件 - 修复类型断言空值安全问题,添加 ! 操作符 - 优化样式类名,替换 flex-cc 为标准 flex 工具类 - 更新路由标签简化文案,移除冗余注释 - 调整 ruff 配置,放宽行长度限制 - 更新 README 与多语言文案,优化项目描述 - 修复表单、图表组件的类型与样式问题 - 简化搜索表单、数据卡片的布局代码
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<!-- 卡片横幅组件 -->
|
||||
<template>
|
||||
<div class="fa-card p-5 flex-c flex-col pb-6 h-full" :style="{ height: height }">
|
||||
<div class="flex-c flex-col gap-4 text-center">
|
||||
<div class="fa-card p-5 flex items-center flex-col pb-6 h-full" :style="{ height: height }">
|
||||
<div class="flex items-center flex-col gap-4 text-center">
|
||||
<div class="w-45">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" loading="eager" />
|
||||
</div>
|
||||
@@ -9,10 +9,10 @@
|
||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex-c gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
v-if="cancelButton?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
||||
class="inline-block h-9 px-3 text-sm/9 cursor-pointer select-none rounded-md border border-g-300"
|
||||
:style="{
|
||||
backgroundColor: cancelButton?.color,
|
||||
color: cancelButton?.textColor,
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="button?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
||||
class="inline-block h-9 px-3 text-sm/9 cursor-pointer select-none rounded-md"
|
||||
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- 返回顶部按钮 -->
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="tad-300 ease-out"
|
||||
leave-active-class="tad-200 ease-in"
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<div
|
||||
v-show="showButton"
|
||||
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
|
||||
class="fixed right-10 bottom-15 size-9.5 flex items-center justify-center cursor-pointer border border-g-300 rounded-md transition duration-300 hover:bg-g-200"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<FaSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 系统 logo:优先使用接口 sys_web_logo,缺省用内置图 -->
|
||||
<template>
|
||||
<div class="flex-cc">
|
||||
<div class="flex items-center justify-center">
|
||||
<img
|
||||
:style="logoStyle"
|
||||
:src="resolvedSrc"
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<li>
|
||||
<div>
|
||||
<div class="flex-c">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="size-5 mr-2.5 text-xs font-medium text-white rounded-full flex-cc"
|
||||
class="size-5 mr-2.5 text-xs font-medium text-white rounded-full flex items-center justify-center"
|
||||
:style="{ background: randomColor() }"
|
||||
>
|
||||
{{ comment.author.substring(0, 1) }}
|
||||
@@ -11,10 +11,10 @@
|
||||
<strong class="block text-sm font-medium">{{ comment.author }}</strong>
|
||||
</div>
|
||||
<span class="block mt-2.5 text-sm text-g-700">{{ comment.content }}</span>
|
||||
<div class="flex-c mt-2.5">
|
||||
<div class="flex items-center mt-2.5">
|
||||
<span class="text-xs text-g-700">{{ formatDate(comment.timestamp) }}</span>
|
||||
<div
|
||||
class="ml-5 text-xs text-g-700 c-p select-none hover:text-theme"
|
||||
class="ml-5 text-xs text-g-700 cursor-pointer select-none hover:text-theme"
|
||||
@click="toggleReply(comment.id)"
|
||||
>
|
||||
回复
|
||||
@@ -119,7 +119,7 @@ const randomColor = () => {
|
||||
|
||||
do {
|
||||
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length);
|
||||
newColor = AppConfig.systemMainColor[index];
|
||||
newColor = AppConfig.systemMainColor[index]!;
|
||||
} while (newColor === lastColor);
|
||||
|
||||
lastColor = newColor;
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 柱状图卡片 -->
|
||||
<template>
|
||||
<div class="fa-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||
<div class="mb-5 flex justify-between items-start px-5 pt-5">
|
||||
<div>
|
||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||
{{ value }}
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<div v-for="(item, index) in list" :key="index" class="flex-c py-2">
|
||||
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
|
||||
<div v-for="(item, index) in list" :key="index" class="flex items-center py-2">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="flex items-center justify-center mr-3 size-10 rounded-lg"
|
||||
:class="item.class"
|
||||
>
|
||||
<FaSvgIcon :icon="item.icon" class="text-xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="fa-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="flex box-border h-full p-5 pr-2">
|
||||
<div class="flex w-full items-start gap-5">
|
||||
<div class="flex-b h-full flex-1 flex-col">
|
||||
<div class="flex justify-between h-full flex-1 flex-col">
|
||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||
{{ title }}
|
||||
</p>
|
||||
@@ -20,17 +20,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||
<div v-if="currentValue" class="flex-cc">
|
||||
<div v-if="currentValue" class="flex items-center justify-center">
|
||||
<div class="size-2 bg-theme rounded mr-2"></div>
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div v-if="previousValue" class="flex-cc">
|
||||
<div v-if="previousValue" class="flex items-center justify-center">
|
||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||
{{ previousValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c h-full max-w-40 flex-1">
|
||||
<div class="flex items-center h-full max-w-40 flex-1">
|
||||
<div ref="chartRef" class="h-30 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 图片卡片 -->
|
||||
<template>
|
||||
<div class="w-full c-p h-full" @click="handleClick">
|
||||
<div class="w-full cursor-pointer h-full" @click="handleClick">
|
||||
<div class="fa-card overflow-hidden h-full">
|
||||
<div class="relative w-full aspect-16/10 overflow-hidden">
|
||||
<ElImage
|
||||
@@ -9,7 +9,7 @@
|
||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
||||
<div class="flex items-center justify-center w-full h-full bg-[#f5f7fa]">
|
||||
<ElIcon><Picture /></ElIcon>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,12 +30,12 @@
|
||||
{{ props.category }}
|
||||
</div>
|
||||
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
||||
<div class="flex-c gap-4 text-xs text-g-600">
|
||||
<span class="flex-c gap-1" v-if="props.views">
|
||||
<div class="flex items-center gap-4 text-xs text-g-600">
|
||||
<span class="flex items-center gap-1" v-if="props.views">
|
||||
<ElIcon class="text-base"><View /></ElIcon>
|
||||
{{ props.views }}
|
||||
</span>
|
||||
<span class="flex-c gap-1" v-if="props.comments">
|
||||
<span class="flex items-center gap-1" v-if="props.comments">
|
||||
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
||||
{{ props.comments }}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 折线图卡片 -->
|
||||
<template>
|
||||
<div class="fa-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-2.5 flex-b items-start p-5">
|
||||
<div class="mb-2.5 flex justify-between items-start p-5">
|
||||
<div>
|
||||
<p class="text-2xl font-medium leading-none">
|
||||
{{ value }}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!-- 进度条卡片 -->
|
||||
<template>
|
||||
<div class="fa-card h-32 flex flex-col justify-center px-5">
|
||||
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
|
||||
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
||||
<div
|
||||
class="mb-3.5 flex items-center"
|
||||
:style="{ justifyContent: icon ? 'space-between' : 'flex-start' }"
|
||||
>
|
||||
<div
|
||||
v-if="icon"
|
||||
class="size-11 flex items-center justify-center bg-g-300 text-xl rounded-lg"
|
||||
:class="iconStyle"
|
||||
>
|
||||
<FaSvgIcon :icon="icon" class="text-2xl"></FaSvgIcon>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<!-- 统计卡片 -->
|
||||
<template>
|
||||
<div
|
||||
class="fa-card h-30 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
class="fa-card h-30 flex items-center px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="boxStyle"
|
||||
>
|
||||
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
|
||||
<div
|
||||
v-if="icon"
|
||||
class="mr-4 size-11 flex items-center justify-center rounded-lg text-xl text-white"
|
||||
:class="iconStyle"
|
||||
>
|
||||
<FaSvgIcon :icon="icon"></FaSvgIcon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
:color="item.status"
|
||||
:center="true"
|
||||
>
|
||||
<div class="flex-c gap-3">
|
||||
<div class="flex-c gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">{{ item.content }}</span>
|
||||
<span v-if="item.code" class="text-sm text-theme">#{{ item.code }}</span>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@ const {
|
||||
type: "cross",
|
||||
},
|
||||
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
||||
const param = params[0];
|
||||
const param = params[0]!;
|
||||
const data = param.data;
|
||||
return `
|
||||
<div :style="'padding: 5px;'">
|
||||
|
||||
@@ -108,7 +108,7 @@ const primaryColor = computed(() => getCssVar("--el-color-primary"));
|
||||
|
||||
const getColor = (customColor?: string, index?: number): string => {
|
||||
if (customColor) return customColor;
|
||||
if (index !== undefined) return props.colors![index % props.colors!.length];
|
||||
if (index !== undefined) return props.colors![index % props.colors!.length]!;
|
||||
return primaryColor.value;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="relative w-full h-100 p-5 mb-5 max-sm:mb-4"
|
||||
:style="{ height: 'calc(100vh - 180px)' }"
|
||||
>
|
||||
<div v-if="isEmpty" class="h-full flex-cc">
|
||||
<div v-if="isEmpty" class="h-full flex items-center justify-center">
|
||||
<ElEmpty description="暂无地图数据" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex-c gap-2" :style="{ color: item.color }">
|
||||
<div class="flex items-center gap-2" :style="{ color: item.color }">
|
||||
<FaSvgIcon v-if="item.icon" :icon="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle transition-all duration-200 hover-btn',
|
||||
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm cursor-pointer rounded-md align-middle transition-all duration-200 hover-btn',
|
||||
buttonClass,
|
||||
]"
|
||||
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
||||
|
||||
@@ -216,7 +216,7 @@ const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] =
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const sampleSize = Math.min(data.length, 100); // 只取前100行计算列宽
|
||||
const columns = Object.keys(data[0]);
|
||||
const columns = Object.keys(data[0]!);
|
||||
|
||||
return columns.map((column) => {
|
||||
// 使用配置的列宽度
|
||||
@@ -514,7 +514,6 @@ const handleImportError = (error: Error) => {
|
||||
* 处理 Excel 导出成功
|
||||
*/
|
||||
const handleExportSuccess = () => {
|
||||
console.log("导出成功");
|
||||
ElMessage.success("Excel 导出成功");
|
||||
};
|
||||
|
||||
@@ -531,7 +530,7 @@ const handleExportError = (error: Error) => {
|
||||
* @param progress 导出进度百分比
|
||||
*/
|
||||
const handleProgress = (progress: number) => {
|
||||
console.log("导出进度:", progress);
|
||||
// 进度由进度条 UI 实时显示
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,8 +48,8 @@ async function importExcel(file: File): Promise<Array<Record<string, unknown>>>
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
const workbook = XLSX.read(data, { type: "array" });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const firstSheetName = workbook.SheetNames[0]!;
|
||||
const worksheet = workbook.Sheets[firstSheetName]!;
|
||||
const results = XLSX.utils.sheet_to_json(worksheet);
|
||||
resolve(results as Array<Record<string, unknown>>);
|
||||
} catch (error) {
|
||||
@@ -206,7 +206,6 @@ const handleImportError = (error: Error) => {
|
||||
* 处理 Excel 导出成功
|
||||
*/
|
||||
const handleExportSuccess = () => {
|
||||
console.log("导出成功");
|
||||
ElMessage.success("Excel 导出成功");
|
||||
};
|
||||
|
||||
@@ -223,7 +222,7 @@ const handleExportError = (error: Error) => {
|
||||
* @param progress 导出进度百分比
|
||||
*/
|
||||
const handleProgress = (progress: number) => {
|
||||
console.log("导出进度:", progress);
|
||||
// 进度由进度条 UI 实时显示
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
class="mb-3 flex items-center flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
@@ -163,7 +163,7 @@
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
class="mb-3 flex items-center flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ElDatePicker } from "element-plus";
|
||||
|
||||
type DatePickerType =
|
||||
| "year"
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<!-- 在 FaSearchBar 上追加「创建人 / 更新人 / 创建时间 / 更新时间」,并内置 UserTableSelect 插槽;业务页只传自己的 items 即可 -->
|
||||
<template>
|
||||
<FaSearchBar
|
||||
ref="innerRef"
|
||||
v-model="modelValue"
|
||||
v-bind="forwardedAttrs"
|
||||
:items="mergedItems"
|
||||
@search="(p) => emit('search', p)"
|
||||
@reset="emit('reset')"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="scope">
|
||||
<slot :name="name" v-bind="scope || {}" />
|
||||
</template>
|
||||
<template v-if="!$slots.created_id" #created_id>
|
||||
<div class="w-full min-w-0">
|
||||
<FaUserTableSelect
|
||||
:model-value="modelValue?.created_id == null ? undefined : modelValue.created_id"
|
||||
@update:model-value="(v: number | undefined) => patchField('created_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!$slots.updated_id" #updated_id>
|
||||
<div class="w-full min-w-0">
|
||||
<FaUserTableSelect
|
||||
:model-value="modelValue?.updated_id == null ? undefined : modelValue.updated_id"
|
||||
@update:model-value="(v: number | undefined) => patchField('updated_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { computed, ref, useAttrs } from "vue";
|
||||
import FaSearchBar from "./index.vue";
|
||||
import type { SearchFormItem } from "./index.vue";
|
||||
import {
|
||||
getAuditSearchFormItems,
|
||||
type GetAuditSearchFormItemsOptions,
|
||||
} from "./auditSearchFormItems";
|
||||
|
||||
defineOptions({ name: "FaSearchBarWithAudit", inheritAttrs: false });
|
||||
|
||||
interface Props {
|
||||
/** 仅业务条件表单项,不含审计四字段 */
|
||||
items: SearchFormItem[];
|
||||
/** 为 false 时与原生 FaSearchBar 一致,仅使用 `items` */
|
||||
includeAudit?: boolean;
|
||||
/** 传给 getAuditSearchFormItems 的选项 */
|
||||
auditItemOptions?: GetAuditSearchFormItemsOptions;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { includeAudit: true });
|
||||
|
||||
interface Emits {
|
||||
search: [Record<string, any>];
|
||||
reset: [];
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const modelValue = defineModel<Record<string, any>>({ default: () => ({}) });
|
||||
const attrs = useAttrs();
|
||||
// @ts-expect-error 循环类型引用问题
|
||||
const innerRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const forwardedAttrs = computed(() => attrs as Record<string, unknown>);
|
||||
const auditItems = computed(() => getAuditSearchFormItems(props.auditItemOptions));
|
||||
const mergedItems = computed(() => {
|
||||
if (!props.includeAudit) return props.items;
|
||||
return [...props.items, ...auditItems.value];
|
||||
});
|
||||
|
||||
function patchField(key: "created_id" | "updated_id", val: number | undefined) {
|
||||
modelValue.value = { ...modelValue.value, [key]: val };
|
||||
}
|
||||
|
||||
function emitImmediateSearch() {
|
||||
emit("search", { ...modelValue.value });
|
||||
}
|
||||
|
||||
// @ts-expect-error 循环类型引用问题
|
||||
defineExpose({
|
||||
validate: (...args: any[]) => innerRef.value?.validate?.(...args),
|
||||
reset: () => innerRef.value?.reset?.(),
|
||||
getOutput: () => innerRef.value?.getOutput?.(),
|
||||
ref: computed(() => innerRef.value?.ref) as unknown as any,
|
||||
});
|
||||
</script>
|
||||
@@ -105,7 +105,7 @@ interface IUser {
|
||||
gender?: string;
|
||||
avatar?: string;
|
||||
email?: string | null;
|
||||
status?: string;
|
||||
status?: number;
|
||||
dept_name?: string;
|
||||
role_names?: string[];
|
||||
created_time?: string;
|
||||
|
||||
@@ -26,7 +26,8 @@ export interface GetAuditSearchFormItemsOptions {
|
||||
|
||||
/**
|
||||
* 常见「创建人 / 更新人 / 创建时间 / 更新时间」搜索项,供 FaSearchBar `items` 使用。
|
||||
* 创建人、更新人需配合 `#created_id`、`#updated_id` 插槽(如 FaSearchBarWithAudit)。
|
||||
* 当 FaSearchBar 设置 `includeAudit` 为 true 时,会自动追加这四个字段。
|
||||
* 创建人、更新人插槽由 FaSearchBar 内置提供,也可通过 `#created_id`、`#updated_id` 覆盖。
|
||||
*/
|
||||
export function getAuditSearchFormItems(
|
||||
options?: GetAuditSearchFormItemsOptions
|
||||
|
||||
@@ -27,46 +27,70 @@
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
:model-value="getFieldValue(item.key)"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 创建人插槽 -->
|
||||
<template v-if="item.key === 'created_id' && !$slots.created_id">
|
||||
<div class="w-full min-w-0">
|
||||
<FaUserTableSelect
|
||||
:model-value="modelValue?.created_id == null ? undefined : modelValue.created_id"
|
||||
@update:model-value="(v: number | undefined) => patchAuditField('created_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 更新人插槽 -->
|
||||
<template v-else-if="item.key === 'updated_id' && !$slots.updated_id">
|
||||
<div class="w-full min-w-0">
|
||||
<FaUserTableSelect
|
||||
:model-value="modelValue?.updated_id == null ? undefined : modelValue.updated_id"
|
||||
@update:model-value="(v: number | undefined) => patchAuditField('updated_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
:model-value="getFieldValue(item.key)"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</template>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||
@@ -114,6 +138,11 @@ import { useWindowSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toRaw, type Component } from "vue";
|
||||
import FaDatePicker from "@/components/forms/fa-search-bar/FaDatePicker.vue";
|
||||
import FaUserTableSelect from "./FaUserTableSelect.vue";
|
||||
import {
|
||||
getAuditSearchFormItems,
|
||||
type GetAuditSearchFormItemsOptions,
|
||||
} from "./auditSearchFormItems";
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
@@ -217,6 +246,10 @@ interface Props {
|
||||
disabledSearch?: boolean;
|
||||
/** 搜索时是否清洗空值 */
|
||||
sanitizeOutput?: Partial<SanitizeOutputOptions>;
|
||||
/** 是否自动追加审计四字段(创建人/更新人/创建时间/更新时间) */
|
||||
includeAudit?: boolean;
|
||||
/** 传给 getAuditSearchFormItems 的选项 */
|
||||
auditItemOptions?: GetAuditSearchFormItemsOptions;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
@@ -248,6 +281,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showSearch: true,
|
||||
disabledSearch: false,
|
||||
sanitizeOutput: () => ({}),
|
||||
includeAudit: false,
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
@@ -260,6 +294,15 @@ const emit = defineEmits<Emits>();
|
||||
const modelValue = defineModel<Record<string, any>>({ default: {} });
|
||||
const initialModelValue = ref<Record<string, any>>({});
|
||||
|
||||
// 审计字段配置
|
||||
const auditItems = computed(() => getAuditSearchFormItems(props.auditItemOptions));
|
||||
|
||||
// 合并业务字段和审计字段
|
||||
const mergedItems = computed(() => {
|
||||
if (!props.includeAudit) return props.items;
|
||||
return [...props.items, ...auditItems.value];
|
||||
});
|
||||
|
||||
// 保存组件初始化时的表单快照,用于 reset 时恢复默认筛选条件。
|
||||
const cloneModelValue = (value: Record<string, any> | undefined) => {
|
||||
if (!value) return {};
|
||||
@@ -426,11 +469,22 @@ const getComponent = (item: SearchFormItem) => {
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新审计字段并立即触发搜索
|
||||
*/
|
||||
const patchAuditField = (key: "created_id" | "updated_id", val: number | undefined) => {
|
||||
modelValue.value = { ...modelValue.value, [key]: val };
|
||||
};
|
||||
|
||||
const emitImmediateSearch = () => {
|
||||
emit("search", getSanitizedOutput());
|
||||
};
|
||||
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden);
|
||||
const filteredItems = mergedItems.value.filter((item) => !item.hidden);
|
||||
const shouldShowLess = !props.isExpand && !isExpanded.value;
|
||||
if (shouldShowLess) {
|
||||
const maxItemsPerRow = Math.floor(24 / props.span) - 1;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@wangeditor-next/editor/dist/css/style.css";
|
||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from "vue";
|
||||
import { onBeforeUnmount, onMounted, onUnmounted, shallowRef, computed } from "vue";
|
||||
import { Editor, Toolbar } from "@wangeditor-next/editor-for-vue";
|
||||
import { useUserStore } from "@stores";
|
||||
import { request, EmojiText } from "@utils";
|
||||
@@ -185,17 +185,21 @@ const onCreateEditor = (editor: IDomEditor) => {
|
||||
};
|
||||
|
||||
// 应用自定义图标(带重试机制)
|
||||
//
|
||||
// 注意:递归 setTimeout 用于等待 wangEditor 工具栏 DOM 渲染完成。
|
||||
// 组件卸载时必须清理,避免定时器在编辑器实例已销毁后继续执行。
|
||||
const applyCustomIcons = () => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 100;
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tryApplyIcons = () => {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
timerId = setTimeout(tryApplyIcons, retryDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -205,7 +209,7 @@ const applyCustomIcons = () => {
|
||||
if (!editorContainer) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
timerId = setTimeout(tryApplyIcons, retryDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +224,7 @@ const applyCustomIcons = () => {
|
||||
// 如果工具栏还没渲染完成,继续重试
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
timerId = setTimeout(tryApplyIcons, retryDelay);
|
||||
} else {
|
||||
console.warn("工具栏渲染超时,无法应用自定义图标 - 编辑器实例:", editor.id);
|
||||
}
|
||||
@@ -228,6 +232,14 @@ const applyCustomIcons = () => {
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||
requestAnimationFrame(tryApplyIcons);
|
||||
|
||||
// 组件卸载时清理挂起的重试定时器
|
||||
onUnmounted(() => {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 暴露编辑器实例和方法
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
.app-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--default-bg-color);
|
||||
|
||||
#app-sidebar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#app-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
|
||||
#app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
|
||||
:deep(.layout-content) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: auto;
|
||||
|
||||
// 子页面默认 style(勿用 overflow:hidden:在 flex 主区内会裁切长页导致无法滚动)
|
||||
.page-content {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
overflow: hidden auto;
|
||||
background: var(--default-box-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1180px) {
|
||||
.app-layout {
|
||||
#app-main {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 800px) {
|
||||
.app-layout {
|
||||
position: relative;
|
||||
|
||||
#app-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app-main {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
|
||||
#app-content {
|
||||
:deep(.layout-content) {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 640px) {
|
||||
.app-layout {
|
||||
#app-main {
|
||||
#app-content {
|
||||
:deep(.layout-content) {
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<!-- 面包屑导航 -->
|
||||
<template>
|
||||
<nav class="ml-2.5 max-lg:hidden!" aria-label="breadcrumb">
|
||||
<ul class="flex-c h-full">
|
||||
<ul class="flex items-center h-full">
|
||||
<li
|
||||
v-for="(item, index) in breadcrumbItems"
|
||||
:key="item.path"
|
||||
class="box-border flex-c h-7 text-sm leading-7"
|
||||
class="box-border flex items-center h-7 text-sm leading-7"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
isClickable(item, index)
|
||||
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
||||
? 'cursor-pointer py-1 rounded transition duration-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
||||
: ''
|
||||
"
|
||||
@click="handleBreadcrumbClick(item, index)"
|
||||
@@ -58,16 +58,16 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
||||
if (!matchedLength) {
|
||||
return [];
|
||||
}
|
||||
const leafRoute = matched[matchedLength - 1];
|
||||
const leafRoute = matched[matchedLength - 1]!;
|
||||
if (leafRoute.name === "Home") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理一级菜单和普通路由
|
||||
const firstRoute = matched[0];
|
||||
const firstRoute = matched[0]!;
|
||||
const isFirstLevel = firstRoute.meta?.isFirstLevel;
|
||||
const lastIndex = matchedLength - 1;
|
||||
const currentRoute = matched[lastIndex];
|
||||
const currentRoute = matched[lastIndex]!;
|
||||
const currentRouteMeta = currentRoute.meta;
|
||||
|
||||
let items = isFirstLevel
|
||||
@@ -75,7 +75,8 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
||||
: matched.map(createBreadcrumbItem);
|
||||
|
||||
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
|
||||
if (items.length > 1 && isWrapperContainer(items[0])) {
|
||||
const firstItem = items[0];
|
||||
if (items.length > 1 && firstItem && isWrapperContainer(firstItem)) {
|
||||
items = items.slice(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
|
||||
<div class="mb-5 flex-cb">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="mt-1.5 flex-c gap-1">
|
||||
<div class="mt-1.5 flex items-center gap-1">
|
||||
<div class="h-2 w-2 rounded-full" :class="isOnline ? 'bg-success' : 'bg-danger'"></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElIcon class="c-p" :size="20" @click="closeChat">
|
||||
<ElIcon class="cursor-pointer" :size="20" @click="closeChat">
|
||||
<Close />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-[calc(100%-70px)] flex-col">
|
||||
<!-- 聊天消息区域 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:w-1!"
|
||||
<ElScrollbar
|
||||
ref="messageContainer"
|
||||
class="flex-1 border-t border-(--default-border) px-4 py-7.5"
|
||||
>
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="px-4 pt-4">
|
||||
@@ -73,10 +73,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="mt-3 flex-cb">
|
||||
<div class="flex-c">
|
||||
<FaSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
<FaSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
|
||||
<FaSvgIcon
|
||||
icon="ri:emotion-happy-line"
|
||||
class="mr-5 cursor-pointer text-g-600 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
|
||||
</div>
|
||||
@@ -88,6 +91,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, Paperclip, Close } from "@element-plus/icons-vue";
|
||||
import { ElScrollbar } from "element-plus";
|
||||
import { mittBus } from "@utils";
|
||||
import meAvatar from "@imgs/avatar/avatar5.webp";
|
||||
import aiAvatar from "@imgs/avatar/avatar10.webp";
|
||||
@@ -121,7 +125,7 @@ const isOnline = ref(true);
|
||||
// 消息相关状态
|
||||
const messageText = ref("");
|
||||
const messageId = ref(10);
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
const messageContainer = ref<InstanceType<typeof ElScrollbar> | null>(null);
|
||||
|
||||
// 初始化聊天消息数据
|
||||
const initializeMessages = (): ChatMessage[] => [
|
||||
@@ -212,9 +216,8 @@ const formatCurrentTime = (): string => {
|
||||
const scrollToBottom = (): void => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
||||
}
|
||||
const wrap = messageContainer.value?.wrapRef;
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}, SCROLL_DELAY);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="flex-c gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,10 +26,12 @@
|
||||
<div
|
||||
v-for="application in enabledApplications"
|
||||
:key="application.name"
|
||||
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:bg-transparent!"
|
||||
class="mr-3 cursor-pointer flex items-center gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:bg-transparent!"
|
||||
@click="handleApplicationClick(application)"
|
||||
>
|
||||
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
|
||||
<div
|
||||
class="app-icon size-12 flex items-center justify-center rounded-lg bg-g-200/80 dark:bg-g-300/30"
|
||||
>
|
||||
<FaSvgIcon
|
||||
class="text-xl"
|
||||
:icon="application.icon"
|
||||
@@ -44,13 +46,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-l-d pl-6 pt-2">
|
||||
<div class="border-l border-[var(--default-border)] pl-6 pt-2">
|
||||
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
|
||||
<ul>
|
||||
<li
|
||||
v-for="quickLink in enabledQuickLinks"
|
||||
:key="quickLink.name"
|
||||
class="c-p py-2 hover:[&_span]:text-theme"
|
||||
class="cursor-pointer py-2 hover:[&_span]:text-theme"
|
||||
@click="handleQuickLinkClick(quickLink)"
|
||||
>
|
||||
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
|
||||
@@ -69,7 +71,9 @@
|
||||
>
|
||||
<div class="px-2">
|
||||
<div class="mb-5 flex items-start gap-4">
|
||||
<div class="flex-cc size-14 rounded-xl bg-linear-to-br from-purple-400 to-indigo-500">
|
||||
<div
|
||||
class="flex items-center justify-center size-14 rounded-xl bg-linear-to-br from-purple-400 to-indigo-500"
|
||||
>
|
||||
<FaSvgIcon icon="ri:loader-line" class="text-2xl text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -249,7 +249,7 @@ class FireworkSystem {
|
||||
private getAvailableParticle(): Firework | null {
|
||||
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
|
||||
const index = (this.poolIndex + i) % CONFIG.POOL_SIZE;
|
||||
const particle = this.particlePool[index];
|
||||
const particle = this.particlePool[index]!;
|
||||
|
||||
if (!particle.active) {
|
||||
this.poolIndex = (index + 1) % CONFIG.POOL_SIZE;
|
||||
@@ -330,13 +330,15 @@ class FireworkSystem {
|
||||
// 复杂的速度计算,模拟真实烟花爆炸轨迹
|
||||
particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5);
|
||||
particle.vy = Math.sin(angle) * speed - 15; // 向上初始速度
|
||||
particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)];
|
||||
const colors = CONFIG.COLORS;
|
||||
const shapes = availableShapes;
|
||||
particle.color = colors[Math.floor(Math.random() * colors.length)] ?? colors[0]!;
|
||||
particle.rotation = Math.random() * 360;
|
||||
particle.rotationSpeed =
|
||||
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
|
||||
(Math.random() > 0.5 ? 1 : -1); // 随机旋转方向
|
||||
particle.scale = 0.8 + Math.random() * 0.4; // 随机缩放
|
||||
particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)];
|
||||
particle.shape = shapes[Math.floor(Math.random() * shapes.length)] ?? shapes[0]!;
|
||||
particle.opacity = 1;
|
||||
particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : undefined;
|
||||
|
||||
@@ -357,7 +359,7 @@ class FireworkSystem {
|
||||
|
||||
// 使用倒序遍历,避免删除元素时的索引混乱问题
|
||||
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
|
||||
const particle = this.activeParticles[i];
|
||||
const particle = this.activeParticles[i]!;
|
||||
|
||||
// 更新粒子位置 (匀加速运动)
|
||||
particle.x += particle.vx;
|
||||
@@ -389,7 +391,7 @@ class FireworkSystem {
|
||||
* @param index 要回收的粒子在活动数组中的索引
|
||||
*/
|
||||
private recycleParticle(index: number): void {
|
||||
const particle = this.activeParticles[index];
|
||||
const particle = this.activeParticles[index]!;
|
||||
particle.active = false; // 标记为非活动状态
|
||||
this.activeParticles.splice(index, 1); // 从活动数组中移除
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
>
|
||||
<template #suffix>
|
||||
<div
|
||||
class="h-4.5 flex-cc rounded border border-g-300 dark:bg-g-200/50! bg-box! px-1.5 text-g-500"
|
||||
class="h-4.5 flex items-center justify-center rounded border border-g-300 dark:bg-g-200/50! bg-box! px-1.5 text-g-500"
|
||||
>
|
||||
<FaSvgIcon icon="fluent:arrow-enter-left-20-filled" />
|
||||
</div>
|
||||
@@ -29,12 +29,12 @@
|
||||
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
|
||||
<div class="result w-full" v-show="searchResult.length">
|
||||
<div
|
||||
class="box mt-0! c-p text-base leading-none"
|
||||
class="box mt-0! cursor-pointer text-base leading-none"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
|
||||
class="mt-2 h-12 flex items-center justify-between rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
|
||||
:class="isHighlighted(index) ? 'highlighted bg-theme/70! text-white!' : ''"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHover(index)"
|
||||
@@ -49,7 +49,7 @@
|
||||
<p class="text-xs text-g-500">{{ $t("search.historyTitle") }}</p>
|
||||
<div class="mt-1.5 w-full">
|
||||
<div
|
||||
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
|
||||
class="box mt-2 h-12 cursor-pointer flex items-center justify-between rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
|
||||
v-for="(item, index) in historyResult"
|
||||
:key="index"
|
||||
:class="
|
||||
@@ -62,7 +62,7 @@
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<div
|
||||
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
|
||||
class="size-5 selected-icon select-none rounded-full text-g-500 flex items-center justify-center cursor-pointer"
|
||||
@click.stop="deleteHistory(index)"
|
||||
>
|
||||
<FaSvgIcon icon="ri:close-large-fill" class="text-xs" />
|
||||
@@ -73,15 +73,17 @@
|
||||
</ElScrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
|
||||
<div class="flex-cc">
|
||||
<div
|
||||
class="dialog-footer box-border flex items-center border-t border-(--default-border) pt-4.5 pb-1"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<FaSvgIcon
|
||||
icon="fluent:arrow-enter-left-20-filled"
|
||||
class="mr-2 box-border h-5 w-5.5 rounded border border-g-400 px-1 text-g-500 shadow-[0_2px_0_var(--default-border-dashed)]"
|
||||
/>
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.selectKeydown") }}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<div class="flex items-center">
|
||||
<FaSvgIcon
|
||||
icon="ri:arrow-up-wide-fill"
|
||||
class="mr-2 box-border h-5 w-5.5 rounded border border-g-400 px-1 text-g-500 shadow-[0_2px_0_var(--default-border-dashed)]"
|
||||
@@ -92,9 +94,9 @@
|
||||
/>
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.switchKeydown") }}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<div class="flex items-center">
|
||||
<i
|
||||
class="mr-2 box-border h-5 rounded border border-g-400 px-1 text-g-500 shadow-[0_2px_0_var(--default-border-dashed)] w-8! flex-cc"
|
||||
class="mr-2 box-border h-5 rounded border border-g-400 px-1 text-g-500 shadow-[0_2px_0_var(--default-border-dashed)] w-8! flex items-center justify-center"
|
||||
><p class="text-[10px] font-medium">ESC</p></i
|
||||
>
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.exitKeydown") }}</span>
|
||||
@@ -111,7 +113,6 @@ import { Search } from "@element-plus/icons-vue";
|
||||
import { mittBus, formatMenuTitle, handleMenuJump } from "@utils";
|
||||
import { useUserStore, useMenuStore } from "@stores";
|
||||
import { type ScrollbarInstance } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaGlobalSearch" });
|
||||
|
||||
const userStore = useUserStore();
|
||||
@@ -295,9 +296,11 @@ const scrollToHighlightedHistoryItem = () => {
|
||||
|
||||
const selectHighlighted = () => {
|
||||
if (searchVal.value && searchResult.value.length) {
|
||||
searchGoPage(searchResult.value[highlightedIndex.value]);
|
||||
const target = searchResult.value[highlightedIndex.value];
|
||||
if (target) searchGoPage(target);
|
||||
} else if (!searchVal.value && historyResult.value.length) {
|
||||
searchGoPage(historyResult.value[historyHIndex.value]);
|
||||
const target = historyResult.value[historyHIndex.value];
|
||||
if (target) searchGoPage(target);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,16 +9,20 @@
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="relative box-border flex-b h-15 leading-15 select-none"
|
||||
class="relative box-border flex justify-between h-15 leading-15 select-none"
|
||||
:class="[
|
||||
tabStyle === 'tab-card' || tabStyle === 'tab-google' || tabStyle === 'tab-default'
|
||||
? 'border-b border-(--fa-card-border)'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<div class="flex-c flex-1 min-w-0 leading-15" :style="{ display: 'flex' }">
|
||||
<div class="flex items-center flex-1 min-w-0 leading-15" :style="{ display: 'flex' }">
|
||||
<!-- 系统信息:Logo + 标题一并受「显示应用 Logo」控制 -->
|
||||
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu && showAppLogo">
|
||||
<div
|
||||
class="flex items-center cursor-pointer"
|
||||
@click="toHome"
|
||||
v-if="isTopMenu && showAppLogo"
|
||||
>
|
||||
<FaLogo class="pl-4.5" :src="headerLogoSrc" />
|
||||
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ headerSystemName }}</p>
|
||||
</div>
|
||||
@@ -64,18 +68,18 @@
|
||||
<FaMixedMenu v-if="isTopLeftMenu" :list="menuList" />
|
||||
</div>
|
||||
|
||||
<div id="app-header-toolbar" class="flex-c gap-2.5">
|
||||
<div id="app-header-toolbar" class="flex items-center gap-2.5">
|
||||
<!-- 搜索 -->
|
||||
<div
|
||||
v-if="shouldShowGlobalSearch"
|
||||
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:hidden! tad-300 hover:-translate-y-0.5 hover:shadow-md"
|
||||
class="flex items-center justify-between w-40 h-9 px-2.5 cursor-pointer border border-g-400 rounded-custom-sm max-md:hidden! transition duration-300 hover:-translate-y-0.5 hover:shadow-md"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<div class="flex-c">
|
||||
<div class="flex items-center">
|
||||
<FaSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
|
||||
<span class="ml-1 text-xs font-normal text-g-500">{{ $t("topBar.search.title") }}</span>
|
||||
</div>
|
||||
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
||||
<div class="flex items-center h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
||||
<FaSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
||||
<FaSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
||||
<span class="ml-0.5 text-xs">k</span>
|
||||
@@ -92,7 +96,10 @@
|
||||
/>
|
||||
|
||||
<!-- 组件尺寸 default/large/small(沿用旧版持久化开关 showSizeSelect) -->
|
||||
<div v-if="shouldShowSizeSelect" class="flex-cc ml-1 max-md:hidden!">
|
||||
<div
|
||||
v-if="shouldShowSizeSelect"
|
||||
class="flex items-center justify-center ml-1 max-md:hidden!"
|
||||
>
|
||||
<FaSizeSelect />
|
||||
</div>
|
||||
|
||||
@@ -142,7 +149,7 @@
|
||||
<div v-if="shouldShowSettings">
|
||||
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
|
||||
<template #reference>
|
||||
<div class="flex-cc">
|
||||
<div class="flex items-center justify-center">
|
||||
<FaIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -189,7 +196,7 @@ import { languageOptions } from "@/locales";
|
||||
import { mittBus, themeAnimation } from "@utils";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
|
||||
import FaUserMenu from "./widget/FaUserMenu.vue";
|
||||
import FaUserMenu from "./widgets/FaUserMenu.vue";
|
||||
|
||||
defineOptions({ name: "FaHeaderBar" });
|
||||
|
||||
|
||||
+14
-16
@@ -13,7 +13,7 @@
|
||||
<!-- 系统配置 -->
|
||||
<ElDivider>接口白名单</ElDivider>
|
||||
<div v-for="(item, key) in apiWhitelistConfigs" :key="key">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<ElFormItem :label="item?.config_name">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in apiWhitelistItems"
|
||||
@@ -63,7 +63,7 @@
|
||||
<!-- 系统配置 -->
|
||||
<ElDivider>IP黑名单</ElDivider>
|
||||
<div v-for="(item, key) in ipBlacklistConfigs" :key="key">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<ElFormItem :label="item?.config_name">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in ipBlacklistItems"
|
||||
@@ -114,17 +114,17 @@
|
||||
<!-- 系统配置 -->
|
||||
<ElDivider>演示环境配置</ElDivider>
|
||||
<div v-for="(item, key) in demoConfigs" :key="key">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<ElFormItem :label="item?.config_name">
|
||||
<!-- 演示模式开关 -->
|
||||
<template v-if="key === 'demo_enable'">
|
||||
<ElSwitch
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
:model-value="item.config_value === 'true'"
|
||||
:model-value="item?.config_value === 'true'"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
item.config_value = value ? 'true' : 'false';
|
||||
item!.config_value = value ? 'true' : 'false';
|
||||
markModified(key);
|
||||
}
|
||||
"
|
||||
@@ -180,7 +180,7 @@
|
||||
<!-- 其他配置项 -->
|
||||
<template v-else>
|
||||
<ElInput
|
||||
v-model="item.config_value"
|
||||
v-model="item!.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
:style="'width: 100%'"
|
||||
@@ -370,12 +370,10 @@ const resetForm = async () => {
|
||||
// 重置其他配置项
|
||||
const keysToReset = Object.keys(modifiedFields);
|
||||
for (const key of keysToReset) {
|
||||
if (
|
||||
key !== "ip_white_list" &&
|
||||
configStore.configData[key as keyof typeof configStore.configData]
|
||||
) {
|
||||
(configStore.configData as Record<string, ConfigTable>)[key as string].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || "";
|
||||
const config = configStore.configData[key as keyof typeof configStore.configData];
|
||||
if (key !== "ip_white_list" && config) {
|
||||
(configStore.configData as Record<string, ConfigTable>)[key as string]!.config_value =
|
||||
config.config_value || "";
|
||||
}
|
||||
delete modifiedFields[key];
|
||||
}
|
||||
@@ -536,18 +534,18 @@ const removeDemoIpWhitelistItem = (id: string) => {
|
||||
|
||||
// 接口白名单配置项
|
||||
const apiWhitelistConfigs = computed(() => ({
|
||||
white_api_list_path: configStore.configData.white_api_list_path,
|
||||
white_api_list_path: configStore.configData.white_api_list_path as ConfigTable | undefined,
|
||||
}));
|
||||
|
||||
// IP黑名单配置项
|
||||
const ipBlacklistConfigs = computed(() => ({
|
||||
ip_black_list: configStore.configData.ip_black_list,
|
||||
ip_black_list: configStore.configData.ip_black_list as ConfigTable | undefined,
|
||||
}));
|
||||
|
||||
// 演示环境配置项
|
||||
const demoConfigs = computed(() => ({
|
||||
demo_enable: configStore.configData.demo_enable,
|
||||
ip_white_list: configStore.configData.ip_white_list,
|
||||
demo_enable: configStore.configData.demo_enable as ConfigTable | undefined,
|
||||
ip_white_list: configStore.configData.ip_white_list as ConfigTable | undefined,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
+4
-4
@@ -15,7 +15,7 @@
|
||||
>
|
||||
<template #reference>
|
||||
<div
|
||||
class="fa-user-menu__avatar-ref mr-5 max-sm:mr-[16px] c-p flex size-8.5 max-sm:w-6.5 max-sm:h-6.5 shrink-0 items-center justify-center"
|
||||
class="fa-user-menu__avatar-ref mr-5 max-sm:mr-[16px] cursor-pointer flex size-8.5 max-sm:w-6.5 max-sm:h-6.5 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
@@ -35,7 +35,7 @@
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3">
|
||||
<div class="flex-c pb-1 px-0">
|
||||
<div class="flex items-center pb-1 px-0">
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left object-cover"
|
||||
@@ -63,7 +63,7 @@
|
||||
<span class="text-xs text-g-500 mb-2 block w-full">当前租户</span>
|
||||
<ElDropdown trigger="click" @command="handleTenantSwitch">
|
||||
<span
|
||||
class="flex-c c-p w-full text-sm font-medium text-(--el-color-primary) hover:underline"
|
||||
class="flex items-center cursor-pointer w-full text-sm font-medium text-(--el-color-primary) hover:underline"
|
||||
>
|
||||
{{ currentTenantName }}
|
||||
<FaSvgIcon icon="ri:arrow-down-s-line" class="ml-1 text-xs" />
|
||||
@@ -78,7 +78,7 @@
|
||||
'text-(--el-color-primary) font-medium': t.id === currentTenant?.id,
|
||||
}"
|
||||
>
|
||||
<span class="flex-c justify-between gap-4">
|
||||
<span class="flex items-center justify-between gap-4">
|
||||
<span>{{ t.name }}</span>
|
||||
<FaSvgIcon v-if="t.id === currentTenant?.id" icon="ri:check-line" />
|
||||
</span>
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 混合菜单 -->
|
||||
<template>
|
||||
<div id="app-menu-top-left" class="relative box-border flex-c w-full overflow-hidden">
|
||||
<div id="app-menu-top-left" class="relative box-border flex items-center w-full overflow-hidden">
|
||||
<!-- 左侧滚动按钮 -->
|
||||
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
|
||||
<ElIcon>
|
||||
@@ -16,11 +16,11 @@
|
||||
@scroll="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="box-border flex-c shrink-0 flex-nowrap h-15 whitespace-nowrap">
|
||||
<div class="box-border flex items-center shrink-0 flex-nowrap h-15 whitespace-nowrap">
|
||||
<template v-for="item in processedMenuList" :key="item.meta.title">
|
||||
<div
|
||||
v-if="!item.meta.isHide"
|
||||
class="menu-item relative shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
|
||||
class="menu-item relative shrink-0 h-10 px-3 text-sm flex items-center cursor-pointer hover:text-theme"
|
||||
:class="{
|
||||
'menu-item-active text-theme': item.isActive,
|
||||
}"
|
||||
|
||||
@@ -134,7 +134,7 @@ import AppConfig from "@/config";
|
||||
import { useConfigStore, useSettingsStore, useMenuStore } from "@stores";
|
||||
import { MenuTypeEnum, MenuWidth } from "@/enums/appEnum";
|
||||
import { isIframe, handleMenuJump } from "@utils";
|
||||
import SidebarSubmenu from "./widget/FaSidebarSubmenu.vue";
|
||||
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useWindowSize, useTimeoutFn } from "@vueuse/core";
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
|
||||
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
||||
<template #title>
|
||||
<div class="menu-icon flex-cc">
|
||||
<div class="menu-icon flex items-center justify-center">
|
||||
<FaMenuRouteIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
@@ -30,7 +30,7 @@
|
||||
:level-item="level + 1"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<div class="menu-icon flex-cc">
|
||||
<div class="menu-icon flex items-center justify-center">
|
||||
<FaMenuRouteIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
@@ -9,18 +9,20 @@
|
||||
v-show="visible"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex-cb px-3.5 mt-3.5">
|
||||
<div class="flex items-center justify-between px-3.5 mt-3.5">
|
||||
<span class="text-base font-medium text-g-800">{{ $t("notice.title") }}</span>
|
||||
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
||||
<span
|
||||
class="text-xs text-g-800 px-1.5 py-1 cursor-pointer select-none rounded hover:bg-g-200"
|
||||
>
|
||||
{{ $t("notice.btnRead") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
|
||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b border-(--default-border)">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 cursor-pointer select-none"
|
||||
:class="{ 'bar-active': barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
@@ -29,16 +31,16 @@
|
||||
</ul>
|
||||
|
||||
<div class="w-full h-[calc(100%-95px)]">
|
||||
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
|
||||
<ElScrollbar class="h-[calc(100%-60px)]">
|
||||
<!-- 通知 -->
|
||||
<ul v-show="barActiveIndex === 0">
|
||||
<li
|
||||
v-for="(item, index) in noticeList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
class="box-border flex items-center px-3.5 py-3.5 cursor-pointer last:border-b-0 hover:bg-g-200/60"
|
||||
>
|
||||
<div
|
||||
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
||||
class="size-9 leading-9 text-center rounded-lg flex items-center justify-center"
|
||||
:class="[getNoticeStyle(item.type).iconClass]"
|
||||
>
|
||||
<FaSvgIcon class="text-lg bg-transparent!" :icon="getNoticeStyle(item.type).icon" />
|
||||
@@ -55,9 +57,11 @@
|
||||
<li
|
||||
v-for="(item, index) in msgList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
class="box-border flex items-center px-3.5 py-3.5 cursor-pointer last:border-b-0 hover:bg-g-200/60"
|
||||
>
|
||||
<div class="size-9 leading-9 text-center rounded-lg flex-cc bg-info/12 text-info">
|
||||
<div
|
||||
class="size-9 leading-9 text-center rounded-lg flex items-center justify-center bg-info/12 text-info"
|
||||
>
|
||||
<FaSvgIcon class="text-lg bg-transparent!" icon="ri:message-3-line" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
@@ -86,10 +90,10 @@
|
||||
>
|
||||
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
||||
<p class="mt-3.5 text-xs bg-transparent!">
|
||||
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex].name }}
|
||||
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex]?.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<div class="relative box-border w-full px-3.5">
|
||||
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
|
||||
@@ -106,6 +110,7 @@
|
||||
import { computed, ref, watch, onMounted, type Ref, type ComputedRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import NoticeAPI, {
|
||||
type NoticeTable,
|
||||
type NotificationPanelMessage,
|
||||
@@ -398,16 +403,4 @@ watch(
|
||||
color: var(--theme-color) !important;
|
||||
border-bottom: 2px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: var(--default-box-color);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- 路由信息调试 -->
|
||||
<div
|
||||
v-if="isOpenRouteInfo === 'true'"
|
||||
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
|
||||
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border border-[var(--default-border)] rounded-md"
|
||||
>
|
||||
router meta:{{ route.meta }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- 开发者工具警告覆盖层 -->
|
||||
<div
|
||||
v-if="showDevToolsWarning"
|
||||
class="fixed top-0 left-0 z-999999 flex-cc w-full h-full text-white bg-linear-to-br from-[#1e1e1e] to-black animate-fade-in"
|
||||
class="fixed top-0 left-0 z-999999 flex items-center justify-center w-full h-full text-white bg-linear-to-br from-[#1e1e1e] to-black animate-fade-in"
|
||||
>
|
||||
<div class="p-5 text-center select-none">
|
||||
<div class="mb-7.5 text-5xl">🔒</div>
|
||||
@@ -66,20 +66,20 @@
|
||||
<span>{{ t("lock.unlock") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="time-container w-screen h-screen">
|
||||
<div class="time-container flex-cc w-screen h-screen">
|
||||
<div class="hour-container mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5">
|
||||
<span>{{ hour }}</span>
|
||||
<span v-show="showClock" class="meridiem absolute left-5 top-5 text-md xl:text-xl">
|
||||
{{ meridiem }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="minute-container w-2/5 h-2/5 md:h-4/5">
|
||||
<div class="minute-container flex-cc w-2/5 h-2/5 md:h-4/5">
|
||||
<span>{{ minute }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div v-show="!showClock" class="entry-wrapper">
|
||||
<div v-show="!showClock" class="entry-wrapper flex-cc">
|
||||
<div class="entry-content">
|
||||
<div class="avatar-container">
|
||||
<img v-if="userAvatar" :src="userAvatar" alt="" class="avatar object-cover" />
|
||||
@@ -581,9 +581,6 @@ onUnmounted(() => {
|
||||
|
||||
.hour-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 220px;
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ export function useSettingsHandlers() {
|
||||
const domOperations = {
|
||||
// 设置HTML类名
|
||||
setHtmlClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName("html")[0];
|
||||
const el = document.getElementsByTagName("html")[0]!;
|
||||
if (add) {
|
||||
el.classList.add(className);
|
||||
} else {
|
||||
@@ -28,7 +28,7 @@ export function useSettingsHandlers() {
|
||||
|
||||
// 设置body类名
|
||||
setBodyClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName("body")[0];
|
||||
const el = document.getElementsByTagName("body")[0]!;
|
||||
if (add) {
|
||||
el.classList.add(className);
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export function useSettingsPanel() {
|
||||
// 初始化系统颜色
|
||||
const initSystemColor = () => {
|
||||
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
||||
settingStore.setElementTheme(AppConfig.systemMainColor[0]);
|
||||
settingStore.setElementTheme(AppConfig.systemMainColor[0]!);
|
||||
settingStore.reload();
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export function useSettingsState() {
|
||||
// 色弱模式初始化
|
||||
const initColorWeak = () => {
|
||||
if (settingStore.colorWeak) {
|
||||
const el = document.getElementsByTagName("html")[0];
|
||||
const el = document.getElementsByTagName("html")[0]!;
|
||||
setTimeout(() => {
|
||||
el.classList.add("color-weak");
|
||||
}, 100);
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<FaSectionTitle :title="$t('setting.box.title')" class="mt-10" />
|
||||
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
|
||||
<div class="box-border flex items-center justify-between p-1 mt-5 rounded-lg bg-g-200">
|
||||
<div
|
||||
v-for="option in boxStyleOptions"
|
||||
:key="option.value"
|
||||
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
|
||||
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center cursor-pointer select-none rounded-md transition-all duration-200"
|
||||
:class="
|
||||
isActive(option.type)
|
||||
? 'text-g-800 bg-(--default-box-color) dark:text-white! dark:bg-g-300'
|
||||
+2
-2
@@ -5,10 +5,10 @@
|
||||
<div
|
||||
v-for="option in containerWidthOptions"
|
||||
:key="option.value"
|
||||
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer border-2! rounded-lg text-g-800! last:mr-0"
|
||||
class="flex items-center justify-center flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer border-2! rounded-lg text-g-800! last:mr-0"
|
||||
:class="{
|
||||
'border-theme [&_i]:text-theme!': containerWidth === option.value,
|
||||
'border-full-d': containerWidth !== option.value,
|
||||
'border border-[var(--default-border)]': containerWidth !== option.value,
|
||||
}"
|
||||
@click="containerHandlers.setWidth(option.value)"
|
||||
>
|
||||
+1
-1
@@ -184,7 +184,7 @@ const handleResetConfig = async () => {
|
||||
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType;
|
||||
settingStore.switchMenuStyles(menuTheme);
|
||||
|
||||
settingStore.setElementTheme(config.systemThemeColor);
|
||||
settingStore.setElementTheme(config.systemThemeColor!);
|
||||
|
||||
// 界面显示(切换类方法)
|
||||
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
@click="$emit('close')"
|
||||
class="flex-cc c-p size-7.5 transition-all! duration-200 rounded hover:bg-g-300/80"
|
||||
class="flex items-center justify-center cursor-pointer size-7.5 transition-all! duration-200 rounded hover:bg-g-300/80"
|
||||
>
|
||||
<FaSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
|
||||
</div>
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
|
||||
<div
|
||||
class="flex items-center justify-between mb-4 last:mb-2"
|
||||
:class="{ 'mobile-hide': config.mobileHide }"
|
||||
>
|
||||
<span class="text-sm">{{ config.label }}</span>
|
||||
|
||||
<!-- 开关类型 -->
|
||||
@@ -37,7 +37,7 @@
|
||||
}"
|
||||
>
|
||||
<li
|
||||
class="worktab-tab fa-card-xs inline-flex flex-cc h-8 mr-1.5 text-xs c-p hover:text-theme group"
|
||||
class="worktab-tab fa-card-xs inline-flex flex items-center justify-center h-8 mr-1.5 text-xs cursor-pointer hover:text-theme group"
|
||||
:class="[
|
||||
item.path === activeTab
|
||||
? chromeTabStrip
|
||||
@@ -99,7 +99,7 @@
|
||||
/>
|
||||
<span
|
||||
v-if="list.length > 1 && !item.fixedTab"
|
||||
class="worktab-close inline-flex flex-cc relative ml-0.5 rounded-full p-1 tad-200"
|
||||
class="worktab-close inline-flex flex items-center justify-center relative ml-0.5 rounded-full p-1 transition duration-200"
|
||||
@click.stop="closeWorktab('current', item.path)"
|
||||
>
|
||||
<FaSvgIcon icon="ri:close-large-fill" class="text-[10px]" />
|
||||
@@ -443,13 +443,13 @@ const useEventHandlers = () => {
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchState.value.startX = event.touches[0].clientX;
|
||||
touchState.value.startX = event.touches[0]!.clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!scrollRef.value || !tabsRef.value) return;
|
||||
|
||||
touchState.value.currentX = event.touches[0].clientX;
|
||||
touchState.value.currentX = event.touches[0]!.clientX;
|
||||
const deltaX = touchState.value.currentX - touchState.value.startX;
|
||||
const xMin = scrollRef.value.offsetWidth - tabsRef.value.offsetWidth;
|
||||
|
||||
@@ -586,8 +586,8 @@ const SCROLL_STEP = 200;
|
||||
function showGoogleTabDivider(index: number): boolean {
|
||||
const tabs = list.value;
|
||||
if (index <= 0 || index >= tabs.length) return false;
|
||||
const prev = tabs[index - 1];
|
||||
const cur = tabs[index];
|
||||
const prev = tabs[index - 1]!;
|
||||
const cur = tabs[index]!;
|
||||
if (prev.fixedTab && !cur.fixedTab) return true;
|
||||
if (prev.path === activeTab.value || cur.path === activeTab.value) return false;
|
||||
return true;
|
||||
|
||||
@@ -64,5 +64,5 @@ function onGuideFinished(): void {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@styles/custom/fa-layouts";
|
||||
@use "./fa-layouts";
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,11 @@ interface Props {
|
||||
commonStyle?: VideoPlayerStyle;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [];
|
||||
pause: [];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
playerId: "",
|
||||
videoUrl: "",
|
||||
@@ -88,12 +93,12 @@ onMounted(() => {
|
||||
|
||||
// 播放事件监听器
|
||||
playerInstance.value.on("play", () => {
|
||||
console.log("Video is playing");
|
||||
emit("play");
|
||||
});
|
||||
|
||||
// 暂停事件监听器
|
||||
playerInstance.value.on("pause", () => {
|
||||
console.log("Video is paused");
|
||||
emit("pause");
|
||||
});
|
||||
|
||||
// 错误事件监听器
|
||||
|
||||
@@ -294,7 +294,7 @@ const handleUpload = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const file = importFormData.files[0].raw as File;
|
||||
const file = importFormData.files[0]!.raw as File;
|
||||
const formData = new FormData();
|
||||
formData.append(props.uploadFileName, file);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ElCalendar v-model="currentDate">
|
||||
<template #date-cell="{ data }">
|
||||
<div
|
||||
class="home-calendar__cell relative flex h-full min-h-14 max-h-20 flex-col overflow-hidden p-0.5 c-p"
|
||||
class="home-calendar__cell relative flex h-full min-h-14 max-h-20 flex-col overflow-hidden p-0.5 cursor-pointer"
|
||||
:class="{ 'is-selected': data.isSelected }"
|
||||
@click="handleCellClick(data.day)"
|
||||
>
|
||||
|
||||
@@ -165,7 +165,7 @@ function resolveTagType(value: unknown, tag: boolean | TagConfig): TagType {
|
||||
if (tag.map) {
|
||||
const raw = value == null ? "" : String(value);
|
||||
if (raw in tag.map) {
|
||||
const t = tag.map[raw].type;
|
||||
const t = tag.map[raw]!.type;
|
||||
if (t && TAG_TYPES.has(t)) return t;
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ function resolveTagText(value: unknown, tag: boolean | TagConfig): string {
|
||||
return raw;
|
||||
}
|
||||
if (tag.map && raw in tag.map) {
|
||||
return tag.map[raw].text ?? raw;
|
||||
return tag.map[raw]!.text ?? raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<li
|
||||
v-for="icon in filteredElementIcons"
|
||||
:key="icon"
|
||||
class="icon-grid-item"
|
||||
class="icon-grid-item flex-cc"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<ElIcon>
|
||||
@@ -195,9 +195,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.icon-grid-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -7,17 +7,23 @@ import { computed } from "vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItHighlightjs from "markdown-it-highlightjs";
|
||||
import hljs from "highlight.js";
|
||||
import DOMPurify from "dompurify";
|
||||
import "highlight.js/styles/atom-one-light.css";
|
||||
|
||||
defineOptions({ name: "FaMarkdownRenderer" });
|
||||
|
||||
interface Props {
|
||||
/** Markdown 源文本 */
|
||||
content: string;
|
||||
/** 超过此长度则截断并追加 "..."(按字符数,不按渲染后长度) */
|
||||
maxLength?: number;
|
||||
/** 是否对渲染后的 HTML 做 XSS 消毒,默认 true(AI 场景默认开;可信后端 HTML 渲染时可关闭) */
|
||||
sanitize?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxLength: undefined,
|
||||
sanitize: true,
|
||||
});
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
@@ -57,11 +63,12 @@ md.renderer.rules.link_open = function (
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return "";
|
||||
const content =
|
||||
const source =
|
||||
props.maxLength && props.content.length > props.maxLength
|
||||
? props.content.substring(0, props.maxLength) + "..."
|
||||
: props.content;
|
||||
return md.render(content);
|
||||
const html = md.render(source);
|
||||
return props.sanitize ? DOMPurify.sanitize(html) : html;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- 普通菜单项 -->
|
||||
<li
|
||||
v-if="!item.children"
|
||||
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
class="menu-item relative flex items-center cursor-pointer select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(item)"
|
||||
@@ -32,10 +32,10 @@
|
||||
<!-- 子菜单 -->
|
||||
<li
|
||||
v-else
|
||||
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
class="menu-item submenu relative flex items-center cursor-pointer select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
:style="menuItemStyle"
|
||||
>
|
||||
<div class="submenu-title flex-c w-full">
|
||||
<div class="submenu-title flex items-center w-full">
|
||||
<FaSvgIcon
|
||||
v-if="item.icon"
|
||||
class="mr-2 shrink-0 text-base text-g-800"
|
||||
@@ -58,7 +58,7 @@
|
||||
<li
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
class="menu-item relative mx-1.5 flex items-center cursor-pointer select-none rounded text-xs transition-colors duration-150 hover:bg-(--el-color-primary-light-9)"
|
||||
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(child)"
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="mb-3 flex items-center gap-3 shrink-0">
|
||||
<ElInput
|
||||
v-model="filterText"
|
||||
placeholder="搜索菜单名称"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
:prefix-icon="Search"
|
||||
size="small"
|
||||
/>
|
||||
<ElButton type="primary" size="small" plain @click="toggleExpand">
|
||||
<template #icon><SwitchIcon /></template>
|
||||
{{ isExpanded ? "收起" : "展开" }}
|
||||
</ElButton>
|
||||
<ElCheckbox v-model="parentChildLinked"> 父子联动 </ElCheckbox>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
ref="permTableRef"
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
:default-expand-all="true"
|
||||
class="flex-1 min-h-0"
|
||||
@selection-change="onSelectionChange"
|
||||
@select="onSelect"
|
||||
@select-all="onSelectAll"
|
||||
>
|
||||
<ElTableColumn type="selection" width="48" />
|
||||
<ElTableColumn
|
||||
prop="name"
|
||||
label="菜单名称"
|
||||
width="220"
|
||||
show-overflow-tooltip
|
||||
:formatter="formatMenuName"
|
||||
/>
|
||||
<ElTableColumn label="功能权限">
|
||||
<template #default="{ row }">
|
||||
<ElCheckboxGroup
|
||||
v-model="checkedBtns[row.id]"
|
||||
class="flex flex-wrap gap-x-3"
|
||||
@change="(val: CheckboxValueType[]) => onBtnChange(row, val)"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="btn in getMenuBtns(row)"
|
||||
:key="btn.id"
|
||||
:value="btn.id"
|
||||
:label="btn.name"
|
||||
/>
|
||||
</ElCheckboxGroup>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, h } from "vue";
|
||||
import type { TableInstance, CheckboxValueType } from "element-plus";
|
||||
import { Search, Switch as SwitchIcon } from "@element-plus/icons-vue";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaMenuTreeTable" });
|
||||
|
||||
// ---- 类型常量 ----
|
||||
const DIR_TYPE = 1;
|
||||
const MENU_TYPE = 2;
|
||||
const BTN_TYPE = 3;
|
||||
|
||||
function isDirOrMenu(type: number | undefined): boolean {
|
||||
return type === DIR_TYPE || type === MENU_TYPE;
|
||||
}
|
||||
|
||||
function isPermission(type: number | undefined): boolean {
|
||||
return type != null && type >= BTN_TYPE;
|
||||
}
|
||||
|
||||
// ---- Props ----
|
||||
interface Props {
|
||||
menuTree: any[];
|
||||
checkedIds?: number[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkedIds: () => [],
|
||||
});
|
||||
|
||||
// ---- 状态 ----
|
||||
const filterText = ref("");
|
||||
const isExpanded = ref(true);
|
||||
const parentChildLinked = ref(true);
|
||||
const isHandlingCascade = ref(false);
|
||||
const permTableRef = ref<TableInstance>();
|
||||
const tableData = ref<any[]>([]);
|
||||
const checkedBtns = ref<Record<number, number[]>>({});
|
||||
const selectedMenuIds = ref<Set<number>>(new Set());
|
||||
|
||||
// ---- 搜索过滤后的菜单树 ----
|
||||
const filteredMenuTree = computed(() =>
|
||||
filterText.value ? filterMenuTree(props.menuTree, filterText.value) : props.menuTree
|
||||
);
|
||||
|
||||
function filterMenuTree(nodes: any[], text: string): any[] {
|
||||
if (!text) return nodes;
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
function walk(n: any): any | null {
|
||||
const nameMatch = (n.name || "").toLowerCase().includes(lower);
|
||||
const filteredChildren = n.children?.map(walk).filter(Boolean) ?? [];
|
||||
|
||||
if (nameMatch || filteredChildren.length > 0) {
|
||||
return { ...n, children: filteredChildren.length > 0 ? filteredChildren : n.children };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodes.map(walk).filter(Boolean) as any[];
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function walkTree(nodes: any[], fn: (node: any) => void) {
|
||||
for (const node of nodes) {
|
||||
fn(node);
|
||||
if (node.children) walkTree(node.children, fn);
|
||||
}
|
||||
}
|
||||
|
||||
function findRawNode(id: number, nodes: any[]): any | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findRawNode(id, node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findTableNode(id: number, nodes: any[]): any | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findTableNode(id, node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findParentInRawTree(menuId: number, nodes: any[]): any | null {
|
||||
for (const n of nodes) {
|
||||
if (n.children?.some((c: any) => c.id === menuId)) return n;
|
||||
if (n.children) {
|
||||
const found = findParentInRawTree(menuId, n.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatMenuName(row: any) {
|
||||
const name = row.name || "";
|
||||
if (row.icon) {
|
||||
return h("span", { class: "inline-flex items-center gap-1.5" }, [
|
||||
h(FaMenuRouteIcon, { icon: row.icon, style: { verticalAlign: "-0.15em" } }),
|
||||
name,
|
||||
]);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function getMenuBtns(row: any): any[] {
|
||||
const fullNode = findRawNode(row.id, props.menuTree);
|
||||
if (!fullNode?.children) return [];
|
||||
return fullNode.children.filter((c: any) => isPermission(c.type));
|
||||
}
|
||||
|
||||
function hasAnySelectedDescendant(node: any): boolean {
|
||||
if (checkedBtns.value[node.id]?.length > 0) return true;
|
||||
const tableNode = findTableNode(node.id, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type) && hasAnySelectedDescendant(child)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findAncestors(nodeId: number): any[] {
|
||||
function collectAncestorIds(nodes: any[], target: number): number[] {
|
||||
for (const n of nodes) {
|
||||
if (n.children?.some((c: any) => c.id === target)) {
|
||||
return [n.id!, ...collectAncestorIds(props.menuTree, n.id!)];
|
||||
}
|
||||
if (n.children) {
|
||||
const found = collectAncestorIds(n.children, target);
|
||||
if (found.length > 0) return found;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return collectAncestorIds(props.menuTree, nodeId)
|
||||
.map((id) => findTableNode(id, tableData.value))
|
||||
.filter(Boolean) as any[];
|
||||
}
|
||||
|
||||
function filterTableData(nodes: any[]): any[] {
|
||||
return nodes
|
||||
.filter((n) => isDirOrMenu(n.type))
|
||||
.map((n) => ({
|
||||
...n,
|
||||
children: n.children ? filterTableData(n.children) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- 级联 ----
|
||||
function collectBtnState(node: any) {
|
||||
const ids = getMenuBtns(node).map((b: any) => b.id!);
|
||||
if (ids.length) checkedBtns.value[node.id!] = ids;
|
||||
}
|
||||
|
||||
function cascadeSelect(node: any) {
|
||||
permTableRef.value?.toggleRowSelection(node, true);
|
||||
collectBtnState(node);
|
||||
const tableNode = findTableNode(node.id, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type)) cascadeSelect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cascadeDeselect(node: any) {
|
||||
permTableRef.value?.toggleRowSelection(node, false);
|
||||
checkedBtns.value[node.id!] = [];
|
||||
const tableNode = findTableNode(node.id, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type)) cascadeDeselect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectAncestors(node: any) {
|
||||
findAncestors(node.id!).forEach((a) => permTableRef.value?.toggleRowSelection(a, true));
|
||||
}
|
||||
|
||||
function deselectAncestorsIfNeeded(node: any) {
|
||||
findAncestors(node.id!).forEach((a) => {
|
||||
if (!hasAnySelectedDescendant(a)) permTableRef.value?.toggleRowSelection(a, false);
|
||||
});
|
||||
}
|
||||
|
||||
function withCascadeGuard(fn: () => void) {
|
||||
isHandlingCascade.value = true;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
isHandlingCascade.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Table 事件 ----
|
||||
function onSelectionChange() {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
const selection = store?.states?.selection?.value || [];
|
||||
const newSelected = new Set<number>();
|
||||
selection.forEach((row: any) => {
|
||||
if (isDirOrMenu(row.type)) newSelected.add(row.id!);
|
||||
});
|
||||
selectedMenuIds.value = newSelected;
|
||||
if (newSelected.size === 0) checkedBtns.value = {};
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
const selection = store?.states?.selection?.value || [];
|
||||
withCascadeGuard(() => {
|
||||
if (!selection.length) {
|
||||
checkedBtns.value = {};
|
||||
selectedMenuIds.value.clear();
|
||||
return;
|
||||
}
|
||||
for (const row of selection) {
|
||||
if (isDirOrMenu(row.type)) {
|
||||
selectedMenuIds.value.add(row.id!);
|
||||
if (parentChildLinked.value) collectBtnState(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onSelect(selection: any[], row: any) {
|
||||
if (isHandlingCascade.value || !parentChildLinked.value) return;
|
||||
const tableRow = findTableNode(row.id, tableData.value);
|
||||
if (!tableRow) return;
|
||||
const isSelected = selection.some((r) => r.id === tableRow.id);
|
||||
withCascadeGuard(() => {
|
||||
if (isSelected) {
|
||||
selectAncestors(tableRow);
|
||||
cascadeSelect(tableRow);
|
||||
} else {
|
||||
cascadeDeselect(tableRow);
|
||||
deselectAncestorsIfNeeded(tableRow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onBtnChange(row: any, val: CheckboxValueType[]) {
|
||||
checkedBtns.value[row.id!] = val.map((v) => Number(v));
|
||||
if (!parentChildLinked.value) return;
|
||||
withCascadeGuard(() => {
|
||||
if (val.length > 0) {
|
||||
permTableRef.value?.toggleRowSelection(row, true);
|
||||
selectAncestors(row);
|
||||
} else if (!hasAnySelectedDescendant(row)) {
|
||||
permTableRef.value?.toggleRowSelection(row, false);
|
||||
deselectAncestorsIfNeeded(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 展开/收起 ----
|
||||
function setAllRowsExpanded(expanded: boolean) {
|
||||
nextTick(() => {
|
||||
const el = permTableRef.value;
|
||||
if (!el || !tableData.value.length) return;
|
||||
walkTree(tableData.value, (row) => {
|
||||
if (row.children?.length) el.toggleRowExpansion(row, expanded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
setAllRowsExpanded(isExpanded.value);
|
||||
}
|
||||
|
||||
// ---- 初始化 ----
|
||||
function checkParentChildLinked(ids: number[], nodes: any[]): boolean {
|
||||
if (!ids.length || !nodes.length) return true;
|
||||
const idMap = new Map<number, any>();
|
||||
walkTree(nodes, (n) => idMap.set(n.id!, n));
|
||||
for (const id of ids) {
|
||||
const node = idMap.get(id);
|
||||
if (!node) continue;
|
||||
if (node.children?.length && node.children.some((c: any) => !ids.includes(c.id!))) return false;
|
||||
const parent = findParentInRawTree(id, nodes);
|
||||
if (parent && !ids.includes(parent.id!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function initSelection(ids: number[]) {
|
||||
await nextTick();
|
||||
const btnsState: Record<number, number[]> = {};
|
||||
const rowsToSelect: any[] = [];
|
||||
|
||||
walkTree(tableData.value, (node) => {
|
||||
if (isDirOrMenu(node.type) && ids.includes(node.id!)) {
|
||||
rowsToSelect.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
walkTree(props.menuTree, (node) => {
|
||||
if (!isPermission(node.type) || !ids.includes(node.id!)) return;
|
||||
const parent = findParentInRawTree(node.id!, props.menuTree);
|
||||
if (parent) (btnsState[parent.id!] ??= []).push(node.id!);
|
||||
});
|
||||
|
||||
checkedBtns.value = btnsState;
|
||||
|
||||
withCascadeGuard(() => {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
if (!store) return;
|
||||
store.clearSelection();
|
||||
rowsToSelect.forEach((row) => store.toggleRowSelection(row, true, false));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 对外方法 ----
|
||||
function getCheckedIds(): number[] {
|
||||
const allIds = new Set<number>();
|
||||
selectedMenuIds.value.forEach((id) => allIds.add(id));
|
||||
Object.values(checkedBtns.value).forEach((btnIds) => btnIds.forEach((id) => allIds.add(id)));
|
||||
return [...allIds];
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
filterText.value = "";
|
||||
const tree = props.menuTree;
|
||||
parentChildLinked.value = checkParentChildLinked(props.checkedIds, tree);
|
||||
tableData.value = filterTableData(tree);
|
||||
nextTick(() => initSelection(props.checkedIds));
|
||||
}
|
||||
|
||||
defineExpose({ getCheckedIds, refresh });
|
||||
|
||||
// ---- 数据变化监听 ----
|
||||
watch(
|
||||
() => [props.menuTree, props.checkedIds] as const,
|
||||
() => refresh(),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
watch(filterText, () => {
|
||||
const tree = filteredMenuTree.value;
|
||||
tableData.value = filterTableData(tree);
|
||||
nextTick(() => initSelection(props.checkedIds));
|
||||
if (filterText.value) setAllRowsExpanded(true);
|
||||
});
|
||||
</script>
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import { ref, watch, nextTick, computed, h } from "vue";
|
||||
import type { TableInstance, CheckboxValueType } from "element-plus";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-route-icon/index.vue";
|
||||
|
||||
// ---- 类型常量 ----
|
||||
const DIR_TYPE = 1;
|
||||
const MENU_TYPE = 2;
|
||||
const BTN_TYPE = 3;
|
||||
|
||||
function isDirOrMenu(type: number | undefined): boolean {
|
||||
return type === DIR_TYPE || type === MENU_TYPE;
|
||||
}
|
||||
|
||||
function isPermission(type: number | undefined): boolean {
|
||||
return type != null && type >= BTN_TYPE;
|
||||
}
|
||||
|
||||
interface MenuTreeTableProps {
|
||||
menuTree: any[];
|
||||
checkedIds?: number[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单树表格业务逻辑
|
||||
* 负责:搜索过滤、级联勾选、父子联动、展开/收起、初始化选中、对外方法
|
||||
*/
|
||||
export function useMenuTreeTable(rawProps: MenuTreeTableProps) {
|
||||
const props = {
|
||||
menuTree: rawProps.menuTree,
|
||||
checkedIds: rawProps.checkedIds ?? [],
|
||||
loading: rawProps.loading,
|
||||
};
|
||||
// ---- 状态 ----
|
||||
const filterText = ref("");
|
||||
const isExpanded = ref(true);
|
||||
const parentChildLinked = ref(true);
|
||||
const isHandlingCascade = ref(false);
|
||||
const permTableRef = ref<TableInstance>();
|
||||
const tableData = ref<any[]>([]);
|
||||
const checkedBtns = ref<Record<number, number[]>>({});
|
||||
const selectedMenuIds = ref<Set<number>>(new Set());
|
||||
|
||||
// ---- 搜索过滤后的菜单树 ----
|
||||
const filteredMenuTree = computed(() =>
|
||||
filterText.value ? filterMenuTree(props.menuTree, filterText.value) : props.menuTree
|
||||
);
|
||||
|
||||
function filterMenuTree(nodes: any[], text: string): any[] {
|
||||
if (!text) return nodes;
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
function walk(n: any): any | null {
|
||||
const nameMatch = (n.name || "").toLowerCase().includes(lower);
|
||||
const filteredChildren = n.children?.map(walk).filter(Boolean) ?? [];
|
||||
|
||||
if (nameMatch || filteredChildren.length > 0) {
|
||||
return { ...n, children: filteredChildren.length > 0 ? filteredChildren : n.children };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodes.map(walk).filter(Boolean) as any[];
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function walkTree(nodes: any[], fn: (node: any) => void) {
|
||||
for (const node of nodes) {
|
||||
fn(node);
|
||||
if (node.children) walkTree(node.children, fn);
|
||||
}
|
||||
}
|
||||
|
||||
function findRawNode(id: number, nodes: any[]): any | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findRawNode(id, node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findTableNode(id: number, nodes: any[]): any | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findTableNode(id, node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findParentInRawTree(menuId: number, nodes: any[]): any | null {
|
||||
for (const n of nodes) {
|
||||
if (n.children?.some((c: any) => c.id === menuId)) return n;
|
||||
if (n.children) {
|
||||
const found = findParentInRawTree(menuId, n.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatMenuName(row: any) {
|
||||
const name = row.name || "";
|
||||
if (row.icon) {
|
||||
return h("span", { class: "inline-flex items-center gap-1.5" }, [
|
||||
h(FaMenuRouteIcon, { icon: row.icon, style: { verticalAlign: "-0.15em" } }),
|
||||
name,
|
||||
]);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function getMenuBtns(row: any): any[] {
|
||||
const fullNode = findRawNode(row.id, props.menuTree);
|
||||
if (!fullNode?.children) return [];
|
||||
return fullNode.children.filter((c: any) => isPermission(c.type));
|
||||
}
|
||||
|
||||
function hasAnySelectedDescendant(node: any): boolean {
|
||||
if (checkedBtns.value[node.id!]?.length ?? 0 > 0) return true;
|
||||
const tableNode = findTableNode(node.id!, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type) && hasAnySelectedDescendant(child)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findAncestors(nodeId: number): any[] {
|
||||
function collectAncestorIds(nodes: any[], target: number): number[] {
|
||||
for (const n of nodes) {
|
||||
if (n.children?.some((c: any) => c.id === target)) {
|
||||
return [n.id!, ...collectAncestorIds(props.menuTree, n.id!)];
|
||||
}
|
||||
if (n.children) {
|
||||
const found = collectAncestorIds(n.children, target);
|
||||
if (found.length > 0) return found;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return collectAncestorIds(props.menuTree, nodeId)
|
||||
.map((id) => findTableNode(id, tableData.value))
|
||||
.filter(Boolean) as any[];
|
||||
}
|
||||
|
||||
function filterTableData(nodes: any[]): any[] {
|
||||
return nodes
|
||||
.filter((n) => isDirOrMenu(n.type))
|
||||
.map((n) => ({
|
||||
...n,
|
||||
children: n.children ? filterTableData(n.children) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- 级联 ----
|
||||
function collectBtnState(node: any) {
|
||||
const ids = getMenuBtns(node).map((b: any) => b.id!);
|
||||
if (ids.length) checkedBtns.value[node.id!] = ids;
|
||||
}
|
||||
|
||||
function cascadeSelect(node: any) {
|
||||
permTableRef.value?.toggleRowSelection(node, true);
|
||||
collectBtnState(node);
|
||||
const tableNode = findTableNode(node.id, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type)) cascadeSelect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cascadeDeselect(node: any) {
|
||||
permTableRef.value?.toggleRowSelection(node, false);
|
||||
checkedBtns.value[node.id!] = [];
|
||||
const tableNode = findTableNode(node.id, tableData.value);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if (isDirOrMenu(child.type)) cascadeDeselect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectAncestors(node: any) {
|
||||
findAncestors(node.id!).forEach((a) => permTableRef.value?.toggleRowSelection(a, true));
|
||||
}
|
||||
|
||||
function deselectAncestorsIfNeeded(node: any) {
|
||||
findAncestors(node.id!).forEach((a) => {
|
||||
if (!hasAnySelectedDescendant(a)) permTableRef.value?.toggleRowSelection(a, false);
|
||||
});
|
||||
}
|
||||
|
||||
function withCascadeGuard(fn: () => void) {
|
||||
isHandlingCascade.value = true;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
isHandlingCascade.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Table 事件 ----
|
||||
function onSelectionChange() {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
const selection = store?.states?.selection?.value || [];
|
||||
const newSelected = new Set<number>();
|
||||
selection.forEach((row: any) => {
|
||||
if (isDirOrMenu(row.type)) newSelected.add(row.id!);
|
||||
});
|
||||
selectedMenuIds.value = newSelected;
|
||||
if (newSelected.size === 0) checkedBtns.value = {};
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
const selection = store?.states?.selection?.value || [];
|
||||
withCascadeGuard(() => {
|
||||
if (!selection.length) {
|
||||
checkedBtns.value = {};
|
||||
selectedMenuIds.value.clear();
|
||||
return;
|
||||
}
|
||||
for (const row of selection) {
|
||||
if (isDirOrMenu(row.type)) {
|
||||
selectedMenuIds.value.add(row.id!);
|
||||
if (parentChildLinked.value) collectBtnState(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onSelect(selection: any[], row: any) {
|
||||
if (isHandlingCascade.value || !parentChildLinked.value) return;
|
||||
const tableRow = findTableNode(row.id, tableData.value);
|
||||
if (!tableRow) return;
|
||||
const isSelected = selection.some((r) => r.id === tableRow.id);
|
||||
withCascadeGuard(() => {
|
||||
if (isSelected) {
|
||||
selectAncestors(tableRow);
|
||||
cascadeSelect(tableRow);
|
||||
} else {
|
||||
cascadeDeselect(tableRow);
|
||||
deselectAncestorsIfNeeded(tableRow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onBtnChange(row: any, val: CheckboxValueType[]) {
|
||||
checkedBtns.value[row.id!] = val.map((v) => Number(v));
|
||||
if (!parentChildLinked.value) return;
|
||||
withCascadeGuard(() => {
|
||||
if (val.length > 0) {
|
||||
permTableRef.value?.toggleRowSelection(row, true);
|
||||
selectAncestors(row);
|
||||
} else if (!hasAnySelectedDescendant(row)) {
|
||||
permTableRef.value?.toggleRowSelection(row, false);
|
||||
deselectAncestorsIfNeeded(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 展开/收起 ----
|
||||
function setAllRowsExpanded(expanded: boolean) {
|
||||
nextTick(() => {
|
||||
const el = permTableRef.value;
|
||||
if (!el || !tableData.value.length) return;
|
||||
walkTree(tableData.value, (row) => {
|
||||
if (row.children?.length) el.toggleRowExpansion(row, expanded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
setAllRowsExpanded(isExpanded.value);
|
||||
}
|
||||
|
||||
// ---- 初始化 ----
|
||||
function checkParentChildLinked(ids: number[], nodes: any[]): boolean {
|
||||
if (!ids.length || !nodes.length) return true;
|
||||
const idMap = new Map<number, any>();
|
||||
walkTree(nodes, (n) => idMap.set(n.id!, n));
|
||||
for (const id of ids) {
|
||||
const node = idMap.get(id);
|
||||
if (!node) continue;
|
||||
if (node.children?.length && node.children.some((c: any) => !ids.includes(c.id!)))
|
||||
return false;
|
||||
const parent = findParentInRawTree(id, nodes);
|
||||
if (parent && !ids.includes(parent.id!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function initSelection(ids: number[]) {
|
||||
await nextTick();
|
||||
const btnsState: Record<number, number[]> = {};
|
||||
const rowsToSelect: any[] = [];
|
||||
|
||||
walkTree(tableData.value, (node) => {
|
||||
if (isDirOrMenu(node.type) && ids.includes(node.id!)) {
|
||||
rowsToSelect.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
walkTree(props.menuTree, (node) => {
|
||||
if (!isPermission(node.type) || !ids.includes(node.id!)) return;
|
||||
const parent = findParentInRawTree(node.id!, props.menuTree);
|
||||
if (parent) (btnsState[parent.id!] ??= []).push(node.id!);
|
||||
});
|
||||
|
||||
checkedBtns.value = btnsState;
|
||||
|
||||
withCascadeGuard(() => {
|
||||
const store = (permTableRef.value as any)?.store;
|
||||
if (!store) return;
|
||||
store.clearSelection();
|
||||
rowsToSelect.forEach((row) => store.toggleRowSelection(row, true, false));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 对外方法 ----
|
||||
function getCheckedIds(): number[] {
|
||||
const allIds = new Set<number>();
|
||||
selectedMenuIds.value.forEach((id) => allIds.add(id));
|
||||
Object.values(checkedBtns.value).forEach((btnIds) => btnIds.forEach((id) => allIds.add(id)));
|
||||
return [...allIds];
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
filterText.value = "";
|
||||
const tree = props.menuTree;
|
||||
parentChildLinked.value = checkParentChildLinked(props.checkedIds, tree);
|
||||
tableData.value = filterTableData(tree);
|
||||
nextTick(() => initSelection(props.checkedIds));
|
||||
}
|
||||
|
||||
// ---- 数据变化监听 ----
|
||||
watch(
|
||||
() => [props.menuTree, props.checkedIds] as const,
|
||||
() => refresh(),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
watch(filterText, () => {
|
||||
const tree = filteredMenuTree.value;
|
||||
tableData.value = filterTableData(tree);
|
||||
nextTick(() => initSelection(props.checkedIds));
|
||||
if (filterText.value) setAllRowsExpanded(true);
|
||||
});
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filterText,
|
||||
isExpanded,
|
||||
parentChildLinked,
|
||||
permTableRef,
|
||||
tableData,
|
||||
checkedBtns,
|
||||
// 工具
|
||||
formatMenuName,
|
||||
getMenuBtns,
|
||||
// 事件
|
||||
onSelectionChange,
|
||||
onSelectAll,
|
||||
onSelect,
|
||||
onBtnChange,
|
||||
toggleExpand,
|
||||
// 对外方法
|
||||
getCheckedIds,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<!-- 菜单权限树表:搜索 + 展开/收起 + 父子联动 + 树形勾选 -->
|
||||
<template>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="mb-3 flex items-center gap-3 shrink-0">
|
||||
<ElInput
|
||||
v-model="filterText"
|
||||
placeholder="搜索菜单名称"
|
||||
clearable
|
||||
class="menu-tree-search-input"
|
||||
:prefix-icon="Search"
|
||||
size="small"
|
||||
/>
|
||||
<ElButton type="primary" size="small" plain @click="toggleExpand">
|
||||
<template #icon><SwitchIcon /></template>
|
||||
{{ isExpanded ? "收起" : "展开" }}
|
||||
</ElButton>
|
||||
<ElCheckbox v-model="parentChildLinked"> 父子联动 </ElCheckbox>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
ref="permTableRef"
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
:default-expand-all="true"
|
||||
class="flex-1 min-h-0"
|
||||
@selection-change="onSelectionChange"
|
||||
@select="onSelect"
|
||||
@select-all="onSelectAll"
|
||||
>
|
||||
<ElTableColumn type="selection" width="48" />
|
||||
<ElTableColumn
|
||||
prop="name"
|
||||
label="菜单名称"
|
||||
width="220"
|
||||
show-overflow-tooltip
|
||||
:formatter="formatMenuName"
|
||||
/>
|
||||
<ElTableColumn label="功能权限">
|
||||
<template #default="{ row }">
|
||||
<ElCheckboxGroup
|
||||
v-model="checkedBtns[row.id]"
|
||||
class="flex flex-wrap gap-x-3"
|
||||
@change="(val: CheckboxValueType[]) => onBtnChange(row, val)"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="btn in getMenuBtns(row)"
|
||||
:key="btn.id"
|
||||
:value="btn.id"
|
||||
:label="btn.name"
|
||||
/>
|
||||
</ElCheckboxGroup>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Switch as SwitchIcon } from "@element-plus/icons-vue";
|
||||
import type { CheckboxValueType } from "element-plus";
|
||||
import { useMenuTreeTable } from "./composables/useMenuTreeTable";
|
||||
|
||||
defineOptions({ name: "FaMenuTreeTable" });
|
||||
|
||||
interface Props {
|
||||
menuTree: any[];
|
||||
checkedIds?: number[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkedIds: () => [],
|
||||
});
|
||||
|
||||
const {
|
||||
filterText,
|
||||
isExpanded,
|
||||
parentChildLinked,
|
||||
permTableRef,
|
||||
tableData,
|
||||
checkedBtns,
|
||||
formatMenuName,
|
||||
getMenuBtns,
|
||||
onSelectionChange,
|
||||
onSelectAll,
|
||||
onSelect,
|
||||
onBtnChange,
|
||||
toggleExpand,
|
||||
getCheckedIds,
|
||||
refresh,
|
||||
} = useMenuTreeTable(props);
|
||||
|
||||
defineExpose({ getCheckedIds, refresh });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-tree-search-input {
|
||||
width: 320px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<ElTag :type="type" :size="size" :effect="effect" :round="round" :closable="closable">
|
||||
{{ label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElTag } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaStatusTag" });
|
||||
|
||||
interface Props {
|
||||
/** 显示文本 */
|
||||
label?: string | number;
|
||||
/**
|
||||
* 标签类型(与 EP ElTag 一致)。
|
||||
* 不传时为 EP 默认样式(无 type 属性)。
|
||||
*/
|
||||
type?: "primary" | "success" | "warning" | "danger" | "info";
|
||||
/** 尺寸 */
|
||||
size?: "large" | "default" | "small";
|
||||
/** 主题效果:light(默认)/ dark / plain */
|
||||
effect?: "light" | "dark" | "plain";
|
||||
/** 是否圆角 */
|
||||
round?: boolean;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
label: "",
|
||||
type: undefined,
|
||||
size: "default",
|
||||
effect: "light",
|
||||
round: false,
|
||||
closable: false,
|
||||
});
|
||||
</script>
|
||||
@@ -247,7 +247,7 @@ const queryParams = reactive<{
|
||||
const tableSelectRef = ref();
|
||||
const popoverWidth = ref(width);
|
||||
useResizeObserver(tableSelectRef, (entries) => {
|
||||
popoverWidth.value = `${entries[0].contentRect.width}px`;
|
||||
popoverWidth.value = `${entries[0]!.contentRect.width}px`;
|
||||
});
|
||||
|
||||
// 表单操作
|
||||
|
||||
@@ -44,13 +44,15 @@
|
||||
:disabled="props.disabled"
|
||||
>
|
||||
<template #default>
|
||||
<template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
|
||||
<template
|
||||
v-if="internalFileList && internalFileList.length > 0 && internalFileList[0]!.url"
|
||||
>
|
||||
<ElImage
|
||||
:key="internalFileList[0].url"
|
||||
:key="internalFileList[0]!.url"
|
||||
class="single-upload__image"
|
||||
:src="internalFileList[0].url"
|
||||
:src="internalFileList[0]!.url"
|
||||
fit="cover"
|
||||
:preview-src-list="props.enablePreview ? [internalFileList[0].url] : []"
|
||||
:preview-src-list="props.enablePreview ? [internalFileList[0]!.url] : []"
|
||||
:preview-teleported="true"
|
||||
@click.stop="handleImageClick"
|
||||
/>
|
||||
@@ -237,8 +239,8 @@ watch(
|
||||
watch(
|
||||
() => internalFileList.value,
|
||||
(newVal) => {
|
||||
if (newVal && newVal.length > 0 && newVal[0].url) {
|
||||
modelValue.value = newVal[0].url;
|
||||
if (newVal && newVal.length > 0 && newVal[0]!.url) {
|
||||
modelValue.value = newVal[0]!.url;
|
||||
} else {
|
||||
modelValue.value = "";
|
||||
}
|
||||
@@ -333,7 +335,7 @@ function handleImageClick(event: Event) {
|
||||
props.enablePreview &&
|
||||
internalFileList.value &&
|
||||
internalFileList.value.length > 0 &&
|
||||
internalFileList.value[0].url
|
||||
internalFileList.value[0]!.url
|
||||
) {
|
||||
// Element Plus的el-image组件会自动处理preview-src-list的预览功能
|
||||
// 这里只需要阻止冒泡即可
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
|
||||
<template>
|
||||
<div class="flex-cb max-md:block!" id="fa-table-header">
|
||||
<div class="flex items-center justify-between max-md:block!" id="fa-table-header">
|
||||
<div class="flex-wrap">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="flex-c md:justify-end max-md:mt-3 max-sm:hidden!">
|
||||
<div class="flex items-center md:justify-end max-md:mt-3 max-sm:hidden!">
|
||||
<!-- 搜索区域显示/隐藏:默认展示搜索(未高亮);点按收起后高亮表示当前为隐藏状态 -->
|
||||
<ElTooltip
|
||||
v-if="showSearchBar != null"
|
||||
@@ -97,11 +97,11 @@
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.prop || item.type"
|
||||
class="column-option flex-c"
|
||||
class="column-option flex items-center"
|
||||
:class="{ 'fixed-column': item.fixed }"
|
||||
>
|
||||
<div
|
||||
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
|
||||
class="drag-icon mr-2 h-4.5 flex items-center justify-center text-g-500"
|
||||
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'"
|
||||
>
|
||||
<FaSvgIcon
|
||||
@@ -157,7 +157,6 @@ import { useTableStore } from "@stores";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import { ElScrollbar } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaTableHeader" });
|
||||
|
||||
@@ -351,7 +350,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/tailwind.css';
|
||||
|
||||
.button {
|
||||
@apply ml-2
|
||||
|
||||
@@ -110,7 +110,7 @@ const formatNumber = (
|
||||
// 处理千分位分隔符
|
||||
if (separator) {
|
||||
const parts = result.split(decimal);
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
||||
parts[0] = parts[0]!.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
||||
result = parts.join(decimal);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
|
||||
class="relative overflow-hidden rounded-custom-sm border flex items-center box-border text-sm"
|
||||
:class="themeClasses"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
|
||||
<div
|
||||
class="flex items-center justify-center absolute left-0 h-full w-9 z-10"
|
||||
:style="{ backgroundColor: bgColor }"
|
||||
>
|
||||
<FaSvgIcon icon="ri:volume-down-line" class="text-lg" />
|
||||
</div>
|
||||
|
||||
@@ -33,7 +36,7 @@
|
||||
|
||||
<div
|
||||
v-if="showClose"
|
||||
class="flex-cc absolute right-0 h-full w-9 c-p"
|
||||
class="flex items-center justify-center absolute right-0 h-full w-9 cursor-pointer"
|
||||
:style="{ backgroundColor: bgColor }"
|
||||
@click="handleClose"
|
||||
>
|
||||
@@ -328,7 +331,6 @@ onBeforeUnmount(() => {
|
||||
defineOptions({ name: "FaTextScroll" });
|
||||
|
||||
const handleClose = () => {
|
||||
console.log("文本滚动组件已关闭");
|
||||
ElMessage.info("已关闭");
|
||||
};
|
||||
</script> -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page-content border-0! bg-transparent! min-h-screen flex-cc">
|
||||
<div class="flex-cc max-md:block! max-md:text-center">
|
||||
<div class="page-content border-0! bg-transparent! min-h-screen flex items-center justify-center">
|
||||
<div class="flex items-center justify-center max-md:block! max-md:text-center">
|
||||
<FaThemeSvg :src="data.imgUrl" size="100%" class="w-100!" />
|
||||
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
|
||||
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 登录根布局
|
||||
* - 顶栏 fixed 透明叠在页面上(不占一条实体顶区);仅插画列 / 表单区随布局切换
|
||||
* - left / right:插画列与表单列横向排列(约 65% / 35%)
|
||||
* - center:全宽 + 视口级背景;登录卡居中、阴影高亮
|
||||
*/
|
||||
.login-auth-split {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-auth-split__col--illustration {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-auth-split__col--form {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 横向:插画 ~65%,表单列占剩余 */
|
||||
.login-auth-split--left,
|
||||
.login-auth-split--right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-auth-split--left .login-auth-split__col--illustration,
|
||||
.login-auth-split--right .login-auth-split__col--illustration {
|
||||
flex: 0 0 65%;
|
||||
}
|
||||
|
||||
.login-auth-split--left .login-auth-split__col--form,
|
||||
.login-auth-split--right .login-auth-split__col--form {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-auth-split--left {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1600px) {
|
||||
.login-auth-split--left .login-auth-split__col--illustration,
|
||||
.login-auth-split--right .login-auth-split__col--illustration {
|
||||
flex: 0 0 60%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 居中模式:仅一列表单壳占满屏宽 */
|
||||
.login-auth-split--center {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-auth-split--center .login-auth-split__col--form {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 顶栏与页脚之间的区域:纵向居中承载登录卡 */
|
||||
.login-auth-split--center .login-page-panel__scroll.login-page-panel__scroll--centered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
|
||||
/* 盖过 Tailwind overflow-y-auto:仅卡片内滚动,外层不出滚动条 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page-panel__scroll--centered .login-panel-align-row {
|
||||
flex: 0 1 auto;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* 悬浮卡片:玻璃拟态,圆角透明 */
|
||||
.login-auth-split--center .auth-right-wrap {
|
||||
width: min(480px, calc(100vw - 2rem)) !important;
|
||||
max-width: min(480px, calc(100vw - 2rem));
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 8rem);
|
||||
padding: 1.25rem 1.75rem;
|
||||
overflow: hidden auto;
|
||||
background: color-mix(in srgb, var(--el-bg-color) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--el-border-color-lighter) 60%, transparent);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 1px 8px rgb(15 23 42 / 5%),
|
||||
0 0 16px color-mix(in srgb, var(--el-color-primary) 6%, transparent);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.login-auth-split--center .auth-right-wrap .form {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
html.dark .login-auth-split--center .auth-right-wrap {
|
||||
background: color-mix(in srgb, var(--el-bg-color) 55%, transparent);
|
||||
border-color: rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 1px 8px rgb(0 0 0 / 18%),
|
||||
0 0 16px color-mix(in srgb, var(--el-color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
/* 居中模式下页脚弱化,减少「分区后台」感 */
|
||||
.login-page-footer--floating-layout {
|
||||
border-top-color: color-mix(in srgb, var(--el-border-color-lighter) 55%, transparent);
|
||||
}
|
||||
|
||||
html.dark .login-page-footer--floating-layout {
|
||||
border-top-color: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
/* 居中模式 footer 单行展示 */
|
||||
.login-auth-split--center .login-page-footer--floating-layout {
|
||||
.login-footer-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-footer-row {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-footer-row + .login-footer-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.login-footer-sep-center {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1180px) {
|
||||
.login-auth-split {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.login-auth-split__col--illustration {
|
||||
flex: 0 0 auto !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-auth-split__col--form {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 窄屏横排切竖排:恢复插画;居中模式本身无插画,不受影响 */
|
||||
.login-auth-split--center .auth-right-wrap {
|
||||
width: min(420px, calc(100vw - 1.5rem)) !important;
|
||||
max-width: min(420px, calc(100vw - 1.5rem));
|
||||
max-height: calc(100dvh - 6rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* 授权页表单区域(左右分栏时) */
|
||||
.auth-right-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 440px;
|
||||
height: 670px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
overflow: hidden;
|
||||
animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
|
||||
@media (width <= 640px) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 10rem);
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.form {
|
||||
height: 100%;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 2.5rem;
|
||||
color: var(--fa-gray-900);
|
||||
|
||||
@media (width <= 768px) {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin-top: 10px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--fa-gray-600);
|
||||
}
|
||||
|
||||
.custom-height {
|
||||
height: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-panel {
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .login-page-panel {
|
||||
.text-g-800 {
|
||||
color: rgb(255 255 255 / 78%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-page-form .el-form-item {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.el-select__wrapper {
|
||||
min-height: 42px !important;
|
||||
}
|
||||
|
||||
.login-secondary-actions .login-secondary-btn {
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.login-page-form .el-input__wrapper {
|
||||
background-color: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
|
||||
}
|
||||
|
||||
.login-page-form .el-input__inner {
|
||||
color: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
.login-page-form .el-input__inner::placeholder {
|
||||
color: rgb(255 255 255 / 35%);
|
||||
}
|
||||
|
||||
.login-page-form .el-select .el-select__wrapper {
|
||||
background-color: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
|
||||
}
|
||||
|
||||
.login-page-form .el-select__placeholder {
|
||||
color: rgb(255 255 255 / 40%);
|
||||
}
|
||||
|
||||
.login-page-form .el-select__selected-item {
|
||||
color: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
.login-remember .el-checkbox__label {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.login-secondary-btn {
|
||||
color: rgb(255 255 255 / 85%) !important;
|
||||
background: transparent !important;
|
||||
border-color: rgb(255 255 255 / 22%) !important;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary) !important;
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-secondary-actions .login-secondary-btn {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.login-footer-text {
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
|
||||
.login-footer-row {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-footer-row + .login-footer-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.login-footer-sep-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-page-footer__link {
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer__sep {
|
||||
margin: 0 0.35rem;
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.login-page-footer__record {
|
||||
display: inline-block;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .login-page-footer {
|
||||
border-top-color: rgb(255 255 255 / 8%);
|
||||
|
||||
.login-footer-text {
|
||||
color: rgb(255 255 255 / 42%) !important;
|
||||
}
|
||||
|
||||
.login-page-footer__link {
|
||||
color: rgb(255 255 255 / 48%);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer__sep {
|
||||
color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.login-page-footer__record {
|
||||
color: rgb(255 255 255 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
.login-mobile-otp-cell {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 48px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
|
||||
.login-mobile-sms-btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-mobile-actions {
|
||||
.el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.login-qr-card {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.login-mobile-otp-cell {
|
||||
background: rgb(255 255 255 / 6%);
|
||||
border-color: rgb(255 255 255 / 14%);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -101,7 +101,7 @@
|
||||
<div class="login-form-tail flex flex-col gap-[1.1rem]">
|
||||
<div class="relative pb-3">
|
||||
<div
|
||||
class="relative z-2 overflow-hidden select-none rounded-lg border border-transparent tad-300"
|
||||
class="relative z-2 overflow-hidden select-none rounded-lg border border-transparent transition duration-300"
|
||||
:class="{ 'border-[#FF4E4F]!': !isPassing && isClickPass }"
|
||||
>
|
||||
<FaDragVerify
|
||||
@@ -116,14 +116,14 @@
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="absolute top-0 z-1 mt-2 px-px text-xs text-[#f56c6c] tad-300"
|
||||
class="absolute top-0 z-1 mt-2 px-px text-xs text-[#f56c6c] transition duration-300"
|
||||
:class="{ 'translate-y-10': !isPassing && isClickPass }"
|
||||
>
|
||||
{{ $t("login.placeholder.slider") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="login-options-row flex-cb text-sm">
|
||||
<div class="login-options-row flex items-center justify-between text-sm">
|
||||
<ElCheckbox v-model="loginForm.remember" class="login-remember">
|
||||
{{ $t("login.rememberPwd") }}
|
||||
</ElCheckbox>
|
||||
@@ -231,5 +231,5 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@styles/custom/fa-login";
|
||||
@use "../fa-login";
|
||||
</style>
|
||||
+1
-1
@@ -122,5 +122,5 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@styles/custom/fa-login";
|
||||
@use "../fa-login";
|
||||
</style>
|
||||
+1
-1
@@ -203,5 +203,5 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@styles/custom/fa-login";
|
||||
@use "../fa-login";
|
||||
</style>
|
||||
+1
-1
@@ -72,5 +72,5 @@ const qrPayload = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@styles/custom/fa-login";
|
||||
@use "../fa-login";
|
||||
</style>
|
||||
+1
-1
@@ -144,5 +144,5 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@styles/custom/fa-login";
|
||||
@use "../fa-login";
|
||||
</style>
|
||||
+13
-9
@@ -16,16 +16,16 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 flex-cc gap-1.5 px-2 py-1.5 max-sm:mr-1"
|
||||
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 flex items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
|
||||
>
|
||||
<div class="color-picker-expandable relative flex-c max-sm:hidden!">
|
||||
<div class="color-picker-expandable relative flex items-center max-sm:hidden!">
|
||||
<div
|
||||
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
|
||||
class="color-dots absolute right-0 rounded-full flex items-center gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="(_color, index) in mainColors"
|
||||
:key="_color"
|
||||
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
|
||||
class="color-dot relative size-5 cursor-pointer flex items-center justify-center rounded-full opacity-0"
|
||||
:class="{ active: _color === systemThemeColor }"
|
||||
:style="{ background: _color, '--index': index }"
|
||||
@click="changeThemeColor(_color)"
|
||||
@@ -33,7 +33,9 @@
|
||||
<FaSvgIcon v-if="_color === systemThemeColor" icon="ri:check-fill" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn palette-btn auth-top-bar__action relative z-2 h-8 w-8 c-p flex-cc tad-300">
|
||||
<div
|
||||
class="btn palette-btn auth-top-bar__action relative z-2 h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
|
||||
>
|
||||
<FaSvgIcon icon="ri:palette-line" class="text-xl transition-colors duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +45,7 @@
|
||||
popper-class="langDropDownStyle"
|
||||
>
|
||||
<div
|
||||
class="btn layout-align-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300"
|
||||
class="btn layout-align-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
|
||||
:title="$t('login.panelAlign.label')"
|
||||
>
|
||||
<FaSvgIcon
|
||||
@@ -71,7 +73,9 @@
|
||||
@command="changeLanguage"
|
||||
popper-class="langDropDownStyle"
|
||||
>
|
||||
<div class="btn language-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300">
|
||||
<div
|
||||
class="btn language-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
|
||||
>
|
||||
<FaSvgIcon
|
||||
icon="ri:translate-2"
|
||||
class="text-[19px] text-g-800 transition-colors duration-300"
|
||||
@@ -93,7 +97,7 @@
|
||||
</ElDropdown>
|
||||
<div
|
||||
v-if="shouldShowThemeToggle"
|
||||
class="btn theme-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300"
|
||||
class="btn theme-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
|
||||
@click="themeAnimation"
|
||||
>
|
||||
<FaSvgIcon
|
||||
@@ -115,7 +119,7 @@ import { themeAnimation } from "@utils";
|
||||
import { languageOptions } from "@/locales";
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
import AppConfig from "@/config";
|
||||
import { LoginPanelAlign } from "@/components/views/fa-login/useLoginPanelAlign";
|
||||
import { LoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
|
||||
|
||||
defineOptions({ name: "AuthTopBar" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 按钮组件 -->
|
||||
<template>
|
||||
<div
|
||||
class="size-8.5 inline-flex flex-cc c-p text-g-600 dark:text-g-800 text-xl rounded tad-300 hover:bg-hover-color"
|
||||
class="size-8.5 inline-flex flex items-center justify-center cursor-pointer text-g-600 dark:text-g-800 text-xl rounded transition duration-300 hover:bg-hover-color"
|
||||
:class="{ 'rounded-full': circle }"
|
||||
>
|
||||
<FaSvgIcon :icon="icon"></FaSvgIcon>
|
||||
|
||||
Reference in New Issue
Block a user