feat(utils): add common utility functions and validation constants

This commit is contained in:
Ray.Hao
2025-11-18 18:25:21 +08:00
parent a0b714999e
commit dc79401c13
14 changed files with 548 additions and 201 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div class="login-container">
<!-- 右侧切换主题语言按钮 -->
<div class="action-bar">
<div class="login-toolbar">
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
<CommonWrapper>
<DarkModeSwitch />
@@ -14,16 +14,14 @@
</el-tooltip>
</div>
<!-- 登录页主体 -->
<div flex-1 flex-center>
<div
class="p-4xl w-full h-auto sm:w-450px sm:h-700px shadow-[var(--el-box-shadow-light)] border-rd-2"
>
<div w-full flex flex-col items-center>
<div class="login-content">
<div class="login-card">
<div class="login-card__inner">
<!-- logo -->
<el-image :src="logo" style="width: 84px" />
<el-image :src="logo" class="w-84px h-auto" />
<!-- 标题 -->
<h2>
<h2 class="my-4">
<el-badge :value="`v ${defaultSettings.version}`" type="success">
{{ defaultSettings.title }}
</el-badge>
@@ -36,10 +34,12 @@
</div>
</div>
<!-- 登录页底部版权 -->
<el-text size="small" class="py-2.5! fixed bottom-0 text-center">
Copyright © 2021 - 2025 youlai.tech All Rights Reserved.
<a href="http://beian.miit.gov.cn/" target="_blank">皖ICP备20006496号-2</a>
</el-text>
<footer class="login-footer">
<el-text size="small">
Copyright © 2021 - 2025 youlai.tech All Rights Reserved.
<a href="http://beian.miit.gov.cn/" target="_blank">皖ICP备20006496号-2</a>
</el-text>
</footer>
</div>
</div>
</template>
@@ -52,25 +52,21 @@ import DarkModeSwitch from "@/components/DarkModeSwitch/index.vue";
type LayoutMap = "login" | "register" | "resetPwd";
const t = useI18n().t;
const { t } = useI18n();
const component = ref<LayoutMap>("login");
const component = ref<LayoutMap>("login"); // 切换显示的组件
const formComponents = {
login: defineAsyncComponent(() => import("./components/Login.vue")),
register: defineAsyncComponent(() => import("./components/Register.vue")),
resetPwd: defineAsyncComponent(() => import("./components/ResetPwd.vue")),
};
// 投票通知
const voteUrl = "https://gitee.com/activity/2025opensource?ident=I6VXEH";
// 保存通知实例,用于在组件卸载时关闭
let notificationInstance: ReturnType<typeof ElNotification> | null = null;
// 显示投票通知
const showVoteNotification = () => {
notificationInstance = ElNotification({
title: "⭐ Gitee 2025 开源评选 · 诚邀您的支持! 🙏",
message: `我正在参加 Gitee 2025 最受欢迎的开源软件投票活动,快来给我投票吧!<br/><a href="${voteUrl}" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">点击投票 →</a>`,
message: `我正在参加 Gitee 2025 最受欢迎的开源软件投票活动,快来给我投票吧!<br/><a href="https://gitee.com/activity/2025opensource?ident=I6VXEH" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">点击投票 →</a>`,
type: "success",
position: "bottom-right",
duration: 0,
@@ -78,14 +74,10 @@ const showVoteNotification = () => {
});
};
// 延迟显示
onMounted(() => {
setTimeout(() => {
showVoteNotification();
}, 500);
setTimeout(showVoteNotification, 500);
});
// 组件卸载时关闭通知
onBeforeUnmount(() => {
if (notificationInstance) {
notificationInstance.close();
@@ -95,6 +87,8 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
$transition-duration: 0.3s;
$transition-offset: 30px;
.login-container {
position: relative;
z-index: 1;
@@ -104,23 +98,20 @@ onBeforeUnmount(() => {
justify-content: center;
width: 100%;
height: 100%;
&::before {
position: fixed;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
content: "";
background: url("@/assets/images/login-bg.svg") center center / cover;
}
}
// 添加伪元素作为背景层
.login-container::before {
position: fixed;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
content: "";
background: url("@/assets/images/login-bg.svg");
background-position: center center;
background-size: cover;
}
.action-bar {
.login-toolbar {
position: fixed;
top: 10px;
right: 10px;
@@ -128,11 +119,9 @@ onBeforeUnmount(() => {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
font-size: 1.125rem;
@media (max-width: 480px) {
top: 10px;
right: auto;
left: 10px;
}
@@ -143,19 +132,52 @@ onBeforeUnmount(() => {
}
}
/* fade-slide */
.login-content {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
height: auto;
padding: 3rem;
border-radius: 0.5rem;
box-shadow: var(--el-box-shadow-light);
@media (min-width: 640px) {
width: 450px;
height: 700px;
}
}
.login-card__inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.login-footer {
position: fixed;
bottom: 0;
padding: 0.625rem 0;
text-align: center;
}
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
transition: all $transition-duration;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
transform: translateX(-$transition-offset);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
transform: translateX($transition-offset);
}
</style>

View File

@@ -4,7 +4,7 @@
<el-row :gutter="20">
<!-- 部门树 -->
<el-col :lg="4" :xs="24" class="mb-[12px]">
<DeptTree v-model="queryParams.deptId" @node-click="handleQuery" />
<UserDeptTree v-model="queryParams.deptId" @node-click="handleQuery" />
</el-col>
<!-- 用户列表 -->
@@ -90,7 +90,7 @@
<el-table
v-loading="loading"
:data="pageData"
:data="userList"
border
stripe
highlight-current-row
@@ -111,8 +111,8 @@
<el-table-column label="邮箱" align="center" prop="email" width="160" />
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope">
<el-tag :type="scope.row.status == 1 ? 'success' : 'info'">
{{ scope.row.status == 1 ? "正常" : "禁用" }}
<el-tag :type="scope.row.status === CommonStatus.ENABLED ? 'success' : 'info'">
{{ scope.row.status === CommonStatus.ENABLED ? "正常" : "禁用" }}
</el-tag>
</template>
</el-table-column>
@@ -166,8 +166,8 @@
<!-- 用户表单 -->
<el-drawer
v-model="dialog.visible"
:title="dialog.title"
v-model="dialogState.visible"
:title="dialogState.title"
append-to-body
:size="drawerSize"
@close="handleCloseDialog"
@@ -225,8 +225,8 @@
inline-prompt
active-text="正常"
inactive-text="禁用"
:active-value="1"
:inactive-value="0"
:active-value="CommonStatus.ENABLED"
:inactive-value="CommonStatus.DISABLED"
/>
</el-form-item>
</el-form>
@@ -240,7 +240,7 @@
</el-drawer>
<!-- 用户导入 -->
<UserImport v-model="importDialogVisible" @import-success="handleQuery()" />
<UserImportDialog v-model="importDialogVisible" @import-success="handleQuery()" />
</div>
</template>
@@ -250,32 +250,35 @@ import { computed, onMounted, reactive, ref } from "vue";
import { useDebounceFn } from "@vueuse/core";
// ==================== 2. Element Plus ====================
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox, type FormInstance } from "element-plus";
// ==================== 3. 类型定义 ====================
import type { UserForm, UserPageQuery, UserPageVO } from "@/api/system/user-api";
// ==================== 3.5 工具函数 ====================
import { downloadFile } from "@/utils";
import { VALIDATORS } from "@/constants";
// ==================== 4. API 服务 ====================
import UserAPI from "@/api/system/user-api";
import DeptAPI from "@/api/system/dept-api";
import RoleAPI from "@/api/system/role-api";
// ==================== 5. Store ====================
import { useAppStore } from "@/store/modules/app-store";
import { useUserStore } from "@/store";
import { useUserStore, useAppStore } from "@/store";
// ==================== 6. Enums ====================
import { DeviceEnum } from "@/enums/settings/device-enum";
import { DeviceEnum, DialogMode, CommonStatus } from "@/enums";
// ==================== 7. Composables ====================
import { useAiAction, useTableSelection } from "@/composables";
// ==================== 8. 组件 ====================
import DeptTree from "./components/DeptTree.vue";
import UserImport from "./components/UserImport.vue";
import UserDeptTree from "./components/UserDeptTree.vue";
import UserImportDialog from "./components/UserImportDialog.vue";
// ==================== 组件配置 ====================
defineOptions({
name: "SystemUser",
name: "User",
inheritAttrs: false,
});
@@ -286,8 +289,8 @@ const userStore = useUserStore();
// ==================== 响应式状态 ====================
// DOM 引用
const queryFormRef = ref();
const userFormRef = ref();
const queryFormRef = ref<FormInstance>();
const userFormRef = ref<FormInstance>();
// 列表查询参数
const queryParams = reactive<UserPageQuery>({
@@ -296,20 +299,24 @@ const queryParams = reactive<UserPageQuery>({
});
// 列表数据
const pageData = ref<UserPageVO[]>();
const userList = ref<UserPageVO[]>([]);
const total = ref(0);
const loading = ref(false);
// 弹窗状态
const dialog = reactive({
const dialogState = reactive({
visible: false,
title: "新增用户",
mode: DialogMode.CREATE,
});
// 初始表单数据
const initialFormData: UserForm = {
status: CommonStatus.ENABLED,
};
// 表单数据
const formData = reactive<UserForm>({
status: 1,
});
const formData = reactive<UserForm>({ ...initialFormData });
// 下拉选项数据
const deptOptions = ref<OptionType[]>();
@@ -328,48 +335,12 @@ const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "600
// ==================== 表单验证规则 ====================
const rules = reactive({
username: [
{
required: true,
message: "用户不能为空",
trigger: "blur",
},
],
nickname: [
{
required: true,
message: "用户昵称不能为空",
trigger: "blur",
},
],
deptId: [
{
required: true,
message: "所属部门不能为空",
trigger: "blur",
},
],
roleIds: [
{
required: true,
message: "用户角色不能为空",
trigger: "blur",
},
],
email: [
{
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请输入正确的邮箱地址",
trigger: "blur",
},
],
mobile: [
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur",
},
],
username: [VALIDATORS.required("用户名不能为空")],
nickname: [VALIDATORS.required("用户昵称不能为空")],
deptId: [VALIDATORS.required("所属部门不能为空")],
roleIds: [VALIDATORS.required("用户角色不能为空")],
email: [VALIDATORS.email],
mobile: [VALIDATORS.mobile],
});
// ==================== 数据加载 ====================
@@ -381,7 +352,7 @@ async function fetchUserList(): Promise<void> {
loading.value = true;
try {
const data = await UserAPI.getPage(queryParams);
pageData.value = data.list;
userList.value = data.list;
total.value = data.total;
} catch (error) {
ElMessage.error("获取用户列表失败");
@@ -408,7 +379,7 @@ function handleQuery(): Promise<void> {
* 重置查询条件
*/
function handleResetQuery(): void {
queryFormRef.value.resetFields();
queryFormRef.value?.resetFields();
queryParams.deptId = undefined;
queryParams.createTime = undefined;
handleQuery();
@@ -446,7 +417,7 @@ function handleResetPassword(row: UserPageVO): void {
* @param id 用户ID编辑时传入
*/
async function handleOpenDialog(id?: string): Promise<void> {
dialog.visible = true;
dialogState.visible = true;
// 并行加载下拉选项数据
try {
@@ -461,7 +432,8 @@ async function handleOpenDialog(id?: string): Promise<void> {
// 编辑:加载用户数据
if (id) {
dialog.title = "修改用户";
dialogState.title = "修改用户";
dialogState.mode = DialogMode.EDIT;
try {
const data = await UserAPI.getFormData(id);
Object.assign(formData, data);
@@ -471,7 +443,8 @@ async function handleOpenDialog(id?: string): Promise<void> {
}
} else {
// 新增:设置默认值
dialog.title = "新增用户";
dialogState.title = "新增用户";
dialogState.mode = DialogMode.CREATE;
}
}
@@ -479,20 +452,21 @@ async function handleOpenDialog(id?: string): Promise<void> {
* 关闭用户表单弹窗
*/
function handleCloseDialog(): void {
dialog.visible = false;
userFormRef.value.resetFields();
userFormRef.value.clearValidate();
dialogState.visible = false;
// 重置表单数据
formData.id = undefined;
formData.status = 1;
// 安全地重置表单
userFormRef.value?.resetFields();
userFormRef.value?.clearValidate();
// 完全重置表单数据
Object.assign(formData, initialFormData);
}
/**
* 提交用户表单(防抖)
*/
const handleSubmit = useDebounceFn(async () => {
const valid = await userFormRef.value.validate().catch(() => false);
const valid = await userFormRef.value?.validate().catch(() => false);
if (!valid) return;
const userId = formData.id;
@@ -514,14 +488,14 @@ const handleSubmit = useDebounceFn(async () => {
} finally {
loading.value = false;
}
}, 1000);
}, 300);
/**
* 删除用户
* @param id 用户ID单个删除时传入
*/
function handleDelete(id?: string): void {
const userIds = id ? id : selectedIds.value.join(",");
const userIds = id ?? selectedIds.value.join(",");
if (!userIds) {
ElMessage.warning("请勾选删除项");
@@ -579,27 +553,7 @@ function handleOpenImportDialog(): void {
async function handleExport(): Promise<void> {
try {
const response = await UserAPI.export(queryParams);
const fileData = response.data;
const contentDisposition = response.headers["content-disposition"];
const fileName = decodeURI(contentDisposition.split(";")[1].split("=")[1]);
const fileType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
// 创建下载链接
const blob = new Blob([fileData], { type: fileType });
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = fileName;
// 触发下载
document.body.appendChild(downloadLink);
downloadLink.click();
// 清理
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(downloadUrl);
downloadFile(response);
ElMessage.success("导出成功");
} catch (error) {
ElMessage.error("导出失败");
@@ -656,9 +610,6 @@ useAiAction({
/**
* 组件挂载时初始化数据
*
* 注意:这里会先加载列表数据,如果 URL 中有 AI 参数(如搜索关键字),
* useAiAction 会在 nextTick 中再次执行搜索,这是预期行为
*/
onMounted(() => {
handleQuery();