chore: 完成多批次代码优化与重构

- 重构工作流模块目录结构,迁移代码文件
- 修复类型断言空值安全问题,添加 ! 操作符
- 优化样式类名,替换 flex-cc 为标准 flex 工具类
- 更新路由标签简化文案,移除冗余注释
- 调整 ruff 配置,放宽行长度限制
- 更新 README 与多语言文案,优化项目描述
- 修复表单、图表组件的类型与样式问题
- 简化搜索表单、数据卡片的布局代码
This commit is contained in:
zhangtao
2026-06-20 05:31:46 +08:00
parent 82b742620b
commit d34d4a4c50
378 changed files with 4149 additions and 6375 deletions
@@ -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"
@@ -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" });
@@ -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(() => {
@@ -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,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;
@@ -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 {
@@ -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();
}
};
@@ -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);
@@ -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'
@@ -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)"
>
@@ -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, () =>
@@ -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>
@@ -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>
@@ -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);
}
}
}
@@ -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>
@@ -122,5 +122,5 @@ defineExpose({
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../fa-login";
</style>
@@ -203,5 +203,5 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../fa-login";
</style>
@@ -72,5 +72,5 @@ const qrPayload = computed(() => {
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../fa-login";
</style>
@@ -144,5 +144,5 @@ defineExpose({
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../fa-login";
</style>
@@ -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>