refactor: 大规模代码整理与功能优化

1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码
2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑
3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件
4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
zhangtao
2026-06-17 01:56:31 +08:00
parent 17b3cd0a4c
commit 73f2823692
500 changed files with 40763 additions and 26616 deletions
@@ -25,6 +25,8 @@ import { CanvasRenderer } from "echarts/renderers";
import { useResizeObserver } from "@vueuse/core";
defineOptions({ name: "FaECharts" });
// 按需注册组件
echarts.use([
RadarChart,
@@ -37,11 +39,16 @@ echarts.use([
LegendComponent,
]);
const props = defineProps<{
interface Props {
options: echarts.EChartsCoreOption;
width?: string;
height?: string;
}>();
}
const props = withDefaults(defineProps<Props>(), {
width: "100%",
height: "100%",
});
const chartRef = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
@@ -160,11 +160,13 @@ const props = withDefaults(defineProps<Props>(), {
imageConfig: () => ({ src: "", width: "12rem", bottom: "-3rem", right: "0" }),
});
interface Emits {
click: []; // 整体点击事件
buttonClick: []; // 按钮点击事件
}
// 定义组件事件
const emit = defineEmits<{
(e: "click"): void; // 整体点击事件
(e: "buttonClick"): void; // 按钮点击事件
}>();
const emit = defineEmits<Emits>();
// 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? "#fff");
@@ -41,15 +41,15 @@ import defaultIcon from "@imgs/3d/icon1.webp";
defineOptions({ name: "FaCardBanner" });
// 定义卡片横幅组件的属性接口
interface CardBannerProps {
interface Props {
/** 高度 */
height?: string;
/** 图片路径 */
image?: string;
/** 标题文本 */
title: string;
title?: string;
/** 描述文本 */
description: string;
description?: string;
/** 主按钮配置 */
button?: {
/** 是否显示 */
@@ -75,7 +75,7 @@ interface CardBannerProps {
}
// 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), {
withDefaults(defineProps<Props>(), {
height: "100%",
image: defaultIcon,
title: "",
@@ -96,11 +96,13 @@ withDefaults(defineProps<CardBannerProps>(), {
}),
});
interface Emits {
click: []; // 主按钮点击事件
cancel: []; // 取消按钮点击事件
}
// 定义组件事件
const emit = defineEmits<{
(e: "click"): void; // 主按钮点击事件
(e: "cancel"): void; // 取消按钮点击事件
}>();
const emit = defineEmits<Emits>();
// 主按钮点击处理函数
const handleClick = () => {
@@ -13,7 +13,9 @@ interface Props {
icon?: string;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), {
icon: "",
});
const attrs = useAttrs();
@@ -42,10 +42,25 @@
<script setup lang="ts">
import { ref } from "vue";
import { commentList, Comment } from "@/mock/temp/commentDetail";
import { type Comment } from "@/mock/temp/commentDetail";
import { ElMessage } from "element-plus";
const comments = commentList;
defineOptions({ name: "FaCommentWidget" });
interface Props {
comments?: Comment[];
}
withDefaults(defineProps<Props>(), {
comments: () => [],
});
interface Emits {
"add-comment": [comment: Comment];
"add-reply": [commentId: number, author: string, content: string];
}
const emit = defineEmits<Emits>();
const newComment = ref<Partial<Comment>>({
author: "",
@@ -60,7 +75,7 @@ const addComment = () => {
return;
}
comments.value.push({
emit("add-comment", {
id: Date.now(),
author: newComment.value.author.trim(),
content: newComment.value.content.trim(),
@@ -79,34 +94,12 @@ const addReply = (commentId: number, replyAuthor: string, replyContent: string)
return;
}
const comment = findComment(comments.value, commentId);
if (comment) {
comment.replies.push({
id: Date.now(),
author: replyAuthor.trim(),
content: replyContent.trim(),
timestamp: new Date().toISOString(),
replies: [],
});
showReplyForm.value = null;
ElMessage.success("回复发布成功");
}
emit("add-reply", commentId, replyAuthor.trim(), replyContent.trim());
showReplyForm.value = null;
ElMessage.success("回复发布成功");
};
const toggleReply = (commentId: number) => {
showReplyForm.value = showReplyForm.value === commentId ? null : commentId;
};
const findComment = (comments: Comment[], commentId: number): Comment | undefined => {
for (const comment of comments) {
if (comment.id === commentId) {
return comment;
}
const found = findComment(comment.replies, commentId);
if (found) {
return found;
}
}
return undefined;
};
</script>
@@ -61,6 +61,8 @@
import AppConfig from "@/config";
import { ref } from "vue";
defineOptions({ name: "FaCommentItem" });
interface Comment {
id: number;
author: string;
@@ -69,15 +71,20 @@ interface Comment {
replies: Comment[];
}
const props = defineProps<{
interface Props {
/** 评论内容 */
comment: Comment;
showReplyForm: number | null;
}>();
}
const emit = defineEmits<{
(event: "toggle-reply", commentId: number): void;
(event: "add-reply", commentId: number, replyAuthor: string, replyContent: string): void;
}>();
const props = defineProps<Props>();
interface Emits {
"toggle-reply": [commentId: number];
"add-reply": [commentId: number, replyAuthor: string, replyContent: string];
}
const emit = defineEmits<Emits>();
const replyAuthor = ref("");
const replyContent = ref("");
@@ -30,32 +30,33 @@ import { computed } from "vue";
defineOptions({ name: "FaCardList" });
const props = withDefaults(
defineProps<{
items: any[];
total?: number;
pageSize?: number;
currentPage?: number;
loading?: boolean;
columns?: number;
keyField?: string;
emptyText?: string;
}>(),
{
total: 0,
pageSize: 12,
currentPage: 1,
loading: false,
columns: 4,
keyField: "id",
emptyText: "暂无数据",
}
);
interface Props {
items: any[];
total?: number;
pageSize?: number;
currentPage?: number;
loading?: boolean;
columns?: number;
keyField?: string;
emptyText?: string;
}
defineEmits<{
const props = withDefaults(defineProps<Props>(), {
total: 0,
pageSize: 12,
currentPage: 1,
loading: false,
columns: 4,
keyField: "id",
emptyText: "暂无数据",
});
interface Emits {
pageChange: [page: number];
itemClick: [item: any];
}>();
}
defineEmits<Emits>();
const gridClass = computed(() => {
const cols = {
@@ -1,6 +1,6 @@
<!-- 数据列表卡片 -->
<template>
<div class="fa-card p-5 h-[28.2rem] overflow-hidden">
<div class="fa-card p-5 h-110 overflow-hidden">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
@@ -66,10 +66,12 @@ const props = withDefaults(defineProps<Props>(), {
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`);
const emit = defineEmits<{
interface Emits {
/** 点击更多按钮事件 */
(e: "more"): void;
}>();
more: [];
}
const emit = defineEmits<Emits>();
const handleMore = () => emit("more");
</script>
@@ -53,9 +53,9 @@ defineOptions({ name: "FaImageCard" });
interface Props {
/** 图片地址 */
imageUrl: string;
imageUrl?: string;
/** 标题 */
title: string;
title?: string;
/** 分类 */
category?: string;
/** 阅读时间 */
@@ -78,9 +78,11 @@ const props = withDefaults(defineProps<Props>(), {
date: "",
});
const emit = defineEmits<{
(e: "click", card: Props): void;
}>();
interface Emits {
click: [card: Props];
}
const emit = defineEmits<Emits>();
const handleClick = () => {
emit("click", props);
@@ -36,7 +36,7 @@
<script setup lang="ts">
defineOptions({ name: "FaStatsCard" });
interface StatsCardProps {
interface Props {
/** 盒子样式 */
boxStyle?: string;
/** 图标 */
@@ -57,9 +57,13 @@ interface StatsCardProps {
textColor?: string;
/** 是否显示箭头 */
showArrow?: boolean;
/** 图标大小 */
iconSize?: number;
/** 图标背景圆角 */
iconBgRadius?: number;
}
withDefaults(defineProps<StatsCardProps>(), {
withDefaults(defineProps<Props>(), {
iconSize: 30,
iconBgRadius: 50,
decimals: 0,
@@ -1,6 +1,6 @@
<!-- 时间轴列表卡片 -->
<template>
<div class="fa-card p-5 h-[28.2rem] overflow-hidden">
<div class="fa-card p-5 h-110 overflow-hidden">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
@@ -50,7 +50,7 @@ interface Props {
/** 时间轴列表数据 */
list: TimelineItem[];
/** 标题 */
title: string;
title?: string;
/** 副标题 */
subtitle?: string;
/** 最大显示数量 */
@@ -34,11 +34,13 @@ const props = withDefaults(defineProps<MapChartProps & { dynamic?: boolean }>(),
dynamic: false,
});
// 定义 emit
const emit = defineEmits<{
interface Emits {
renderComplete: [];
regionClick: [region: { name: string; adcode: string; level: string }];
}>();
}
// 定义 emit
const emit = defineEmits<Emits>();
// 检查是否为空数据
const isEmpty = computed(() => {
@@ -45,9 +45,11 @@ const hasAnyAuthItem = computed(() => {
return props.list.some((item) => !item.auth || hasAuth(item.auth));
});
const emit = defineEmits<{
(e: "click", item: ButtonMoreItem): void;
}>();
interface Emits {
click: [item: ButtonMoreItem];
}
const emit = defineEmits<Emits>();
const handleClick = (item: ButtonMoreItem) => {
emit("click", item);
@@ -30,9 +30,11 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<{
(e: "click"): void;
}>();
interface Emits {
click: [];
}
const emit = defineEmits<Emits>();
// 默认按钮配置
const defaultButtons = {
@@ -46,7 +46,7 @@ defineOptions({ name: "FaDragVerify" });
const emit = defineEmits(["handlerMove", "update:value", "passCallback"]);
// 组件属性接口定义
interface PropsType {
interface Props {
/** 是否通过验证 */
value: boolean;
/** 组件宽度 */
@@ -80,7 +80,7 @@ interface PropsType {
}
// 属性默认值设置
const props = withDefaults(defineProps<PropsType>(), {
const props = withDefaults(defineProps<Props>(), {
value: false,
width: "100%",
height: 40,
@@ -46,7 +46,7 @@ interface ColumnConfig {
}
/** 导出配置选项 */
interface ExportOptions {
interface Props {
/** 数据源 */
data: ExportData[];
/** 文件名(不含扩展名) */
@@ -90,7 +90,7 @@ interface ExportOptions {
};
}
const props = withDefaults(defineProps<ExportOptions>(), {
const props = withDefaults(defineProps<Props>(), {
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
sheetName: "Sheet1",
type: "primary",
@@ -108,12 +108,14 @@ const props = withDefaults(defineProps<ExportOptions>(), {
workbookOptions: () => ({}),
});
const emit = defineEmits<{
interface Emits {
"before-export": [data: ExportData[]];
"export-success": [filename: string, rowCount: number];
"export-error": [error: ExportError];
"export-progress": [progress: number];
}>();
}
const emit = defineEmits<Emits>();
/** 导出错误类型 */
class ExportError extends Error {
@@ -3,12 +3,13 @@
<div class="inline-block">
<ElUpload
:auto-upload="false"
accept=".xlsx, .xls"
:accept="accept"
:show-file-list="false"
:disabled="disabled"
@change="handleFileChange"
>
<ElButton type="primary" v-ripple>
<slot>导入 Excel</slot>
<ElButton type="primary" v-ripple :loading="loading">
<slot>{{ buttonText }}</slot>
</ElButton>
</ElUpload>
</div>
@@ -20,6 +21,24 @@ import type { UploadFile } from "element-plus";
defineOptions({ name: "FaExcelImport" });
interface Props {
/** 接受的文件类型 */
accept?: string;
/** 按钮文本 */
buttonText?: string;
/** 加载状态 */
loading?: boolean;
/** 是否禁用 */
disabled?: boolean;
}
withDefaults(defineProps<Props>(), {
accept: ".xlsx, .xls",
buttonText: "导入 Excel",
loading: false,
disabled: false,
});
// Excel 导入工具函数
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
@@ -44,10 +63,12 @@ async function importExcel(file: File): Promise<Array<Record<string, unknown>>>
}
// 定义 emits
const emit = defineEmits<{
interface Emits {
"import-success": [data: Array<Record<string, unknown>>];
"import-error": [error: Error];
}>();
}
const emit = defineEmits<Emits>();
// 处理文件导入
const handleFileChange = async (uploadFile: UploadFile) => {
@@ -277,7 +277,7 @@ export interface FormItem {
}
// 表单配置
interface FormProps {
interface Props {
/** 表单数据 */
items: FormItem[];
/** 每列的宽度(基于 24 格布局) */
@@ -319,7 +319,7 @@ interface SanitizeOutputOptions {
keepFalse: boolean;
}
const props = withDefaults(defineProps<FormProps>(), {
const props = withDefaults(defineProps<Props>(), {
items: () => [],
span: 6,
gutter: 12,
@@ -334,12 +334,12 @@ const props = withDefaults(defineProps<FormProps>(), {
maxHeight: "75vh",
});
interface FormEmits {
interface Emits {
reset: [];
submit: [Record<string, any>];
}
const emit = defineEmits<FormEmits>();
const emit = defineEmits<Emits>();
const modelValue = defineModel<Record<string, any>>({ default: {} });
const initialModelValue = ref<Record<string, any>>({});
@@ -520,9 +520,20 @@ const getSanitizedOutput = () => {
};
const getProps = (item: FormItem) => {
if (item.props) return item.props;
const props = { ...item };
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
let props: Record<string, any>;
if (item.props) {
props = { ...item.props };
} else {
props = { ...item };
rootProps.forEach((key) => delete props[key]);
}
// 对于日期选择器组件,确保 type 被传递给 FaDatePicker
const datePickerTypes = ["date", "daterange", "datetime", "datetimerange", "monthrange"];
if (item.type && datePickerTypes.includes(item.type) && !props.type) {
props.type = item.type;
}
return props;
};
@@ -31,24 +31,29 @@ type DatePickerType =
| "daterange"
| "monthrange";
const props = withDefaults(
defineProps<{
modelValue?: any;
type?: DatePickerType;
format?: string;
rangeSeparator?: string;
startPlaceholder?: string;
endPlaceholder?: string;
}>(),
{
type: "datetimerange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
}
);
defineOptions({ name: "FaDatePicker" });
const emit = defineEmits(["update:model-value"]);
interface Props {
modelValue?: any;
type?: DatePickerType;
format?: string;
rangeSeparator?: string;
startPlaceholder?: string;
endPlaceholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
type: "datetimerange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
});
interface Emits {
(e: "update:model-value", value: any): void;
}
const emit = defineEmits<Emits>();
/** 是否为范围选择模式 */
const isRangeType = computed(() => {
@@ -47,22 +47,23 @@ import {
defineOptions({ name: "FaSearchBarWithAudit", inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 仅业务条件表单项,不含审计四字段 */
items: SearchFormItem[];
/** 为 false 时与原生 FaSearchBar 一致,仅使用 `items` */
includeAudit?: boolean;
/** 传给 getAuditSearchFormItems 的选项 */
auditItemOptions?: GetAuditSearchFormItemsOptions;
}>(),
{ includeAudit: true }
);
interface Props {
/** 仅业务条件表单项,不含审计四字段 */
items: SearchFormItem[];
/** 为 false 时与原生 FaSearchBar 一致,仅使用 `items` */
includeAudit?: boolean;
/** 传给 getAuditSearchFormItems 的选项 */
auditItemOptions?: GetAuditSearchFormItemsOptions;
}
const emit = defineEmits<{
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();
@@ -18,14 +18,20 @@
import type { ISelectConfig } from "@/components/others/fa-table-select/index.vue";
import UserAPI, { UserPageQuery } from "@/api/module_system/user";
// 父组件双向绑定的值(选中用户ID)
const props = defineProps<{ modelValue?: number }>();
defineOptions({ name: "FaUserTableSelect" });
// 事件向上派发,支持父组件监听和 v-model(仅回传选中用户ID)
const emit = defineEmits<{
interface Props {
modelValue?: number;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
confirmClick: [data: IUser[]];
"update:modelValue": [val: number | undefined];
}>();
}
const emit = defineEmits<Emits>();
const selectConfig: ISelectConfig = {
pk: "id",
@@ -190,7 +190,7 @@ export interface SearchFormItem {
}
// 表单配置
interface SearchBarProps {
interface Props {
/** 表单数据 */
items: SearchFormItem[];
/** 每列的宽度(基于 24 格布局) */
@@ -234,7 +234,7 @@ interface SanitizeOutputOptions {
keepFalse: boolean;
}
const props = withDefaults(defineProps<SearchBarProps>(), {
const props = withDefaults(defineProps<Props>(), {
items: () => [],
span: 6,
gutter: 12,
@@ -250,12 +250,12 @@ const props = withDefaults(defineProps<SearchBarProps>(), {
sanitizeOutput: () => ({}),
});
interface SearchBarEmits {
interface Emits {
reset: [];
search: [Record<string, any>];
}
const emit = defineEmits<SearchBarEmits>();
const emit = defineEmits<Emits>();
const modelValue = defineModel<Record<string, any>>({ default: {} });
const initialModelValue = ref<Record<string, any>>({});
@@ -75,16 +75,28 @@
<template #footer>
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
<div class="flex-cc">
<FaSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
<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">
<FaSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
<FaSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
<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)]"
/>
<FaSvgIcon
icon="ri:arrow-down-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)]"
/>
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.switchKeydown") }}</span>
</div>
<div class="flex-c">
<i class="keyboard w-8! flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
<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"
><p class="text-[10px] font-medium">ESC</p></i
>
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.exitKeydown") }}</span>
</div>
</div>
@@ -409,21 +421,3 @@ const highlightOnHoverHistory = (index: number) => {
}
}
</style>
<style scoped>
@reference '@styles/core/tailwind.css';
.keyboard {
@apply 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)]
last-of-type:mr-1.5;
}
</style>
@@ -249,8 +249,17 @@ const configStore = useConfigStore();
const activeTabRef = ref("apiWhitelist");
// 与父组件的 v-model 同步
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits(["update:modelValue"]);
interface Props {
modelValue: boolean;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
(e: "update:modelValue", value: boolean): void;
}
const emit = defineEmits<Emits>();
const drawerVisible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit("update:modelValue", val),
@@ -0,0 +1,113 @@
<!-- 租户切换器(顶栏版):头像左侧,始终可见 -->
<template>
<ElDropdown
v-if="tenantList.length > 1"
trigger="click"
@command="handleSwitch"
placement="bottom"
>
<span class="tenant-btn">
<span class="tenant-icon">🏢</span>
<span class="tenant-name">{{ currentTenantName }}</span>
<FaSvgIcon icon="ri:arrow-down-s-line" class="arrow" />
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="t in tenantList"
:key="t.id"
:command="t.id"
:class="{ 'is-active': t.id === currentTenant?.id }"
>
<span class="dropdown-row">
<span>{{ t.name }}</span>
<FaSvgIcon v-if="t.id === currentTenant?.id" icon="ri:check-line" class="check" />
</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useUserStore } from "@stores";
import { storeToRefs } from "pinia";
defineOptions({ name: "FaTenantSwitcher" });
const userStore = useUserStore();
const { tenantList, currentTenant } = storeToRefs(userStore);
const currentTenantName = computed(
() => currentTenant.value?.name || tenantList.value[0]?.name || "—"
);
async function handleSwitch(tenantId: number) {
if (tenantId === currentTenant.value?.id) return;
try {
await userStore.selectTenant(tenantId);
setTimeout(() => window.location.reload(), 200);
} catch {
// 静默失败
}
}
</script>
<style scoped>
.tenant-btn {
display: inline-flex;
gap: 4px;
align-items: center;
max-width: 160px;
height: 30px;
padding: 0 10px 0 6px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
background: var(--fa-gray-100);
border: 1px solid var(--fa-gray-300);
border-radius: 6px;
transition:
background 0.15s,
border-color 0.15s;
}
.tenant-btn:hover {
background: var(--fa-gray-200);
border-color: var(--el-color-primary-light-5);
}
.tenant-icon {
flex-shrink: 0;
font-size: 15px;
line-height: 1;
}
.tenant-name {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
.arrow {
flex-shrink: 0;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.dropdown-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
width: 100%;
}
.check {
flex-shrink: 0;
color: var(--el-color-primary);
}
</style>
@@ -58,7 +58,7 @@
<ul class="py-4 mt-3 border-t border-g-300/80">
<li
v-if="tenantList.length > 1"
class="btn-item flex-col! items-start! mb-4 p-2! bg-(--fa-gray-100) rounded-lg"
class="flex select-none cursor-pointer last:mb-0 hover:bg-(--fa-gray-200) flex-col! items-start! mb-4 p-2! rounded-lg bg-(--fa-gray-100)"
>
<span class="text-xs text-g-500 mb-2 block w-full">当前租户</span>
<ElDropdown trigger="click" @command="handleTenantSwitch">
@@ -87,28 +87,46 @@
</template>
</ElDropdown>
</li>
<li class="btn-item" @click="goPage('/fastlink/profile')">
<FaSvgIcon icon="ri:user-3-line" />
<span>{{ $t("topBar.user.userCenter") }}</span>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="goPage('/fastlink/profile')"
>
<FaSvgIcon icon="ri:user-3-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.userCenter") }}</span>
</li>
<li class="btn-item" @click="openParamConfig">
<FaSvgIcon icon="ri:settings-3-line" />
<span>{{ $t("topBar.user.paramConfig") }}</span>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="openParamConfig"
>
<FaSvgIcon icon="ri:settings-3-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.paramConfig") }}</span>
</li>
<li class="btn-item" @click="toGithub()">
<FaSvgIcon icon="ri:github-line" />
<span>{{ $t("topBar.user.github") }}</span>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="toGithub()"
>
<FaSvgIcon icon="ri:github-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.github") }}</span>
</li>
<li class="btn-item" @click="toGitee">
<FaSvgIcon icon="ri:git-branch-line" />
<span>{{ $t("topBar.user.gitee") }}</span>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="toGitee"
>
<FaSvgIcon icon="ri:git-branch-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.gitee") }}</span>
</li>
<li class="btn-item" @click="lockScreen()">
<FaSvgIcon icon="ri:lock-line" />
<span>{{ $t("topBar.user.lockScreen") }}</span>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="lockScreen()"
>
<FaSvgIcon icon="ri:lock-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.lockScreen") }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<li class="btn-item btn-item--logout" @click="handleLogout">
<li
class="flex p-2 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200) justify-center mt-5 mb-0 py-1.5 text-xs border border-g-400 hover:text-(--el-color-danger) hover:border-(--el-color-danger-light-3)"
@click="handleLogout"
>
{{ $t("topBar.user.logout") }}
</li>
</ul>
@@ -213,37 +231,6 @@ function closeUserMenu(): void {
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
@layer components {
.btn-item {
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
span {
@apply text-sm;
}
.fa-svg-icon {
@apply mr-2 text-base;
}
&:hover {
background-color: var(--fa-gray-200);
}
}
/** 退出:沿用菜单项 hover 底纹,并加重边框/文字,避免仅 shadow 几乎无反馈 */
.btn-item.btn-item--logout {
@apply justify-center mt-5 mb-0 py-1.5 text-xs border border-g-400;
&:hover {
color: var(--el-color-danger);
background-color: var(--fa-gray-200);
border-color: var(--el-color-danger-light-3);
}
}
}
/* ElPopover 基于 Tooltip:触发层默认 inline-block,与顶栏 flex 图标中线对齐 */
.fa-user-menu .el-tooltip__trigger {
display: inline-flex !important;
@@ -45,29 +45,29 @@
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { computed } from "vue";
import { AppRouteRecord } from "@/types/router";
import { handleMenuJump, formatMenuTitle } from "@utils";
defineOptions({ name: "FaHorizontalSubmenu" });
const props = defineProps({
item: {
type: Object as PropType<AppRouteRecord>,
required: true,
},
theme: {
type: Object,
default: () => ({}),
},
isMobile: Boolean,
level: {
type: Number,
default: 0,
},
interface Props {
item: AppRouteRecord;
theme?: Record<string, any>;
isMobile?: boolean;
level?: number;
}
const props = withDefaults(defineProps<Props>(), {
theme: () => ({}),
level: 0,
});
const emit = defineEmits(["close"]);
interface Emits {
close: [];
}
const emit = defineEmits<Emits>();
// 过滤后的子菜单项(不包含隐藏的)
const filteredChildren = computed(() => {
@@ -223,28 +223,6 @@ const initScrollState = (): void => {
onMounted(initScrollState);
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button-arrow {
@apply absolute
top-1/2
z-2
flex
items-center
justify-center
size-7.5
text-g-600
cursor-pointer
rounded
transition-all
duration-300
-translate-y-1/2
hover:text-g-900
hover:bg-g-200;
}
</style>
<style scoped>
:deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
@@ -1,7 +1,7 @@
<!-- 通知组件 -->
<template>
<div
class="fa-notification-panel fa-card-sm shadow-xl!"
class="absolute top-14.5 right-5 w-90 h-125 overflow-hidden transition-all duration-300 origin-top will-change-[top,left] max-[640px]:top-[65px] max-[640px]:right-0 max-[640px]:w-full max-[640px]:h-[80vh] fa-card-sm shadow-xl!"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0,
@@ -156,13 +156,17 @@ type NoticeType = "email" | "message" | "collection" | "user" | "notice";
const { t } = useI18n();
const props = defineProps<{
interface Props {
value: boolean;
}>();
}
const emit = defineEmits<{
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:value": [value: boolean];
}>();
}
const emit = defineEmits<Emits>();
const show = ref(false);
const visible = ref(false);
@@ -390,25 +394,6 @@ watch(
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.fa-notification-panel {
@apply absolute
top-14.5
right-5
w-90
h-125
overflow-hidden
transition-all
duration-300
origin-top
will-change-[top,left]
max-[640px]:top-[65px]
max-[640px]:right-0
max-[640px]:w-full
max-[640px]:h-[80vh];
}
.bar-active {
color: var(--theme-color) !important;
border-bottom: 2px solid var(--theme-color);
@@ -48,7 +48,7 @@
:bottom="28"
class="z-90"
>
<FaSvgIcon icon="ri:arrow-up-circle-line" class="text-2xl text-g-600" />
<FaSvgIcon icon="ri:arrow-up-circle-line" class="text-2xl text-theme" />
</ElBacktop>
</div>
</template>
@@ -147,6 +147,8 @@
<script setup lang="ts">
import { Lock } from "@element-plus/icons-vue";
defineOptions({ name: "FaScreenLock" });
import type { FormInstance, FormRules } from "element-plus";
import { ElInput } from "element-plus";
import { useI18n } from "vue-i18n";
@@ -34,7 +34,9 @@ interface Props {
open?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
open: false,
});
// 使用设置面板逻辑
const settingsPanel = useSettingsPanel();
@@ -13,6 +13,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaBasicSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
@@ -21,6 +21,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaBoxStyleSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
@@ -23,6 +23,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaColorSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
@@ -21,6 +21,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaContainerSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
@@ -19,6 +19,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaMenuLayoutSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsState } from "../composables/useSettingsState";
@@ -22,6 +22,8 @@
<script setup lang="ts">
import AppConfig from "@/config";
defineOptions({ name: "FaMenuStyleSettings" });
import { MenuTypeEnum, type MenuThemeEnum } from "@/enums/appEnum";
import { useSettingsStore } from "@stores";
@@ -8,10 +8,14 @@
</template>
<script setup lang="ts">
defineOptions({ name: "FaSectionTitle" });
interface Props {
title: string;
style?: Record<string, any>;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), {
style: () => ({}),
});
</script>
@@ -19,6 +19,8 @@
</template>
<script setup lang="ts">
defineOptions({ name: "FaSettingDrawer" });
interface Props {
modelValue: boolean;
}
@@ -29,7 +31,7 @@ interface Emits {
(e: "close"): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const visible = computed({
@@ -12,7 +12,11 @@
</template>
<script setup lang="ts">
defineEmits<{
defineOptions({ name: "FaSettingHeader" });
interface Emits {
close: [];
}>();
}
defineEmits<Emits>();
</script>
@@ -37,6 +37,8 @@
<script setup lang="ts">
import type { ComputedRef } from "vue";
defineOptions({ name: "FaSettingItem" });
interface SettingItemConfig {
key: string;
label: string;
@@ -62,7 +64,7 @@ interface Emits {
(e: "change", value: any): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
// 标准化选项,处理computed和普通数组
@@ -17,6 +17,8 @@
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaThemeSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useTheme } from "@/hooks/core/useTheme";
@@ -49,7 +49,7 @@ import ImgCutter from "vue-img-cutter";
defineOptions({ name: "FaCutterImg" });
interface CutterProps {
interface Props {
// 基础配置
/** 是否模态框 */
isModal?: boolean;
@@ -119,7 +119,7 @@ interface CutterResult {
dataURL: string;
}
const props = withDefaults(defineProps<CutterProps>(), {
const props = withDefaults(defineProps<Props>(), {
// 基础配置默认值
isModal: false,
tool: true,
@@ -156,7 +156,14 @@ const props = withDefaults(defineProps<CutterProps>(), {
previewMode: true,
});
const emit = defineEmits(["update:imgUrl", "error", "imageLoadComplete", "imageLoadError"]);
interface Emits {
"update:imgUrl": [value: string];
error: [error: any];
imageLoadComplete: [result: CutterResult];
imageLoadError: [error: any];
}
const emit = defineEmits<Emits>();
const temImgPath = ref("");
const imgCutterModal = ref();
@@ -11,11 +11,11 @@ defineOptions({ name: "FaVideoPlayer" });
interface Props {
/** 播放器容器 ID */
playerId: string;
playerId?: string;
/** 视频源URL */
videoUrl: string;
videoUrl?: string;
/** 视频封面图URL */
posterUrl: string;
posterUrl?: string;
/** 是否自动播放 */
autoplay?: boolean;
/** 音量大小(0-1) */
@@ -54,34 +54,33 @@ import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
defineOptions({ name: "FaDialog", inheritAttrs: false });
const props = withDefaults(
defineProps<{
modelValue: boolean;
title?: string;
width?: string | number;
/** 默认可拖拽;全屏时 Element Plus 会限制拖拽 */
draggable?: boolean;
/** 透传到 el-dialog 的 class */
dialogClass?: string;
/** 遮罩层自定义 class */
modalClass?: string;
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
formMode?: "detail" | "create" | "update";
/** 确定按钮 loading 状态 */
confirmLoading?: boolean;
/** 确定按钮文本 */
confirmText?: string;
/** 取消按钮文本 */
cancelText?: string;
}>(),
{
draggable: true,
confirmText: "确定",
cancelText: "取消",
}
);
interface Props {
modelValue: boolean;
title?: string;
width?: string | number;
/** 默认可拖拽;全屏时 Element Plus 会限制拖拽 */
draggable?: boolean;
/** 透传到 el-dialog 的 class */
dialogClass?: string;
/** 遮罩层自定义 class */
modalClass?: string;
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
formMode?: "detail" | "create" | "update";
/** 确定按钮 loading 状态 */
confirmLoading?: boolean;
/** 确定按钮文本 */
confirmText?: string;
/** 取消按钮文本 */
cancelText?: string;
}
const emit = defineEmits<{
const props = withDefaults(defineProps<Props>(), {
draggable: true,
confirmText: "确定",
cancelText: "取消",
});
interface Emits {
"update:modelValue": [v: boolean];
close: [];
opened: [];
@@ -90,7 +89,9 @@ const emit = defineEmits<{
cancel: [];
/** 点击确定按钮 */
confirm: [];
}>();
}
const emit = defineEmits<Emits>();
const attrs = useAttrs();
const fullscreen = ref(false);
@@ -48,31 +48,30 @@ import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
defineOptions({ name: "FaDrawer", inheritAttrs: false });
const props = withDefaults(
defineProps<{
modelValue: boolean;
title?: string;
size?: string | number;
direction?: "rtl" | "ltr" | "ttb" | "btt";
/** 透传到 el-drawer 的 class */
drawerClass?: string;
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
formMode?: "detail" | "create" | "update";
/** 确定按钮 loading 状态 */
confirmLoading?: boolean;
/** 确定按钮文本 */
confirmText?: string;
/** 取消按钮文本 */
cancelText?: string;
}>(),
{
direction: "rtl",
confirmText: "确定",
cancelText: "取消",
}
);
interface Props {
modelValue: boolean;
title?: string;
size?: string | number;
direction?: "rtl" | "ltr" | "ttb" | "btt";
/** 透传到 el-drawer 的 class */
drawerClass?: string;
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
formMode?: "detail" | "create" | "update";
/** 确定按钮 loading 状态 */
confirmLoading?: boolean;
/** 确定按钮文本 */
confirmText?: string;
/** 取消按钮文本 */
cancelText?: string;
}
const emit = defineEmits<{
const props = withDefaults(defineProps<Props>(), {
direction: "rtl",
confirmText: "确定",
cancelText: "取消",
});
interface Emits {
"update:modelValue": [v: boolean];
close: [];
opened: [];
@@ -80,7 +79,9 @@ const emit = defineEmits<{
cancel: [];
/** 点击确定按钮 */
confirm: [];
}>();
}
const emit = defineEmits<Emits>();
const attrs = useAttrs();
@@ -85,7 +85,7 @@ function saveBlobDownload(blob: Blob, rawName: string) {
/**
* 导出模态框组件属性定义
*/
interface FaExportDialogProps {
interface Props {
/** 内容配置 */
contentConfig: Pick<
IContentConfig,
@@ -100,7 +100,7 @@ interface FaExportDialogProps {
}
// 定义接收的属性
const props = defineProps<FaExportDialogProps>();
const props = withDefaults(defineProps<Props>(), {});
const remoteExportEnabled = computed(
() => !!(props.contentConfig.exportsAction || props.contentConfig.exportsBlobAction)
@@ -85,7 +85,7 @@ defineOptions({ name: "FaImportDialog", inheritAttrs: false });
/**
* 导入模态框组件属性定义
*/
interface FaImportDialogProps {
interface Props {
/**
* 弹窗标题
*/
@@ -178,7 +178,7 @@ interface FaImportDialogProps {
}
// 定义props
const props = withDefaults(defineProps<FaImportDialogProps>(), {
const props = withDefaults(defineProps<Props>(), {
title: "导入数据",
width: "600px",
maxHeight: "60vh",
@@ -198,8 +198,7 @@ const importModalVisible = defineModel<boolean>("modelValue", {
default: false,
});
// 定义事件
const emit = defineEmits<{
interface Emits {
/** 导入成功事件 */
"import-success": [data: any];
/** 导入失败事件 */
@@ -210,7 +209,10 @@ const emit = defineEmits<{
"download-template": [];
/** 上传事件 */
upload: [formData: FormData, file: File];
}>();
}
// 定义事件
const emit = defineEmits<Emits>();
// 引用
const importFormRef = ref(null);
@@ -17,15 +17,14 @@ defineOptions({
const { t } = useI18n();
const props = defineProps({
text: {
type: String,
default: "",
},
style: {
type: Object,
default: () => ({}),
},
interface Props {
text?: string;
style?: Record<string, any>;
}
const props = withDefaults(defineProps<Props>(), {
text: "",
style: () => ({}),
});
async function handleClipboard() {
@@ -13,7 +13,6 @@
:label-class-name="item.labelClassName"
:class-name="item.className"
>
<!-- 带 slot 名称的项:通过具名插槽自定义 -->
<slot
v-if="item.slot"
:name="item.slot"
@@ -21,22 +20,12 @@
:value="data ? getNestedValue(data, item.prop) : undefined"
:row="data"
/>
<!-- Tag 模式 -->
<template v-else-if="item.tag">
<ElTag
v-if="item.tag === true && data && data[item.prop] !== undefined"
:type="resolveTagType(getNestedValue(data, item.prop))"
>
{{ getNestedValue(data, item.prop) }}
</ElTag>
<ElTag
v-else-if="typeof item.tag === 'object' && data"
:type="resolveTagType(getNestedValue(data, item.prop), item.tag.type)"
>
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
</ElTag>
</template>
<!-- 纯文本模式 -->
<ElTag
v-else-if="item.tag != null"
:type="resolveTagType(getNestedValue(data, item.prop), item.tag)"
>
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
</ElTag>
<template v-else>
<slot
:name="item.prop"
@@ -49,13 +38,11 @@
</template>
</ElDescriptionsItem>
</template>
<!-- 完全自定义模式 -->
<template v-else>
<slot />
</template>
</ElDescriptions>
</ElScrollbar>
<!-- 不使用滚动条时直接渲染 -->
<ElDescriptions v-else v-bind="bindings" :class="ns.b()">
<template v-if="$slots.title" #title>
<slot name="title" />
@@ -76,20 +63,12 @@
:value="data ? getNestedValue(data, item.prop) : undefined"
:row="data"
/>
<template v-else-if="item.tag">
<ElTag
v-if="item.tag === true && data && data[item.prop] !== undefined"
:type="resolveTagType(getNestedValue(data, item.prop))"
>
{{ getNestedValue(data, item.prop) }}
</ElTag>
<ElTag
v-else-if="typeof item.tag === 'object' && data"
:type="resolveTagType(getNestedValue(data, item.prop), item.tag.type)"
>
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
</ElTag>
</template>
<ElTag
v-else-if="item.tag != null"
:type="resolveTagType(getNestedValue(data, item.prop), item.tag)"
>
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
</ElTag>
<template v-else>
<slot
:name="item.prop"
@@ -113,94 +92,62 @@ defineOptions({ name: "FaDescriptions" });
import { computed, useAttrs } from "vue";
import { useNamespace } from "element-plus";
// ── 组件类型定义(内联,避免跨文件引用导致 TS Server 延迟)──
/** ElTag 可接受的 type 值 */
export type TagType = "primary" | "success" | "warning" | "danger" | "info";
/** Tag 渲染配置 */
export interface TagConfig {
/**
* Tag 映射表:key 为数据值,value 为 { type: ElTag类型, text: 显示文本 }
* 例:{ '0': { type: 'success', text: '启用' }, '1': { type: 'danger', text: '停用' } }
*/
map?: Record<string, { type?: TagType; text?: string }>;
/** 默认 type,当 map 未匹配到时使用 */
type?: TagType;
}
/** 单个描述项配置 */
export interface DescriptionsItem {
/** 标签文本 */
label: string;
/** 对应 data 中的属性名,支持 'a.b.c' 嵌套路径 */
prop: string;
/** 列跨度,不传则使用组件级 span 默认值 */
span?: number;
/**
* 渲染模式:
* - 不设置:纯文本
* - true:作为 ElTag 渲染,文本为 data[prop]
* - TagConfig 对象:通过 map 映射类型和文本
*/
tag?: boolean | TagConfig;
/** 自定义插槽名称,设置后该 item 将通过具名插槽渲染 */
slot?: string;
/** 传递给 el-descriptions-item 的 label-class-name */
labelClassName?: string;
/** 传递给 el-descriptions-item 的 class-name */
className?: string;
}
const attrs = useAttrs();
const ns = useNamespace("descriptions");
const props = withDefaults(
defineProps<{
/** 每行显示的描述项数量,默认 4 */
column?: number;
/** 是否展示边框,默认 true */
border?: boolean;
/** 列表尺寸,默认 'default' */
size?: "default" | "small";
/** 标签宽度 */
labelWidth?: string;
/** 描述项配置列表 */
items?: DescriptionsItem[];
/** 数据对象,items 模式下自动取 data[item.prop] 渲染 */
data?: Record<string, unknown> | null;
/** 默认 span,当 item 未指定 span 时生效,默认 2 */
span?: number;
/** 是否需要内置 ElScrollbar 包裹,默认 true */
scrollbar?: boolean;
/** ElScrollbar 最大高度,默认 '70vh' */
maxHeight?: string;
}>(),
{
column: 4,
border: true,
size: "default",
labelWidth: undefined,
items: () => [],
data: null,
span: 2,
scrollbar: true,
maxHeight: "70vh",
}
);
interface Props {
column?: number;
border?: boolean;
size?: "default" | "small";
labelWidth?: string;
items?: DescriptionsItem[];
data?: Record<string, unknown> | null;
span?: number;
scrollbar?: boolean;
maxHeight?: string;
}
const props = withDefaults(defineProps<Props>(), {
column: 4,
border: true,
size: "default",
labelWidth: undefined,
items: () => [],
data: null,
span: 2,
scrollbar: true,
maxHeight: "70vh",
});
// 传递给 ElDescriptions 的绑定属性
const bindings = computed(() => {
const bind: Record<string, unknown> = {
column: props.column,
border: props.border,
...attrs, // 透传 class、style 等属性
...attrs,
};
if (props.size !== "default") bind.size = props.size;
if (props.labelWidth !== undefined) bind.labelWidth = props.labelWidth;
return bind;
});
/** 获取嵌套属性值,支持 'a.b.c' 路径 */
function getNestedValue(obj: Record<string, unknown> | null, path: string): unknown {
if (!obj) return undefined;
return path.split(".").reduce((acc, key) => {
@@ -211,22 +158,28 @@ function getNestedValue(obj: Record<string, unknown> | null, path: string): unkn
}, obj as unknown);
}
/** 解析 ElTag 的 type 属性,确保返回 ElTag 可接受的有效类型值 */
function resolveTagType(_value: unknown, fallback?: string): TagType {
// 优先级 1: fallback 本身就是合法 TagType → 直接使用
if (fallback && isValidTagType(fallback)) return fallback;
// 优先级 2: 无有效 fallback 时,用 'info' 作为兜底
function resolveTagType(value: unknown, tag: boolean | TagConfig): TagType {
if (typeof tag === "boolean") {
return tag ? "success" : "danger";
}
if (tag.map) {
const raw = value == null ? "" : String(value);
if (raw in tag.map) {
const t = tag.map[raw].type;
if (t && TAG_TYPES.has(t)) return t;
}
}
if (tag.type && TAG_TYPES.has(tag.type)) return tag.type;
return "info";
}
const TAG_TYPES: Set<string> = new Set(["primary", "success", "warning", "danger", "info"]);
function isValidTagType(v: string): v is TagType {
return TAG_TYPES.has(v);
}
/** 解析 ElTag 显示的文本,支持 tagMap 映射 */
function resolveTagText(value: unknown, tag: TagConfig): string {
function resolveTagText(value: unknown, tag: boolean | TagConfig): string {
const raw = value == null ? "" : String(value);
if (typeof tag === "boolean") {
return raw;
}
if (tag.map && raw in tag.map) {
return tag.map[raw].text ?? raw;
}
@@ -235,10 +188,6 @@ function resolveTagText(value: unknown, tag: TagConfig): string {
</script>
<style scoped>
/*
* fa-descriptions 在 ElScrollbar 内部包裹时,
* 需要占满宽度,避免 ElDescriptions 边框被截断。
*/
:deep(.fa-descriptions) {
width: 100%;
}
@@ -60,25 +60,32 @@
<script setup lang="ts">
defineOptions({ name: "FaGuide" });
import { computed, type PropType } from "vue";
import { computed } from "vue";
import { useSettingsStore } from "@stores";
import { MenuTypeEnum } from "@/enums/appEnum";
const settingStore = useSettingsStore();
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
teleport: {
type: [String, Object] as PropType<string | HTMLElement | null>,
default: "body",
},
interface Props {
modelValue?: boolean;
teleport?: string | HTMLElement | null;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
teleport: "body" as const,
});
const emit = defineEmits(["update:modelValue", "change", "prev", "next", "skip"]);
interface Emits {
"update:modelValue": [value: boolean];
change: [index: number];
prev: [];
next: [];
skip: [];
}
const emit = defineEmits<Emits>();
const open = computed({
get: () => props.modelValue,
@@ -89,18 +89,21 @@ import {
resolveIconForFaSvgIcon,
} from "@utils";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
width: {
type: String,
default: "500px",
},
interface Props {
modelValue?: string;
width?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: "",
width: "500px",
});
const emit = defineEmits(["update:modelValue"]);
interface Emits {
"update:modelValue": [value: string];
}
const emit = defineEmits<Emits>();
const iconSelectRef = ref();
const popoverContentRef = ref();
@@ -109,9 +112,7 @@ const activeTab = ref("svg");
const svgIcons = ref<string[]>([]);
const elementIcons = ref<string[]>(Object.keys(ElementPlusIconsVue));
const selectedIcon = defineModel("modelValue", {
type: String,
required: true,
const selectedIcon = defineModel<string | undefined>("modelValue", {
default: "",
});
@@ -81,15 +81,19 @@ interface WeekOption {
}
// 定义props
const props = defineProps<{
interface Props {
cronValue?: string;
}>();
}
const props = withDefaults(defineProps<Props>(), {});
// 定义emits
const emit = defineEmits<{
interface Emits {
(e: "confirm", value: string): void;
(e: "cancel"): void;
}>();
}
const emit = defineEmits<Emits>();
const formRef = ref();
@@ -20,9 +20,14 @@ import { computed } from "vue";
import VueJsonPretty from "vue-json-pretty";
import "vue-json-pretty/lib/styles.css";
const props = defineProps({
value: { type: [String, Object, Array, Number, Boolean], default: "" },
height: { type: String, default: "240px" },
interface Props {
value?: string | Record<string, any> | any[] | number | boolean;
height?: string;
}
const props = withDefaults(defineProps<Props>(), {
value: "",
height: "240px",
});
const parsed = computed(() => {
@@ -9,6 +9,8 @@ import markdownItHighlightjs from "markdown-it-highlightjs";
import hljs from "highlight.js";
import "highlight.js/styles/atom-one-light.css";
defineOptions({ name: "FaMarkdownRenderer" });
interface Props {
content: string;
maxLength?: number;
@@ -136,11 +136,13 @@ const props = withDefaults(defineProps<Props>(), {
animationDuration: 100,
});
const emit = defineEmits<{
interface Emits {
(e: "select", item: MenuItemType): void;
(e: "show"): void;
(e: "hide"): void;
}>();
}
const emit = defineEmits<Emits>();
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
@@ -33,13 +33,15 @@ import {
defineOptions({ name: "FaMenuRouteIcon", inheritAttrs: false });
const props = defineProps<{
interface Props {
icon?: string;
color?: string;
/** 与 Vue `class` 一致(含条件 `false`) */
class?: string | string[] | Record<string, boolean> | null | undefined | false;
style?: Record<string, unknown> | string;
}>();
}
const props = withDefaults(defineProps<Props>(), {});
const trimmedIcon = computed(() => props.icon?.trim() ?? "");
@@ -0,0 +1,415 @@
<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>
@@ -20,52 +20,41 @@
</template>
<script setup lang="ts">
import { watch, type PropType } from "vue";
import { watch } from "vue";
const props = defineProps({
total: {
type: Number as PropType<number>,
default: 0,
},
pageSizes: {
type: Array as PropType<number[]>,
default() {
return [10, 20, 30, 50, 100];
},
},
layout: {
type: String,
default: "total, sizes, prev, pager, next, jumper",
},
background: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
defineOptions({ name: "FaPagination" });
interface Props {
total?: number;
pageSizes?: number[];
layout?: string;
background?: boolean;
disabled?: boolean;
/** 页码按钮数量(透传 ElPagination) */
pagerCount: {
type: Number as PropType<number>,
default: undefined,
},
pagerCount?: number;
/** 分页器尺寸(透传 ElPagination) */
size: {
type: String as PropType<"" | "default" | "small" | "large">,
default: undefined,
},
autoScroll: {
type: Boolean,
default: true,
},
hidden: {
type: Boolean,
default: false,
},
size?: "" | "default" | "small" | "large";
autoScroll?: boolean;
hidden?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
total: 0,
pageSizes: () => [10, 20, 30, 50, 100],
layout: "total, sizes, prev, pager, next, jumper",
background: true,
disabled: false,
pagerCount: undefined,
size: undefined,
autoScroll: true,
hidden: false,
});
const emit = defineEmits(["pagination"]);
interface Emits {
pagination: [params: { page: number; limit: number }];
}
const emit = defineEmits<Emits>();
const currentPage = defineModel("page", {
type: Number,
@@ -9,19 +9,12 @@
* Widget 演示页章节标题(与历史 `page-title` Tailwind 一致)。
* 业务页如需同款排版可直接复用;CRUD 列表页优先用 FaSearchBar + 表格区标题即可。
*/
withDefaults(
defineProps<{
/** 页面唯一主标题用 h1,分区用 h2 */
tag?: "h1" | "h2";
}>(),
{ tag: "h1" }
);
</script>
defineOptions({ name: "FaShowcase" });
<style scoped>
@reference "@styles/core/tailwind.css";
.fa-widget-demo-title {
@apply my-5 text-xl font-medium first:mt-0;
interface Props {
/** 页面唯一主标题用 h1,分区用 h2 */
tag?: "h1" | "h2";
}
</style>
withDefaults(defineProps<Props>(), { tag: "h1" });
</script>
@@ -198,21 +198,22 @@ export interface ISelectConfig<T = any> {
[key: string]: any;
}>;
}
const props = withDefaults(
defineProps<{
selectConfig: ISelectConfig;
text?: string;
}>(),
{
text: "",
}
);
interface Props {
selectConfig: ISelectConfig;
text?: string;
}
const props = withDefaults(defineProps<Props>(), {
text: "",
});
// 自定义事件
const emit = defineEmits<{
interface Emits {
confirmClick: [selection: any[]];
clearClick: [];
}>();
}
const emit = defineEmits<Emits>();
// 主键
const pk = props.selectConfig.pk ?? "id";
@@ -83,134 +83,76 @@ import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } f
import ParamsAPI from "@/api/module_system/params";
import { dataURLToFile } from "@utils";
const props = defineProps({
interface Props {
/**
* 请求携带的额外参数
*/
data: {
type: Object,
default: () => {
return {};
},
},
data?: Record<string, any>;
/**
* 上传文件的参数名
*/
name: {
type: String,
default: "file",
},
name?: string;
/**
* 最大文件大小(单位:M)
*/
maxFileSize: {
type: Number,
default: 10,
},
maxFileSize?: number;
/**
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
*/
accept: {
type: String,
default: "image/*",
},
accept?: string;
/**
* 自定义样式,用于设置组件的宽度和高度等其他样式
*/
style: {
type: Object,
default: () => {
return {
width: "150px",
height: "150px",
};
},
},
style?: Record<string, any>;
/**
* 是否禁用
*/
disabled: {
type: Boolean,
default: false,
},
disabled?: boolean;
/**
* 是否显示提示信息
*/
showTip: {
type: Boolean,
default: false,
},
showTip?: boolean;
/**
* 提示文本
*/
tipText: {
type: String,
default: "",
},
tipText?: string;
/**
* 是否启用图片预览功能
*/
enablePreview: {
type: Boolean,
default: true,
},
enablePreview?: boolean;
/** 选图后先裁剪再上传(用于站点 Logo / 背景等) */
enableCrop: {
type: Boolean,
default: false,
},
enableCrop?: boolean;
cropBoxWidth?: number;
cropBoxHeight?: number;
cropCutWidth?: number;
cropCutHeight?: number;
cropQuality?: number;
cropFileType?: "png" | "jpeg" | "webp";
cropDialogTitle?: string;
cropInnerTitle?: string;
cropPreviewTitle?: string;
}
cropBoxWidth: {
type: Number,
default: 520,
},
cropBoxHeight: {
type: Number,
default: 360,
},
cropCutWidth: {
type: Number,
default: 400,
},
cropCutHeight: {
type: Number,
default: 300,
},
cropQuality: {
type: Number,
default: 0.92,
},
cropFileType: {
type: String as () => "png" | "jpeg" | "webp",
default: "jpeg",
},
cropDialogTitle: {
type: String,
default: "裁剪图片",
},
cropInnerTitle: {
type: String,
default: "调整图片",
},
cropPreviewTitle: {
type: String,
default: "预览",
},
const props = withDefaults(defineProps<Props>(), {
data: () => ({}),
name: "file",
maxFileSize: 10,
accept: "image/*",
style: () => ({ width: "150px", height: "150px" }),
disabled: false,
showTip: false,
tipText: "",
enablePreview: true,
enableCrop: false,
cropBoxWidth: 520,
cropBoxHeight: 360,
cropCutWidth: 400,
cropCutHeight: 300,
cropQuality: 0.92,
cropFileType: "jpeg",
cropDialogTitle: "裁剪图片",
cropInnerTitle: "调整图片",
cropPreviewTitle: "预览",
});
// 接收字符串类型的modelValue,保持与现有代码的兼容性
@@ -307,12 +249,14 @@ watch(
/**
* 定义组件触发的事件
*/
const emit = defineEmits<{
interface Emits {
(e: "success", fileInfo: UploadFilePath): void;
(e: "error", error: any): void;
(e: "input", value: string): void;
(e: "update:modelValue", value: string): void;
}>();
}
const emit = defineEmits<Emits>();
/**
* 限制用户上传文件的格式和大小
@@ -28,7 +28,7 @@ import { useSettingsStore } from "@stores";
defineOptions({ name: "FaWatermark" });
interface WatermarkProps {
interface Props {
/** 水印内容 */
content?: string;
/** 水印是否可见 */
@@ -51,7 +51,7 @@ interface WatermarkProps {
zIndex?: number;
}
const props = withDefaults(defineProps<WatermarkProps>(), {
const props = withDefaults(defineProps<Props>(), {
content: AppConfig.systemInfo.name,
visible: false,
fontSize: 16,
@@ -19,7 +19,7 @@
<ElButton
v-if="permCreate"
v-hasPerm="permCreate"
type="success"
type="primary"
:icon="Plus"
@click="$emit('add')"
plain
@@ -30,7 +30,7 @@
v-if="permImport"
v-hasPerm="permImport"
v-ripple
type="warning"
type="success"
:loading="importLoading"
:icon="Upload"
@click="$emit('import')"
@@ -42,7 +42,7 @@
v-if="permExport"
v-hasPerm="permExport"
v-ripple
type="primary"
type="warning"
:loading="exportLoading"
:icon="Download"
@click="$emit('export')"
@@ -89,38 +89,39 @@ import { ArrowDown, Delete, Download, Plus, Upload } from "@element-plus/icons-v
import { computed } from "vue";
import type { FaTableHeaderLeftConfigButton } from "@/components/modal/types";
const props = withDefaults(
defineProps<{
/** 与 CrudContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
configButtons?: FaTableHeaderLeftConfigButton[];
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
removeIds?: Array<string | number>;
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
permCreate?: string | string[];
/** 导入按钮权限,不传则不显示;顺序在新增之后、批量删除之前 */
permImport?: string | string[];
/** 导出按钮权限,不传则不显示 */
permExport?: string | string[];
/** 导入按钮 loading */
importLoading?: boolean;
/** 导出按钮 loading */
exportLoading?: boolean;
/** 批量删除权限,不传则不显示 */
permDelete?: string | string[];
/** 「更多」下拉权限,不传则不显示 */
permPatch?: string | string[];
/** 批量删除中(按钮 loading,并禁用「更多」) */
deleteLoading?: boolean;
}>(),
{
removeIds: () => [],
deleteLoading: false,
importLoading: false,
exportLoading: false,
}
);
defineOptions({ name: "FaTableHeaderLeft" });
defineEmits<{
interface Props {
/** 与 CrudContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
configButtons?: FaTableHeaderLeftConfigButton[];
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
removeIds?: Array<string | number>;
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
permCreate?: string | string[];
/** 导入按钮权限,不传则不显示;顺序在新增之后、批量删除之前 */
permImport?: string | string[];
/** 导出按钮权限,不传则不显示 */
permExport?: string | string[];
/** 导入按钮 loading */
importLoading?: boolean;
/** 导出按钮 loading */
exportLoading?: boolean;
/** 批量删除权限,不传则不显示 */
permDelete?: string | string[];
/** 「更多」下拉权限,不传则不显示 */
permPatch?: string | string[];
/** 批量删除中(按钮 loading,并禁用「更多」) */
deleteLoading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
removeIds: () => [],
deleteLoading: false,
importLoading: false,
exportLoading: false,
});
interface Emits {
/** 配置模式:与 CrudContent handleToolbar 一致 */
toolbar: [name: string];
add: [];
@@ -128,7 +129,9 @@ defineEmits<{
export: [];
delete: [];
more: [value: string];
}>();
}
defineEmits<Emits>();
const moreDisabled = computed(() => props.removeIds.length === 0 || props.deleteLoading);
</script>
@@ -192,11 +192,13 @@ const columns = defineModel<ColumnOption[]>("columns", {
default: () => [],
});
const emit = defineEmits<{
(e: "refresh"): void;
(e: "search"): void;
(e: "update:showSearchBar", value: boolean): void;
}>();
interface Emits {
refresh: [];
search: [];
"update:showSearchBar": [value: boolean];
}
const emit = defineEmits<Emits>();
/**
* 获取列的显示状态
@@ -149,7 +149,7 @@ interface PaginationOptions {
}
/** FaTable 组件的 Props 接口 */
interface FaTableProps extends TableProps<Record<string, any>> {
interface Props extends TableProps<Record<string, any>> {
/** 加载状态 */
loading?: boolean;
/** 列渲染配置 */
@@ -168,7 +168,7 @@ interface FaTableProps extends TableProps<Record<string, any>> {
disableRowDrag?: boolean;
}
const props = withDefaults(defineProps<FaTableProps>(), {
const props = withDefaults(defineProps<Props>(), {
columns: () => [],
fit: true,
showHeader: true,
@@ -314,12 +314,14 @@ const mergedTableProps = computed(() => ({
: undefined,
}));
const emit = defineEmits<{
interface Emits {
(e: "pagination:size-change", val: number): void;
(e: "pagination:current-change", val: number): void;
(e: "update:data", val: Record<string, unknown>[]): void;
(e: "row-order-change", val: Record<string, unknown>[]): void;
}>();
}
const emit = defineEmits<Emits>();
/** 无 data 时用固定空数组,避免 v-model 每次拿到新 [] */
const emptyDataStub = ref<Record<string, unknown>[]>([]);
@@ -12,8 +12,10 @@
import { computed, watch, nextTick, onUnmounted, shallowRef } from "vue";
import { useTransition, TransitionPresets } from "@vueuse/core";
defineOptions({ name: "FaCountTo" });
// 类型定义
interface CountToProps {
interface Props {
/** 目标值 */
target: number;
/** 动画持续时间(毫秒) */
@@ -36,7 +38,7 @@ interface CountToProps {
disabled?: boolean;
}
interface CountToEmits {
interface Emits {
started: [value: number];
finished: [value: number];
paused: [value: number];
@@ -64,7 +66,7 @@ const MAX_DECIMALS = 10;
const DEFAULT_EASING = "easeOutExpo";
const DEFAULT_DURATION = 2000;
const props = withDefaults(defineProps<CountToProps>(), {
const props = withDefaults(defineProps<Props>(), {
target: 0,
duration: DEFAULT_DURATION,
autoStart: true,
@@ -77,7 +79,7 @@ const props = withDefaults(defineProps<CountToProps>(), {
disabled: false,
});
const emit = defineEmits<CountToEmits>();
const emit = defineEmits<Emits>();
// 工具函数
const validateNumber = (value: number, name: string, defaultValue: number): number => {
@@ -7,7 +7,7 @@
<FaTextScroll
v-if="showFestivalStrip"
class="mb-3!"
type="primary"
type="theme"
:text="festivalScrollDisplayHtml"
height="40px"
:speed="55"
@@ -67,7 +67,7 @@ type ThemeType =
/**
* 文本滚动组件属性接口
*/
export interface TextScrollProps {
export interface Props {
/** 滚动文本内容 */
text?: string;
/** 主题类型 */
@@ -88,7 +88,7 @@ export interface TextScrollProps {
alwaysScroll?: boolean;
}
const props = withDefaults(defineProps<TextScrollProps>(), {
const props = withDefaults(defineProps<Props>(), {
text: "",
direction: "left",
speed: 80,
@@ -100,9 +100,11 @@ const props = withDefaults(defineProps<TextScrollProps>(), {
alwaysScroll: true,
});
const emit = defineEmits<{
interface Emits {
close: [];
}>();
}
const emit = defineEmits<Emits>();
const handleClose = () => {
emit("close");
@@ -121,14 +121,18 @@ defineOptions({ name: "AuthTopBar" });
const DEFAULT_APP_VERSION = "3.0.0";
const props = defineProps<{
interface Props {
/** 登录区表单水平对齐;未传入时不展示布局切换 */
panelAlign?: LoginPanelAlign | null;
}>();
}
const emit = defineEmits<{
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:panelAlign": [value: LoginPanelAlign];
}>();
}
const emit = defineEmits<Emits>();
const layoutAlignOptions: {
value: LoginPanelAlign;
@@ -51,8 +51,7 @@
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
@keyup.enter="$emit('submit')"
@keyup="onPasswordKeyup"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
@@ -179,7 +178,9 @@ import type { Account, AccountKey } from "@views/module_system/auth/login/types"
const loginForm = defineModel<LoginFormData>("loginForm", { required: true });
defineProps<{
defineOptions({ name: "FaLoginAccountForm" });
interface Props {
rules: FormRules;
captchaState: CaptchaInfo;
codeLoading: boolean;
@@ -189,12 +190,14 @@ defineProps<{
isDark: boolean;
dragVerifyTextColor: string;
loading: boolean;
}>();
}
withDefaults(defineProps<Props>(), {});
const isPassing = defineModel<boolean>("isPassing", { required: true });
const isClickPass = defineModel<boolean>("isClickPass", { required: true });
defineEmits<{
interface Emits {
submit: [];
setupAccount: [key: AccountKey];
getCaptcha: [];
@@ -203,15 +206,20 @@ defineEmits<{
forget: [];
register: [];
oauth: [provider: "wechat" | "qq" | "github" | "gitee"];
}>();
}
const emit = defineEmits<Emits>();
const formRef = ref();
const dragVerifyRef = ref<{ reset?: () => void } | null>(null);
const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
function onPasswordKeyup(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
@@ -16,8 +16,20 @@
</template>
<script setup lang="ts">
defineProps<{ hint: string; linkText: string }>();
defineEmits<{ link: [] }>();
defineOptions({ name: "FaLoginAuthLinkRow" });
interface Props {
hint: string;
linkText: string;
}
withDefaults(defineProps<Props>(), {});
interface Emits {
link: [];
}
defineEmits<Emits>();
</script>
<style scoped lang="scss">
@@ -17,13 +17,12 @@ import loginIcon from "@fa_imgs/background.svg";
defineOptions({ name: "LoginCenterBackdrop" });
withDefaults(
defineProps<{
/** 铺满视口并置于底层(与固定顶栏配合) */
viewportFixed?: boolean;
}>(),
{ viewportFixed: false }
);
interface Props {
/** 铺满视口并置于底层(与固定顶栏配合) */
viewportFixed?: boolean;
}
withDefaults(defineProps<Props>(), { viewportFixed: false });
</script>
<style scoped lang="scss">
@@ -33,7 +33,6 @@
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
@@ -52,7 +51,6 @@
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
@@ -88,13 +86,22 @@ import type { FormRules } from "element-plus";
const forgetForm = defineModel<ForgetPasswordForm>("forgetForm", { required: true });
defineProps<{
defineOptions({ name: "FaLoginForgetPanel" });
interface Props {
forgetRules: FormRules<ForgetPasswordForm>;
formKey: number | string;
forgetLoading: boolean;
}>();
}
defineEmits<{ submit: []; toLogin: [] }>();
withDefaults(defineProps<Props>(), {});
interface Emits {
submit: [];
toLogin: [];
}
const emit = defineEmits<Emits>();
const formRef = ref();
const isCapsLock = ref(false);
@@ -102,6 +109,9 @@ const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
@@ -93,11 +93,18 @@ import loginIcon from "@fa_imgs/background.svg";
import { useConfigStore } from "@stores";
import { themeAnimation } from "@utils";
defineProps<{
defineOptions({ name: "FaLoginLeftView" });
interface Props {
hideContent?: boolean;
/** 顶栏已展示 Logo/标题/版本时隐藏左侧重复顶栏 */
hideTopBranding?: boolean;
}>();
}
withDefaults(defineProps<Props>(), {
hideContent: false,
hideTopBranding: false,
});
const configStore = useConfigStore();
@@ -78,7 +78,14 @@
import { Iphone } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
defineEmits<{ back: []; register: [] }>();
defineOptions({ name: "FaLoginMobilePanel" });
interface Emits {
back: [];
register: [];
}
defineEmits<Emits>();
const { t } = useI18n();
@@ -37,7 +37,14 @@
import QrcodeVue from "qrcode.vue";
import type { Level, RenderAs } from "qrcode.vue";
defineEmits<{ back: []; register: [] }>();
defineOptions({ name: "FaLoginQrPanel" });
interface Emits {
back: [];
register: [];
}
defineEmits<Emits>();
/** 集中配置 + H 级纠错 + SVG(矢量清晰、易对齐容器) */
const qrCodeRenderOptions: {
@@ -32,7 +32,6 @@
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
@@ -51,7 +50,6 @@
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
@@ -104,18 +102,27 @@ import type { FormRules } from "element-plus";
const registerForm = defineModel<RegisterForm>("registerForm", { required: true });
defineProps<{
defineOptions({ name: "FaLoginRegisterPanel" });
interface Props {
registerRules: FormRules<RegisterForm>;
formKey: number | string;
registerLoading: boolean;
userAgreementHref: string;
}>();
}
withDefaults(defineProps<Props>(), {});
const registerAgreementReadModel = defineModel<boolean>("registerAgreementRead", {
required: true,
});
defineEmits<{ submit: []; toLogin: [] }>();
interface Emits {
submit: [];
toLogin: [];
}
const emit = defineEmits<Emits>();
const formRef = ref();
const isCapsLock = ref(false);
@@ -123,6 +130,9 @@ const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
@@ -29,7 +29,13 @@
<script setup lang="ts">
import type { OAuthProvider } from "@/api/module_system/auth";
defineEmits<{ oauth: [provider: OAuthProvider] }>();
defineOptions({ name: "FaLoginThirdPartySection" });
interface Emits {
oauth: [provider: OAuthProvider];
}
defineEmits<Emits>();
const { t } = useI18n();
@@ -30,7 +30,7 @@ defineSlots<{
buttons?: () => void;
}>();
interface ResultPageProps {
interface Props {
/** 成功/失败 */
type: "success" | "fail";
/** 标题 */
@@ -41,7 +41,7 @@ interface ResultPageProps {
iconCode: string;
}
withDefaults(defineProps<ResultPageProps>(), {
withDefaults(defineProps<Props>(), {
type: "success",
title: "",
message: "",