web登录注册修改

This commit is contained in:
2025-12-24 12:06:59 +08:00
parent 1b80fda0d7
commit 3d1e19030a
20 changed files with 662 additions and 252 deletions

View File

@@ -7,6 +7,7 @@
import { api } from '@/apis/index';
import type { ResultDomain } from '@/types';
import type { ConfigItem, SaveConfigParam } from '@/types/system/config';
import type { SystemBaseInfo } from '@/types/system/baseinfo';
/**
* 系统配置API服务
*/
@@ -58,5 +59,14 @@ export const configApi = {
async deleteConfig(configKey: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`/system/config/${configKey}`);
return response.data;
},
/**
* 获取系统基础信息(公开接口)
* @returns Promise<ResultDomain<SystemBaseInfo>>
*/
async getBaseInfo(): Promise<ResultDomain<SystemBaseInfo>> {
const response = await api.get<SystemBaseInfo>('/system/config/baseinfo');
return response.data;
}
};

View File

@@ -19,11 +19,11 @@
<!-- 已登录状态 -->
<div class="user-info" v-else>
<div class="user-avatar">
<img :src="userAvatar" :alt="user?.username" v-if="userAvatar">
<img :src="userAvatar" :alt="currentUser?.username" v-if="userAvatar">
<span class="avatar-placeholder" v-else>{{ avatarText }}</span>
</div>
<div class="user-details" v-if="!collapsed">
<div class="user-name">{{ user?.username }}</div>
<div class="user-name">{{ currentUser?.username }}</div>
<div class="user-role">{{ primaryRole }}</div>
</div>
</div>
@@ -103,7 +103,9 @@ interface Props {
}
const props = defineProps<Props>();
const user = computed(() => store.getters['auth/user']);
// 使用传入的 userinfo 或从 store 获取
const currentUser = computed(() => props.userinfo || store.getters['auth/user']);
// Emits
const emit = defineEmits<{
@@ -125,11 +127,12 @@ const isLoggedIn = computed(() => {
});
const userAvatar = computed(() => {
return props.userinfo?.avatar && props.userinfo?.avatar!='default' ? FILE_DOWNLOAD_URL + props.userinfo?.avatar : defaultAvatarImg;
const avatar = currentUser.value?.avatar;
return avatar && avatar !== 'default' ? FILE_DOWNLOAD_URL + avatar : defaultAvatarImg;
});
const avatarText = computed(() => {
const name = props.userinfo?.fullName || props.userinfo?.username || '';
const name = currentUser.value?.fullName || currentUser.value?.username || '';
return name.charAt(0).toUpperCase();
});

View File

@@ -21,37 +21,16 @@
<nav class="sidebar-nav">
<MenuSidebar :menus="sidebarMenus" :collapsed="false" @menu-click="handleMenuClick" />
</nav>
<div class="sidebar-footer">
<div class="sidebar-footer-contain">
<!-- 用户头像+名称 -->
<div class="user-info">
<img :src="getUserAvatar()" alt="用户头像" class="user-avatar" />
<span>{{ user?.username }}</span>
</div>
<div class="dropdown-item" @click="toggleDropdown">
<img src="@/assets/imgs/else.svg" alt="其他" class="dropdown-icon" />
</div>
</div>
<transition name="dropdown-up">
<div v-if="showDropdown" class="dropdown-menu" @mouseleave="showDropdown = false">
<div class="dropdown-item">
<img src="@/assets/imgs/home.svg" alt="用户界面" class="dropdown-icon" />
<ChangeHome />
</div>
<div class="dropdown-item" @click="handleLogout">
<img src="@/assets/imgs/logout.svg" alt="退出登录" class="dropdown-icon" />
<span>退出登录</span>
</div>
</div>
</transition>
</div>
</aside>
<!-- 页面内容 -->
<main class="main-content">
<router-view />
<main class="main-wrapper">
<nav class="top-nav">
<UserDropdown :userinfo="user" @logout="handleLogout" />
</nav>
<div class="main-content">
<router-view />
</div>
</main>
</div>
@@ -66,14 +45,12 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import type { SysMenu } from '@/types';
import { MenuType } from '@/types/enums';
import { MenuSidebar, ChangeHome } from '@/components';
import { FILE_DOWNLOAD_URL } from '@/config';
import defaultAvatar from '@/assets/imgs/default-avatar.png';
import { MenuSidebar, UserDropdown } from '@/components';
const route = useRoute();
const router = useRouter();
@@ -81,8 +58,6 @@ const store = useStore();
const user = computed(() => store.getters['auth/user']);
const showDropdown = ref(false);
// 获取所有菜单
const allMenus = computed(() => store.getters['auth/menuTree']);
@@ -106,17 +81,9 @@ function handleMenuClick(menu: SysMenu) {
}
}
function getUserAvatar() {
return user.value?.avatar ? `${FILE_DOWNLOAD_URL}/${user.value?.avatar}` : defaultAvatar;
}
function handleLogout() {
store.dispatch('auth/logout');
}
function toggleDropdown() {
showDropdown.value = !showDropdown.value;
}
</script>
<style lang="scss" scoped>
@@ -153,9 +120,12 @@ function toggleDropdown() {
}
.sidebar-header {
padding: 24px 24px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
height: 80px;
padding: 12px 24px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
height: 76px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.logo-container {
@@ -215,122 +185,32 @@ function toggleDropdown() {
}
}
.sidebar-footer {
height: 70px;
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
background: #F9FAFB;
overflow: hidden;
height: 100vh;
}
.top-nav {
display: flex;
height: 76px;
justify-content: flex-end;
align-items: center;
justify-content: center;
position: relative;
border-top: 1px solid rgba(0, 0, 0, 0.1);
.sidebar-footer-contain {
display: flex;
align-items: center;
justify-content: space-between;
width: 80%;
height: 80%;
border-radius: 8px;
background-color: #F6F6F6;
.user-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #1f2933;
height: 100%;
}
.user-avatar {
/* width: 20px; */
height: 80%;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.dropdown-item {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
border-radius: 6px;
&:hover {
background-color: #f5f5f5;
}
.dropdown-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
object-fit: contain;
}
}
}
}
.dropdown-menu {
position: absolute;
bottom: 52px;
left: 50%;
transform: translateX(-50%);
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: 8px 0;
min-width: 180px;
z-index: 10;
.dropdown-item {
padding: 8px 16px;
display: flex;
align-items: center;
cursor: pointer;
span,
.change-home-text {
font-size: 14px;
}
.dropdown-icon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
object-fit: contain;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.dropdown-up-enter-active,
.dropdown-up-leave-active {
transition: all 0.15s ease-out;
}
.dropdown-up-enter-from,
.dropdown-up-leave-to {
opacity: 0;
transform: translate(-50%, 6px);
}
.dropdown-up-enter-to,
.dropdown-up-leave-from {
opacity: 1;
transform: translate(-50%, 0);
padding: 12px 20px;
background: #FFFFFF;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
flex-shrink: 0;
min-height: 48px;
}
.main-content {
flex: 1;
background: #F9FAFB;
overflow-y: auto;
/* min-width: 0; */
padding: 20px;
box-sizing: border-box;
height: 100vh;
// 美化滚动条
&::-webkit-scrollbar {

View File

@@ -22,6 +22,14 @@ async function initApp() {
app.use(ElementPlus);
app.use(store);
// 加载系统基础信息Logo、系统名称、登录开关等
try {
await store.dispatch('system/loadBaseInfo');
console.log('[应用初始化] 系统基础信息加载成功');
} catch (error) {
console.error('[应用初始化] 系统基础信息加载失败:', error);
}
// 在路由初始化前,尝试恢复登录状态并生成动态路由
const authState = (store.state as any).auth;

View File

@@ -1,5 +1,6 @@
import { createStore } from "vuex";
import authModule from './modules/auth';
import systemModule from './modules/system';
export default createStore({
state: {},
@@ -7,6 +8,7 @@ export default createStore({
mutations: {},
actions: {},
modules: {
auth: authModule
auth: authModule,
system: systemModule
},
});

View File

@@ -0,0 +1,93 @@
import { configApi } from '@/apis/system';
import type { SystemBaseInfo } from '@/types/system/baseinfo';
import { FILE_DOWNLOAD_URL } from '@/config';
interface SystemState {
baseInfo: SystemBaseInfo | null;
loading: boolean;
}
export default {
namespaced: true,
state: (): SystemState => ({
baseInfo: null,
loading: false
}),
getters: {
// 短信登录是否启用
smsLoginEnabled: (state: SystemState) => state.baseInfo?.smsLoginEnabled || false,
// 邮箱登录是否启用
emailLoginEnabled: (state: SystemState) => state.baseInfo?.emailLoginEnabled || false,
// 系统名称
systemName: (state: SystemState) => state.baseInfo?.systemName || '红色思政学习平台',
// 系统简称
systemShortName: (state: SystemState) => state.baseInfo?.systemShortName || '思政平台',
// Logo URL自动拼接fileId为空时使用默认图片
loginLogoUrl: (state: SystemState) => {
const fileId = state.baseInfo?.loginLogo;
return fileId ? `${FILE_DOWNLOAD_URL}/${fileId}` : '/logo-icon.svg';
},
homeLogoUrl: (state: SystemState) => {
const fileId = state.baseInfo?.homeLogo;
return fileId ? `${FILE_DOWNLOAD_URL}/${fileId}` : '/logo-icon.svg';
},
adminLogoUrl: (state: SystemState) => {
const fileId = state.baseInfo?.adminLogo;
return fileId ? `${FILE_DOWNLOAD_URL}/${fileId}` : '/logo-icon.svg';
},
faviconUrl: (state: SystemState) => {
const fileId = state.baseInfo?.favicon;
return fileId ? `${FILE_DOWNLOAD_URL}/${fileId}` : '/favicon.ico';
}
},
mutations: {
SET_BASE_INFO(state: SystemState, info: SystemBaseInfo) {
state.baseInfo = info;
},
SET_LOADING(state: SystemState, loading: boolean) {
state.loading = loading;
}
},
actions: {
async loadBaseInfo({ commit, getters }: any) {
commit('SET_LOADING', true);
try {
const result = await configApi.getBaseInfo();
if (result.success && result.data) {
commit('SET_BASE_INFO', result.data);
// 动态设置favicon
const faviconUrl = getters.faviconUrl;
if (faviconUrl && faviconUrl !== '/favicon.ico') {
setFavicon(faviconUrl);
}
}
} catch (error) {
console.error('加载系统基础信息失败:', error);
} finally {
commit('SET_LOADING', false);
}
}
}
};
/**
* 动态设置网站图标
*/
function setFavicon(faviconUrl: string) {
const link = document.querySelector("link[rel*='icon']") as HTMLLinkElement || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = faviconUrl;
document.getElementsByTagName('head')[0].appendChild(link);
}

View File

@@ -0,0 +1,16 @@
/**
* 系统基础信息接口
*/
export interface SystemBaseInfo {
// 基础信息
systemName: string; // 系统名称
systemShortName: string; // 系统简称
loginLogo: string; // 登录页LogofileId
homeLogo: string; // 首页LogofileId
adminLogo: string; // 管理后台LogofileId
favicon: string; // 网站图标fileId
// 登录开关(来自不同分组)
smsLoginEnabled: boolean; // 短信登录开关sms分组
emailLoginEnabled: boolean; // 邮箱登录开关email分组
}

View File

@@ -104,6 +104,27 @@
/>
<span v-if="item.remark" class="form-item-remark">{{ item.remark }}</span>
</el-form-item>
<!-- 图片上传 -->
<el-form-item
v-else-if="getRenderType(item) === 'imgupload'"
:label="item.configName || item.configKey"
:prop="item.configKey"
>
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="true"
:limit="1"
list-type="picture-card"
:on-success="(response: any) => handleUploadSuccess(response, group.groupKey, item.configKey)"
:on-remove="() => handleRemove(group.groupKey, item.configKey)"
:file-list="getFileList(group.groupKey, item.configKey)"
>
<el-icon><Plus /></el-icon>
</el-upload>
<span v-if="item.remark" class="form-item-remark">{{ item.remark }}</span>
</el-form-item>
</template>
<!-- 操作按钮 -->
@@ -125,11 +146,14 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, computed } from 'vue';
import { AdminLayout } from '@/views/admin';
import { ElMessage } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { configApi } from '@/apis/system';
import type { ConfigItem } from '@/types/system/config';
import { APP_CONFIG, FILE_DOWNLOAD_URL } from '@/config';
import { useStore } from 'vuex';
defineOptions({
name: 'SystemConfigView'
@@ -146,6 +170,13 @@ interface ConfigGroup {
const loading = ref(false);
const saving = ref(false);
const activeTab = ref('');
const store = useStore();
// 上传配置
const uploadUrl = APP_CONFIG.file.uploadUrl;
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${store.state.auth.token}`
}));
// 配置分组列表
const configGroups = ref<ConfigGroup[]>([]);
@@ -256,10 +287,13 @@ async function loadConfigs() {
// 定义分组名称映射
const groupNames: Record<string, string> = {
basic: '基配置',
email: '邮件配置',
storage: '存储配置',
system: '系统参数'
'基础配置': '基配置',
'爬虫配置': '爬虫配置',
'Dify配置': 'Dify配置',
'邮件配置': '邮件配置',
'短信配置': '短信配置',
'存储配置': '存储配置',
'系统参数': '系统参数'
};
// 按分组组织配置
@@ -329,6 +363,41 @@ async function loadConfigs() {
}
}
/**
* 获取文件列表(用于显示已上传的图片)
*/
function getFileList(groupKey: string, configKey: string): any[] {
const fileId = configData[groupKey]?.[configKey];
if (fileId) {
return [{
name: 'image',
url: `${FILE_DOWNLOAD_URL}${fileId}`
}];
}
return [];
}
/**
* 处理上传成功
*/
function handleUploadSuccess(response: any, groupKey: string, configKey: string) {
if (response.success && response.data) {
// 后端返回的是fileId
configData[groupKey][configKey] = response.data;
ElMessage.success('图片上传成功');
} else {
ElMessage.error(response.message || '图片上传失败');
}
}
/**
* 处理删除图片
*/
function handleRemove(groupKey: string, configKey: string) {
configData[groupKey][configKey] = '';
ElMessage.success('图片已删除');
}
/**
* 保存指定分组的配置
*/

View File

@@ -26,15 +26,30 @@
</div>
</div>
<!-- 重置密码方式切换 -->
<div class="reset-type-tabs">
<!-- 不支持密码找回提示 -->
<div v-if="!canResetPassword" class="reset-disabled-notice">
<div class="notice-icon">
<el-icon :size="48" color="#909399"><WarningFilled /></el-icon>
</div>
<h3 class="notice-title">不支持密码找回</h3>
<p class="notice-text">系统管理员尚未开启手机号或邮箱验证码找回功能</p>
<p class="notice-text">请联系管理员重置密码</p>
<el-button type="primary" @click="$router.push('/login')" class="back-button">返回登录</el-button>
</div>
<!-- 重置密码表单 -->
<template v-else>
<!-- 重置密码方式切换 -->
<div class="reset-type-tabs" v-if="showResetTypeTabs">
<div
v-if="smsLoginEnabled"
:class="['tab-item', { active: resetType === 'phone' }]"
@click="switchResetType('phone')"
>
手机号重置
</div>
<div
v-if="emailLoginEnabled"
:class="['tab-item', { active: resetType === 'email' }]"
@click="switchResetType('email')"
>
@@ -140,6 +155,7 @@
</el-button>
</el-form-item>
</el-form>
</template>
</div>
<!-- 底部信息 -->
@@ -156,9 +172,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onUnmounted } from 'vue';
import { ref, reactive, onBeforeUnmount, onUnmounted, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { WarningFilled } from '@element-plus/icons-vue';
import { authApi } from '@/apis/system/auth';
// 响应式引用
@@ -171,9 +189,35 @@ let emailTimer: number | null = null;
// Composition API
const router = useRouter();
const store = useStore();
// 获取系统配置
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 是否支持密码找回
const canResetPassword = computed(() => smsLoginEnabled.value || emailLoginEnabled.value);
// 是否显示重置方式切换标签
const showResetTypeTabs = computed(() => smsLoginEnabled.value && emailLoginEnabled.value);
// 重置方式phone-手机号email-邮箱
const resetType = ref<'phone' | 'email'>('phone');
const getDefaultResetType = (): 'phone' | 'email' => {
if (!smsLoginEnabled.value && emailLoginEnabled.value) {
return 'email';
}
return 'phone';
};
const resetType = ref<'phone' | 'email'>(getDefaultResetType());
// 监听配置变化
watch([smsLoginEnabled, emailLoginEnabled], () => {
if (resetType.value === 'phone' && !smsLoginEnabled.value && emailLoginEnabled.value) {
resetType.value = 'email';
} else if (resetType.value === 'email' && !emailLoginEnabled.value && smsLoginEnabled.value) {
resetType.value = 'phone';
}
});
// 表单数据
const forgotForm = reactive({
@@ -690,9 +734,40 @@ onUnmounted(() => {
line-height: 2;
color: #D9D9D9;
text-align: center;
padding-bottom: 0;
}
}
.reset-disabled-notice {
text-align: center;
padding: 60px 20px;
.notice-icon {
margin-bottom: 24px;
}
.notice-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
}
.notice-text {
font-size: 14px;
color: #606266;
margin: 8px 0;
line-height: 1.6;
}
.back-button {
margin-top: 32px;
width: 200px;
background-color: #C62828;
border: none;
}
}
// Element Plus 组件样式覆盖
:deep(.el-form-item) {
margin-bottom: 16px;

View File

@@ -27,23 +27,38 @@
<h2 class="forgot-password-title">找回密码</h2>
</div>
<!-- 重置密码方式切换 -->
<div class="reset-type-tabs">
<div
:class="['tab-item', { active: resetType === 'phone' }]"
@click="switchResetType('phone')"
>
手机号重置
</div>
<div
:class="['tab-item', { active: resetType === 'email' }]"
@click="switchResetType('email')"
>
邮箱重置
<!-- 不支持密码找回提示 -->
<div v-if="!canResetPassword" class="reset-disabled-notice">
<div class="notice-icon">
<el-icon :size="48" color="#909399"><WarningFilled /></el-icon>
</div>
<h3 class="notice-title">不支持密码找回</h3>
<p class="notice-text">系统管理员尚未开启手机号或邮箱验证码找回功能</p>
<p class="notice-text">请联系管理员重置密码</p>
<el-button type="primary" @click="goToLogin" class="back-button">返回登录</el-button>
</div>
<!-- 忘记密码表单 -->
<!-- 重置密码表单 -->
<template v-else>
<!-- 重置密码方式切换 -->
<div class="reset-type-tabs" v-if="showResetTypeTabs">
<div
v-if="smsLoginEnabled"
:class="['tab-item', { active: resetType === 'phone' }]"
@click="switchResetType('phone')"
>
手机号重置
</div>
<div
v-if="emailLoginEnabled"
:class="['tab-item', { active: resetType === 'email' }]"
@click="switchResetType('email')"
>
邮箱重置
</div>
</div>
<!-- 忘记密码表单 -->
<el-form
ref="forgotFormRef"
:model="forgotForm"
@@ -139,6 +154,7 @@
</el-button>
</el-form-item>
</el-form>
</template>
</div>
<!-- 底部信息 -->
@@ -155,9 +171,11 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ref, reactive, onBeforeUnmount, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { WarningFilled } from '@element-plus/icons-vue';
import { authApi } from '@/apis/system/auth';
// 响应式引用
@@ -170,9 +188,36 @@ let emailTimer: number | null = null;
// Composition API
const router = useRouter();
const store = useStore();
// 获取系统配置
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 是否支持密码找回(至少一种方式启用)
const canResetPassword = computed(() => smsLoginEnabled.value || emailLoginEnabled.value);
// 是否显示重置方式切换标签(两种方式都启用)
const showResetTypeTabs = computed(() => smsLoginEnabled.value && emailLoginEnabled.value);
// 重置方式phone-手机号email-邮箱
const resetType = ref<'phone' | 'email'>('phone');
// 根据配置设置默认值
const getDefaultResetType = (): 'phone' | 'email' => {
if (!smsLoginEnabled.value && emailLoginEnabled.value) {
return 'email';
}
return 'phone';
};
const resetType = ref<'phone' | 'email'>(getDefaultResetType());
// 监听配置变化,自动调整重置方式
watch([smsLoginEnabled, emailLoginEnabled], () => {
if (resetType.value === 'phone' && !smsLoginEnabled.value && emailLoginEnabled.value) {
resetType.value = 'email';
} else if (resetType.value === 'email' && !emailLoginEnabled.value && smsLoginEnabled.value) {
resetType.value = 'phone';
}
});
// 表单数据
const forgotForm = reactive({
@@ -747,4 +792,34 @@ onUnmounted(() => {
margin: 0;
}
}
.reset-disabled-notice {
text-align: center;
padding: 60px 20px;
.notice-icon {
margin-bottom: 24px;
}
.notice-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
}
.notice-text {
font-size: 14px;
color: #606266;
margin: 8px 0;
line-height: 1.6;
}
.back-button {
margin-top: 32px;
width: 200px;
background-color: #C62828;
border: none;
}
}
</style>

View File

@@ -35,6 +35,7 @@
密码登录
</div>
<div
v-if="showCaptchaLoginTab"
:class="['tab-item', { active: loginMode === 'captcha' }]"
@click="switchLoginMode('captcha')"
>
@@ -74,14 +75,16 @@
<!-- 验证码登录模式 -->
<template v-else>
<!-- 登录方式选择 -->
<div class="captcha-type-tabs">
<div class="captcha-type-tabs" v-if="showCaptchaTypeTabs">
<div
v-if="smsLoginEnabled"
:class="['captcha-tab-item', { active: captchaType === 'phone' }]"
@click="switchCaptchaType('phone')"
>
手机号
</div>
<div
v-if="emailLoginEnabled"
:class="['captcha-tab-item', { active: captchaType === 'email' }]"
@click="switchCaptchaType('email')"
>
@@ -196,7 +199,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
@@ -210,19 +213,45 @@ const loginLoading = ref(false);
const showCaptcha = ref(false);
const captchaImage = ref('');
// Composition API
const router = useRouter();
const route = useRoute();
const store = useStore();
// 获取系统配置
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 是否显示验证码登录选项卡
const showCaptchaLoginTab = computed(() => smsLoginEnabled.value || emailLoginEnabled.value);
// 是否显示验证码类型切换选项卡
const showCaptchaTypeTabs = computed(() => smsLoginEnabled.value && emailLoginEnabled.value);
// 登录模式password-密码登录captcha-验证码登录
const loginMode = ref<'password' | 'captcha'>('password');
// 验证码类型phone-手机号email-邮箱
const captchaType = ref<'phone' | 'email'>('phone');
const getDefaultCaptchaType = (): 'phone' | 'email' => {
if (!smsLoginEnabled.value && emailLoginEnabled.value) {
return 'email';
}
return 'phone';
};
const captchaType = ref<'phone' | 'email'>(getDefaultCaptchaType());
// 倒计时
const smsCountdown = ref(0);
const emailCountdown = ref(0);
// Composition API
const router = useRouter();
const route = useRoute();
const store = useStore();
// 监听配置变化
watch([smsLoginEnabled, emailLoginEnabled], () => {
if (captchaType.value === 'phone' && !smsLoginEnabled.value && emailLoginEnabled.value) {
captchaType.value = 'email';
} else if (captchaType.value === 'email' && !emailLoginEnabled.value && smsLoginEnabled.value) {
captchaType.value = 'phone';
}
});
// 表单数据
const loginForm = reactive<LoginParam>({

View File

@@ -35,6 +35,7 @@
密码登录
</div>
<div
v-if="showCaptchaLoginTab"
:class="['tab-item', { active: loginMode === 'captcha' }]"
@click="switchLoginMode('captcha')"
>
@@ -74,14 +75,16 @@
<!-- 验证码登录模式 -->
<template v-else>
<!-- 登录方式选择 -->
<div class="captcha-type-tabs">
<div class="captcha-type-tabs" v-if="showCaptchaTypeTabs">
<div
v-if="smsLoginEnabled"
:class="['captcha-tab-item', { active: captchaType === 'phone' }]"
@click="switchCaptchaType('phone')"
>
手机号
</div>
<div
v-if="emailLoginEnabled"
:class="['captcha-tab-item', { active: captchaType === 'email' }]"
@click="switchCaptchaType('email')"
>
@@ -196,7 +199,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
@@ -210,19 +213,39 @@ const loginLoading = ref(false);
const showCaptcha = ref(false);
const captchaImage = ref('');
// Composition API
const router = useRouter();
const route = useRoute();
const store = useStore();
// 获取系统配置(必须先定义,因为后续初始化会用到)
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 登录模式password-密码登录captcha-验证码登录
const loginMode = ref<'password' | 'captcha'>('password');
// 验证码类型phone-手机号email-邮箱
const captchaType = ref<'phone' | 'email'>('phone');
// 根据配置设置默认值
const getDefaultCaptchaType = (): 'phone' | 'email' => {
// 如果只启用了邮箱登录,默认使用邮箱
if (!smsLoginEnabled.value && emailLoginEnabled.value) {
return 'email';
}
// 其他情况默认使用手机号
return 'phone';
};
const captchaType = ref<'phone' | 'email'>(getDefaultCaptchaType());
// 倒计时
const smsCountdown = ref(0);
const emailCountdown = ref(0);
// Composition API
const router = useRouter();
const route = useRoute();
const store = useStore();
// 是否显示验证码登录选项卡(至少一个验证码登录方式启用)
const showCaptchaLoginTab = computed(() => smsLoginEnabled.value || emailLoginEnabled.value);
// 是否显示验证码类型切换选项卡(两种验证码方式都启用)
const showCaptchaTypeTabs = computed(() => smsLoginEnabled.value && emailLoginEnabled.value);
// 表单数据
const loginForm = reactive<LoginParam>({
@@ -334,15 +357,22 @@ const switchCaptchaType = (type: 'phone' | 'email') => {
if (captchaType.value === type) return;
captchaType.value = type;
// 清空相关表单数据和验证
loginFormRef.value?.clearValidate();
loginForm.phone = '';
loginForm.email = '';
loginForm.captcha = '';
loginForm.captchaId = '';
loginFormRef.value?.clearValidate();
};
// 监听配置变化,动态调整验证码类型
watch([smsLoginEnabled, emailLoginEnabled], () => {
// 如果当前选中的验证码类型未启用,切换到可用的类型
if (captchaType.value === 'phone' && !smsLoginEnabled.value && emailLoginEnabled.value) {
captchaType.value = 'email';
} else if (captchaType.value === 'email' && !emailLoginEnabled.value && smsLoginEnabled.value) {
captchaType.value = 'phone';
}
});
// 发送短信验证码
const handleSendSmsCode = async () => {
// 验证手机号

View File

@@ -36,6 +36,7 @@
用户名
</div>
<div
v-if="smsLoginEnabled"
class="tab-item"
:class="{ active: registerType === RegisterType.PHONE }"
@click="switchRegisterType(RegisterType.PHONE)"
@@ -43,6 +44,7 @@
手机号
</div>
<div
v-if="emailLoginEnabled"
class="tab-item"
:class="{ active: registerType === RegisterType.EMAIL }"
@click="switchRegisterType(RegisterType.EMAIL)"
@@ -183,8 +185,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onUnmounted } from 'vue';
import { ref, reactive, computed, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import type { RegisterParam } from '@/types';
import { RegisterType } from '@/types';
@@ -200,10 +203,24 @@ let emailTimer: number | null = null;
// Composition API
const router = useRouter();
const store = useStore();
// 获取系统配置
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 注册方式
const registerType = ref<RegisterType>(RegisterType.USERNAME);
// 监听配置变化
watch([smsLoginEnabled, emailLoginEnabled], () => {
if (registerType.value === RegisterType.PHONE && !smsLoginEnabled.value) {
registerType.value = RegisterType.USERNAME;
} else if (registerType.value === RegisterType.EMAIL && !emailLoginEnabled.value) {
registerType.value = RegisterType.USERNAME;
}
});
// 表单数据
const registerForm = reactive<RegisterParam>({
registerType: RegisterType.USERNAME,

View File

@@ -36,6 +36,7 @@
用户名
</div>
<div
v-if="smsLoginEnabled"
class="tab-item"
:class="{ active: registerType === RegisterType.PHONE }"
@click="switchRegisterType(RegisterType.PHONE)"
@@ -43,6 +44,7 @@
手机号
</div>
<div
v-if="emailLoginEnabled"
class="tab-item"
:class="{ active: registerType === RegisterType.EMAIL }"
@click="switchRegisterType(RegisterType.EMAIL)"
@@ -190,8 +192,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { ref, reactive, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import type { RegisterParam } from '@/types';
import { RegisterType } from '@/types';
@@ -207,10 +210,25 @@ let emailTimer: number | null = null;
// Composition API
const router = useRouter();
const store = useStore();
// 获取系统配置
const smsLoginEnabled = computed(() => store.getters['system/smsLoginEnabled']);
const emailLoginEnabled = computed(() => store.getters['system/emailLoginEnabled']);
// 注册方式
const registerType = ref<RegisterType>(RegisterType.USERNAME);
// 监听配置变化,自动调整注册方式
watch([smsLoginEnabled, emailLoginEnabled], () => {
// 如果当前选中的注册方式未启用,切换到用户名注册
if (registerType.value === RegisterType.PHONE && !smsLoginEnabled.value) {
registerType.value = RegisterType.USERNAME;
} else if (registerType.value === RegisterType.EMAIL && !emailLoginEnabled.value) {
registerType.value = RegisterType.USERNAME;
}
});
// 表单数据
const registerForm = reactive<RegisterParam>({
registerType: RegisterType.USERNAME,

View File

@@ -25,23 +25,19 @@
<!-- 搜索框 -->
<div class="header-search">
<el-input
v-model="localSearchKeyword"
placeholder="搜索文章和课程内容"
class="search-input"
@keyup.enter="handleSearch"
>
<template #suffix>
<el-button
type="primary"
:icon="Search"
@click="handleSearch"
class="search-button"
>
搜索
</el-button>
</template>
</el-input>
<div class="search-box">
<input
v-model="localSearchKeyword"
type="text"
placeholder="搜索文章和课程内容"
@keyup.enter="handleSearch"
/>
<div class="search-button" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
</div>
</div>
</div>
</div>
@@ -438,19 +434,20 @@ onMounted(() => {
.back-button {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
padding: 8px 16px;
background: #f5f7fa;
border: none;
border-radius: 4px;
color: #606266;
cursor: pointer;
transition: all 0.3s;
background: #F9FAFB;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
font-size: 14px;
color: #4A5565;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #e4e7ed;
color: #409eff;
background: #F3F4F6;
color: #E7000B;
border-color: #E7000B;
}
}
@@ -486,21 +483,55 @@ onMounted(() => {
.header-search {
width: 400px;
}
.search-input {
:deep(.el-input__wrapper) {
border-radius: 24px;
padding-right: 0;
}
.search-box {
position: relative;
width: 100%;
height: 40px;
background: #FFFFFF;
border: 1px solid rgba(186, 192, 204, 0.5);
border-radius: 30px;
display: flex;
align-items: center;
overflow: hidden;
:deep(.el-input__suffix) {
margin-right: 4px;
input {
flex: 1;
height: 100%;
padding: 0 90px 0 20px;
border: none;
outline: none;
font-family: "Source Han Sans SC";
font-size: 14px;
color: #141F38;
&::placeholder {
color: rgba(0, 0, 0, 0.3);
}
}
.search-button {
border-radius: 20px;
padding: 8px 24px;
position: absolute;
right: 0;
width: 60px;
height: 100%;
background: #C62828;
border-radius: 0 30px 30px 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.3s;
color: white;
&:hover {
background: #B71C1C;
}
.el-icon {
font-size: 18px;
}
}
}