refactor: 优化通用组件实现与工具类型

- src/components/DictSelect: 改用 defineModel 与 computed,修复选项加载时清空已选值与父子值分叉
- src/components/ThemeSwitcher: ThemeSwitch 改名,修正 theneList 拼写
- src/composables/useTableSelection: 显式判空,避免 id 为 0 的行被过滤
- src/utils/download: 收窄入参类型,headers 改为可选
- src/api/system/user: 导出与模板下载接口声明 Blob 响应类型
- src/views/system/dict: 标签类型选项排除空串,el-tag 去掉 as any
- src/views/system/user/components/UserImportDialog: 去掉响应类型的 any 标注
- src/layouts、src/views/login: 删除可自动导入组件的冗余 import
This commit is contained in:
Ray.Hao
2026-09-01 07:26:09 +08:00
parent cf5768dbf4
commit c9bd327fc0
12 changed files with 158 additions and 132 deletions

View File

@@ -109,7 +109,7 @@ const UserAPI = {
/** 下载用户导入模板 */
downloadTemplate() {
return request({
return request<Blob>({
url: `${USER_BASE_URL}/template`,
method: "get",
responseType: "blob",
@@ -122,7 +122,7 @@ const UserAPI = {
* @param queryParams 查询参数
*/
export(queryParams: UserQueryParams) {
return request({
return request<Blob>({
url: `${USER_BASE_URL}/export`,
method: "get",
params: queryParams,

View File

@@ -1,12 +1,11 @@
<template>
<el-select
v-if="type === 'select'"
v-model="selectedValue"
v-model="selectedSingle"
:placeholder="placeholder"
:disabled="disabled"
clearable
:style="style"
@change="handleChange"
>
<el-option
v-for="option in options"
@@ -18,10 +17,9 @@
<el-radio-group
v-else-if="type === 'radio'"
v-model="selectedValue"
v-model="selectedSingle"
:disabled="disabled"
:style="style"
@change="handleChange"
>
<el-radio v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
@@ -30,10 +28,9 @@
<el-checkbox-group
v-else-if="type === 'checkbox'"
v-model="selectedValue"
v-model="selectedMulti"
:disabled="disabled"
:style="style"
@change="handleChange"
>
<el-checkbox v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
@@ -44,30 +41,33 @@
<script setup lang="ts">
import { useDictStore } from "@/stores";
const dictStore = useDictStore();
defineOptions({
name: "DictSelect",
});
const props = defineProps({
/** 字典编码 */
code: {
type: String,
required: true,
},
modelValue: {
type: [String, Number, Array],
required: false,
},
/** 展示形态(下拉/单选/多选) */
type: {
type: String,
default: "select",
validator: (value: string) => ["select", "radio", "checkbox"].includes(value),
},
/** 提示文字 */
placeholder: {
type: String,
default: "请选择",
},
/** 是否禁用(form-create 只读回显依赖此属性) */
disabled: {
type: Boolean,
default: false,
},
/** 行内样式(默认宽度 300px) */
style: {
type: Object,
default: () => {
@@ -78,46 +78,51 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:modelValue"]);
const modelValue = defineModel<string | number | Array<string | number>>();
const dictStore = useDictStore();
/** 字典选项 */
const options = ref<Array<{ label: string; value: string | number }>>([]);
const selectedValue = ref<any>(
typeof props.modelValue === "string" || typeof props.modelValue === "number"
? props.modelValue
: Array.isArray(props.modelValue)
? props.modelValue
: undefined
);
/**
* 单选值(select/radio):选项加载后把外部值翻译为选项的原始值(消除 "1" 与 1 的
* 字符串/数字差异),未命中时原样保留不清空;用户选择直接写回 modelValue
*/
const selectedSingle = computed<string | number | undefined>({
get() {
const value = modelValue.value;
if (value === null || value === undefined || Array.isArray(value)) return undefined;
const matched = options.value.find((option) => String(option.value) === String(value));
return matched?.value ?? value;
},
set(value) {
modelValue.value = value;
},
});
// 监听 modelValue 和 options 的变化
/** 多选值(checkbox):非数组入参按空选处理 */
const selectedMulti = computed<Array<string | number>>({
get() {
return Array.isArray(modelValue.value) ? modelValue.value : [];
},
set(value) {
modelValue.value = value;
},
});
// 获取字典数据(空编码跳过:设计器画布上未配置字典编码时不发无效请求;
// watch 而非 onMounted:设计器右侧面板修改编码后画布即时刷新选项)
watch(
[() => props.modelValue, () => options.value],
([newValue, newOptions]) => {
if (newOptions.length > 0 && newValue !== undefined) {
if (props.type === "checkbox") {
selectedValue.value = Array.isArray(newValue) ? newValue : [];
} else {
const matchedOption = newOptions.find(
(option) => String(option.value) === String(newValue)
);
selectedValue.value = matchedOption?.value;
}
} else {
selectedValue.value = undefined;
() => props.code,
async (code) => {
if (!code) {
options.value = [];
return;
}
await dictStore.loadDictItems(code);
options.value = dictStore.getDictItems(code);
},
{ immediate: true }
);
// 监听 selectedValue 的变化并触发 update:modelValue
function handleChange(val: any) {
emit("update:modelValue", val);
}
// 获取字典数据
onMounted(async () => {
await dictStore.loadDictItems(props.code);
options.value = dictStore.getDictItems(props.code);
});
</script>

View File

@@ -38,7 +38,7 @@ const { locale, t } = useI18n();
/**
* 处理语言切换
*
* @param lang 语言(zh-cn、en<EFBFBD>?
* @param lang 语言(zh-cn、en)
*/
function handleLanguageChange(lang: string) {
locale.value = lang;

View File

@@ -1,12 +1,12 @@
<template>
<el-dropdown trigger="click" @command="handleDarkChange">
<el-dropdown trigger="click" @command="handleThemeChange">
<el-icon :size="20">
<component :is="currentThemeIcon" />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in theneList"
v-for="item in themeList"
:key="item.value"
:command="item.value"
:disabled="settingsStore.theme === item.value"
@@ -28,7 +28,7 @@ import { Moon, Sunny, Monitor } from "@element-plus/icons-vue";
const { t } = useI18n();
const settingsStore = useSettingsStore();
const theneList = [
const themeList = [
{ label: t("login.light"), value: ThemeMode.LIGHT, component: Sunny },
{ label: t("login.dark"), value: ThemeMode.DARK, component: Moon },
{ label: t("login.auto"), value: ThemeMode.AUTO, component: Monitor },
@@ -42,7 +42,7 @@ const currentThemeIcon = computed(() => {
return settingsStore.resolvedTheme === ThemeMode.DARK ? Moon : Sunny;
});
const handleDarkChange = (theme: ThemeMode) => {
const handleThemeChange = (theme: ThemeMode) => {
settingsStore.theme = theme;
};
</script>

View File

@@ -34,7 +34,9 @@ export function useTableSelection<T extends { id?: string | number }>() {
* @param selection 选中的行数据列表
*/
function handleSelectionChange(selection: T[]): void {
selectedIds.value = selection.flatMap((item) => (item.id ? [item.id] : []));
selectedIds.value = selection.flatMap((item) =>
item.id === undefined || item.id === null ? [] : [item.id]
);
}
/**

View File

@@ -13,8 +13,6 @@
<script setup lang="ts">
import { useAppStore } from "@/stores";
import Hamburger from "@/components/Hamburger/index.vue";
import Breadcrumb from "@/components/Breadcrumb/index.vue";
const props = withDefaults(
defineProps<{

View File

@@ -18,7 +18,7 @@
</div>
<div class="layout-toolbar__item">
<NoticeDropdown />
<NoticeCenter />
</div>
<div v-if="showTenantSwitcher" class="layout-toolbar__item">
@@ -60,12 +60,6 @@ import { defaults } from "@/settings";
import { DeviceEnum, SidebarColor, ThemeMode, LayoutMode } from "@/enums/settings";
import { useAppStore, useSettingsStore, useUserStore } from "@/stores";
import CommandPalette from "@/components/CommandPalette/index.vue";
import Fullscreen from "@/components/Fullscreen/index.vue";
import SizeSelect from "@/components/SizeSelect/index.vue";
import LangSelect from "@/components/LangSelect/index.vue";
import NoticeDropdown from "@/components/NoticeDropdown/index.vue";
import TenantSwitcher from "@/components/TenantSwitcher/index.vue";
import { useTenantStoreHook } from "@/stores/tenant";
const { t } = useI18n();

View File

@@ -29,9 +29,19 @@ function extractFileName(contentDisposition: string): string {
return `download_${Date.now()}`;
}
/**
* 下载响应:axios 二进制响应(blob 透传整个 response)或自组装的二进制数据
*/
interface DownloadResponse {
/** 二进制内容 */
data: BlobPart;
/** 响应头(用于解析文件名,自组装数据可省略) */
headers?: Record<string, unknown>;
}
/**
* 下载文件
* @param response Axios 响应对象
* @param response 二进制接口响应或 { data } 形式的二进制内容
* @param customFileName 自定义文件名(可选)
*
* @example
@@ -44,10 +54,10 @@ function extractFileName(contentDisposition: string): string {
* downloadFile(response, "用户列表.xlsx");
* ```
*/
export function downloadFile(response: { data: any; headers: any }, customFileName?: string): void {
export function downloadFile(response: DownloadResponse, customFileName?: string): void {
try {
const fileData = response.data;
const contentDisposition = response.headers["content-disposition"];
const contentDisposition = String(response.headers?.["content-disposition"] ?? "");
const fileName = customFileName || extractFileName(contentDisposition);
// 创建 Blob 对象

View File

@@ -1,7 +1,7 @@
<template>
<div class="login-page">
<div class="login-toolbar">
<ThemeSwitch />
<ThemeSwitcher />
<LangSelect size="text-18px" />
</div>
@@ -21,10 +21,7 @@
<span class="login-brand__tag-dot" />
Enterprise Ready
</el-tag>
<h1 class="login-brand__title">企业级管理系统</h1>
<p class="login-brand__desc">
提供安全、高效、可扩展的管理解决方案,助力企业数字化转型与业务增长。
</p>
<h1 class="login-brand__title">开箱即用的企业级中后台解决方案</h1>
</div>
<div class="login-brand__features">
<div class="login-brand__feature">
@@ -171,7 +168,6 @@ import router from "@/router";
import { useUserStore } from "@/stores";
import { AuthStorage } from "@/utils/auth";
import { appConfig } from "@/settings";
import ThemeSwitch from "@/components/ThemeSwitch/index.vue";
import ResetPwd from "./components/ResetPwd.vue";
import QrCodeLogin from "./components/QrCodeLogin.vue";
import logo from "@/assets/images/logo.png";
@@ -251,7 +247,7 @@ onMounted(() => getCaptcha());
</script>
<style lang="scss" scoped>
$primary: #5d87ff;
$primary: #165dff;
$bg: #f8fafc;
$text-primary: #273248;
$text-secondary: #667085;
@@ -310,8 +306,13 @@ $input-h: 44px;
}
&__logo {
box-sizing: border-box;
width: 42px;
height: 42px;
padding: 5px;
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 18%);
border-radius: 12px;
}
&__identity {
@@ -325,7 +326,7 @@ $input-h: 44px;
font-size: 24px;
font-weight: 600;
line-height: 1;
color: $text-primary;
color: #1e3a8a;
}
&__version {
@@ -336,9 +337,9 @@ $input-h: 44px;
font-size: 12px;
font-weight: 600;
line-height: 1;
color: rgba($primary, 0.88);
background: rgba($primary, 0.07);
border: 1px solid rgba($primary, 0.13);
color: rgb(30 58 138 / 80%);
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 16%);
border-radius: 999px;
}
@@ -347,8 +348,8 @@ $input-h: 44px;
flex: 1;
flex-direction: column;
justify-content: center;
width: min(720px, 100%);
padding: 20px 0 88px;
width: min(760px, 100%);
padding: 40px 0 60px;
}
&__main {
@@ -357,13 +358,14 @@ $input-h: 44px;
&__tag {
gap: 8px;
height: 28px;
padding: 0 13px 0 11px;
margin-bottom: 18px;
height: 32px;
padding: 0 15px 0 13px;
margin-bottom: 20px;
font-size: 12px;
font-weight: 700;
color: $primary;
background: rgba($primary, 0.035);
border-color: rgba($primary, 0.14);
color: #1d4ed8;
background: rgb(37 99 235 / 8%);
border-color: rgb(37 99 235 / 16%);
:deep(.el-tag__content) {
display: inline-flex;
@@ -377,26 +379,26 @@ $input-h: 44px;
flex-shrink: 0;
width: 7px;
height: 7px;
background: $primary;
background: #2563eb;
border-radius: 50%;
box-shadow: 0 0 0 3px rgba($primary, 0.12);
box-shadow: 0 0 0 3px rgb(37 99 235 / 16%);
}
&__title {
margin: 0 0 18px;
font-size: 46px;
margin: 0;
font-size: 36px;
font-weight: 800;
line-height: 1.18;
color: #222b3a;
line-height: 1.15;
color: #1e3a8a;
letter-spacing: 0;
}
&__desc {
max-width: 560px;
margin: 0;
font-size: 16px;
line-height: 1.75;
color: $text-secondary;
font-size: 18px;
line-height: 1.6;
color: #475569;
}
&__features {
@@ -404,19 +406,19 @@ $input-h: 44px;
align-items: center;
width: fit-content;
max-width: 100%;
margin-top: 28px;
margin-top: 32px;
}
&__feature {
position: relative;
display: inline-flex;
gap: 8px;
gap: 10px;
align-items: center;
height: 28px;
padding: 0 13px;
font-size: 13px;
height: 32px;
padding: 0 15px;
font-size: 14px;
font-weight: 600;
color: $text-primary;
color: rgb(30 58 138 / 85%);
background: transparent;
&:first-child {
@@ -430,7 +432,7 @@ $input-h: 44px;
width: 1px;
height: 14px;
content: "";
background: rgba(39 50 72 / 12%);
background: rgb(37 99 235 / 18%);
}
}
@@ -438,11 +440,11 @@ $input-h: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: $primary;
background: rgba($primary, 0.08);
border: 1px solid rgba($primary, 0.1);
width: 22px;
height: 22px;
color: #1d4ed8;
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 16%);
border-radius: 6px;
}
@@ -450,9 +452,9 @@ $input-h: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
color: $primary;
width: 14px;
height: 14px;
color: #1d4ed8;
}
&__feature-text {
@@ -488,8 +490,8 @@ $input-h: 44px;
}
&__title {
margin: 0 0 4px;
font-size: 34px;
margin: 0 0 10px;
font-size: 28px;
font-weight: 750;
line-height: 1.1;
color: $text-primary;
@@ -497,8 +499,8 @@ $input-h: 44px;
}
&__desc {
margin: 8px 0 24px;
font-size: 14px;
margin: 10px 0 28px;
font-size: 15px;
color: $text-muted;
}
}
@@ -579,7 +581,7 @@ $input-h: 44px;
.login-btn {
width: 100%;
height: 44px;
font-size: 15px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
box-shadow: 0 12px 24px rgba($primary, 0.18);
@@ -627,7 +629,7 @@ $input-h: 44px;
justify-content: center;
height: 44px;
padding: 0;
font-size: 13px;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
background: transparent;
@@ -664,41 +666,56 @@ $input-h: 44px;
.dark .login-brand {
background-image: url("@/assets/images/login/bg-dark.svg");
&__logo {
background: rgb(255 255 255 / 10%);
border-color: rgb(255 255 255 / 20%);
}
&__name {
color: rgb(255 255 255 / 86%);
color: rgb(255 255 255 / 92%);
}
&__version {
color: rgb(167 190 255 / 92%);
background: rgba($primary, 0.12);
border-color: rgba($primary, 0.2);
color: rgb(255 255 255 / 85%);
background: rgb(255 255 255 / 10%);
border-color: rgb(255 255 255 / 16%);
}
&__tag {
color: rgba($primary, 0.95);
background: rgba($primary, 0.08);
border-color: rgba($primary, 0.18);
color: #fff;
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 16%);
}
&__tag-dot {
background: #fff;
box-shadow: 0 0 0 3px rgb(255 255 255 / 18%);
}
&__title {
color: rgb(255 255 255 / 90%);
color: #fff;
}
&__desc {
color: rgb(226 232 240 / 62%);
color: rgb(255 255 255 / 65%);
}
&__feature {
color: rgb(255 255 255 / 76%);
color: rgb(255 255 255 / 85%);
&:not(:last-child)::after {
background: rgba(255 255 255 / 12%);
background: rgb(255 255 255 / 18%);
}
}
&__feature-mark {
background: rgba($primary, 0.15);
border-color: rgba($primary, 0.18);
color: #fff;
background: rgb(255 255 255 / 10%);
border-color: rgb(255 255 255 / 16%);
}
&__feature-icon {
color: #fff;
}
}
@@ -760,7 +777,7 @@ $input-h: 44px;
height: auto;
min-height: auto;
padding: 28px 40px 0;
background: #fff;
background: linear-gradient(180deg, #ffffff 0%, #dbeafe 100%);
&__hero {
display: none;

View File

@@ -136,7 +136,7 @@
</template>
<el-option v-for="type in tagTypeOptions" :key="type" :label="type" :value="type">
<div flex-y-center gap-10px>
<el-tag :type="type as any">{{ formData.label ?? "字典标签" }}</el-tag>
<el-tag :type="type">{{ formData.label ?? "字典标签" }}</el-tag>
<span>{{ type }}</span>
</div>
</el-option>
@@ -180,7 +180,7 @@ const queryFormRef = ref<FormInstance>();
const dictItemFormRef = ref<FormInstance>();
// 标签类型可选项。
const tagTypeOptions: NonNullable<DictItemForm["tagType"]>[] = [
const tagTypeOptions: Exclude<NonNullable<DictItemForm["tagType"]>, "">[] = [
"primary",
"success",
"info",

View File

@@ -533,8 +533,8 @@ const showRouteParams = computed(() => formData.type === MenuTypeEnum.MENU);
const routeNameTooltip = computed(() =>
isEmbeddedExternal.value
? "开启缓存时填写,需和内嵌页路由名称保持一致,例如 Apifox"
: "开启缓存时填写,需和页面组件 name 保持一致,例如 User"
? "路由唯一标识,需全局唯一,确保多个内嵌页面互不冲突,例如 Apifox"
: "路由唯一标识,需全局唯一,用于动态路由注册与命令面板检索,例如 User"
);
const routeNamePlaceholder = computed(() => (isEmbeddedExternal.value ? "Apifox" : "User"));

View File

@@ -144,7 +144,7 @@ function handleFileExceed(): void {
* 下载导入模板
*/
function downloadTemplate(): void {
UserAPI.downloadTemplate().then((response: any) => {
UserAPI.downloadTemplate().then((response) => {
downloadFile(response);
});
}