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

@@ -21,20 +21,30 @@ INSERT INTO `tb_sys_config` (`id`, `config_key`, `config_name`, `config_value`,
('30', 'dify.dataset.defaultIndexingTechnique', '默认索引方式', 'high_quality', 'string', 'select', 'Dify配置', '默认索引方式', NULL, '知识库文档的默认索引方式', NULL, NULL, NULL, NULL, 'high_quality,economy', 30, 1, '1', now()),
('31', 'dify.dataset.defaultEmbeddingModel', '默认Embedding模型', 'text-embedding-ada-002', 'string', 'input', 'Dify配置', '默认Embedding模型', '请输入Embedding模型名称', '知识库使用的默认Embedding模型', NULL, NULL, NULL, NULL, NULL, 31, 1, '1', now()),
-- 基础配置Logo、系统信息
('100', 'system.name', '系统名称', '红色思政学习平台', 'string', 'input', '基础配置', '系统显示名称', '请输入系统名称', '系统对外显示的名称', NULL, NULL, NULL, NULL, NULL, 100, 1, '1', now()),
('101', 'system.shortName', '系统简称', '思政平台', 'string', 'input', '基础配置', '系统简称', '请输入系统简称', '系统的简短名称', NULL, NULL, NULL, NULL, NULL, 101, 1, '1', now()),
('102', 'system.logo.login', '登录页Logo', '', 'string', 'imgupload', '基础配置', '登录页Logo', NULL, '存储文件ID建议尺寸120x120px', NULL, NULL, NULL, NULL, NULL, 102, 1, '1', now()),
('103', 'system.logo.home', '首页Logo', '', 'string', 'imgupload', '基础配置', '首页Logo', NULL, '存储文件ID建议尺寸120x40px', NULL, NULL, NULL, NULL, NULL, 103, 1, '1', now()),
('104', 'system.logo.admin', '管理后台Logo', '', 'string', 'imgupload', '基础配置', '管理后台Logo', NULL, '存储文件ID建议尺寸40x40px', NULL, NULL, NULL, NULL, NULL, 104, 1, '1', now()),
('105', 'system.favicon', '网站图标', '', 'string', 'imgupload', '基础配置', '网站图标', NULL, '存储文件ID建议格式ico/png尺寸32x32px', NULL, NULL, NULL, NULL, NULL, 105, 1, '1', now()),
-- 邮件配置
('40', 'email.host', 'SMTP服务器地址', 'smtp.qq.com', 'string', 'input', '邮件配置', 'SMTP服务器地址', '请输入SMTP服务器地址', '邮件发送服务器地址', NULL, NULL, NULL, NULL, NULL, 40, 1, '1', now()),
('41', 'email.port', 'SMTP端口', '587', 'integer', 'input', '邮件配置', 'SMTP服务器端口', '请输入SMTP端口', '邮件发送服务器端口', NULL, 25, 587, NULL, NULL, 41, 1, '1', now()),
('42', 'email.username', '发件人邮箱', '3223905473@qq.com', 'string', 'input', '邮件配置', '发件人邮箱地址', '请输入发件人邮箱', '用于发送邮件的邮箱账号', NULL, NULL, NULL, NULL, NULL, 42, 1, '1', now()),
('43', 'email.password', '邮箱授权码', 'xmdmxvtjumxocicc', 'string', 'password', '邮件配置', '邮箱授权码/密码', '请输入邮箱授权码', '邮箱的授权码或密码', NULL, NULL, NULL, NULL, NULL, 43, 1, '1', now()),
('44', 'email.fromName', '发件人名称', '校园新闻系统', 'string', 'input', '邮件配置', '发件人显示名称', '请输入发件人名称', '件中显示的发件人名称', NULL, NULL, NULL, NULL, NULL, 44, 1, '1', now()),
('45', 'email.ssl.enable', '启用SSL', 'true', 'boolean', 'switch', '邮件配置', '是否启用SSL', NULL, 'SSL加密连接', NULL, NULL, NULL, NULL, NULL, 45, 1, '1', now()),
('46', 'email.timeout', '连接超时时间', '30000', 'integer', 'input', '邮件配置', '连接超时时间(毫秒)', '请输入超时时间', 'SMTP连接超时时间', NULL, 5000, 60000, '毫秒', NULL, 46, 1, '1', now()),
('40', 'email.login.enabled', '启用邮箱登录', 'false', 'boolean', 'switch', '邮件配置', '是否启用邮箱登录', NULL, '关闭后登录页将不显示邮箱登录选项', NULL, NULL, NULL, NULL, NULL, 40, 1, '1', now()),
('41', 'email.host', 'SMTP服务器地址', 'smtp.qq.com', 'string', 'input', '邮件配置', 'SMTP服务器地址', '请输入SMTP服务器地址', '邮件发送服务器地址', NULL, NULL, NULL, NULL, NULL, 41, 1, '1', now()),
('42', 'email.port', 'SMTP端口', '587', 'integer', 'input', '邮件配置', 'SMTP服务器端口', '请输入SMTP端口', '邮件发送服务器端口', NULL, 25, 587, NULL, NULL, 42, 1, '1', now()),
('43', 'email.username', '发件人邮箱', '3223905473@qq.com', 'string', 'input', '邮件配置', '发件人邮箱地址', '请输入发件人邮箱', '用于发送邮件的邮箱账号', NULL, NULL, NULL, NULL, NULL, 43, 1, '1', now()),
('44', 'email.password', '邮箱授权码', 'xmdmxvtjumxocicc', 'string', 'password', '邮件配置', '邮箱授权码/密码', '请输入邮箱授权码', '箱的授权码或密码', NULL, NULL, NULL, NULL, NULL, 44, 1, '1', now()),
('45', 'email.fromName', '发件人名称', '校园新闻系统', 'string', 'input', '邮件配置', '发件人显示名称', '请输入发件人名称', '邮件中显示的发件人名称', NULL, NULL, NULL, NULL, NULL, 45, 1, '1', now()),
('46', 'email.ssl.enable', '启用SSL', 'true', 'boolean', 'switch', '邮件配置', '是否启用SSL', NULL, 'SSL加密连接', NULL, NULL, NULL, NULL, NULL, 46, 1, '1', now()),
('47', 'email.timeout', '连接超时时间', '30000', 'integer', 'input', '邮件配置', '连接超时时间(毫秒)', '请输入超时时间', 'SMTP连接超时时间', NULL, 5000, 60000, '毫秒', NULL, 47, 1, '1', now()),
-- 短信配置
('50', 'sms.provider', '短信服务商', 'aliyun', 'string', 'select', '短信配置', '短信服务提供商', NULL, '短信服务提供商类型', NULL, NULL, NULL, NULL, 'aliyun', 50, 1, '1', now()),
('51', 'sms.accessKeyId', 'AccessKey ID', 'LTAI5t68do3qVXx5Rufugt3X', 'string', 'input', '短信配置', '短信服务AccessKey ID', '请输入AccessKey ID', '云服务商的AccessKey ID', NULL, NULL, NULL, NULL, NULL, 51, 1, '1', now()),
('52', 'sms.accessKeySecret', 'AccessKey Secret', '2vD9ToIff49Vph4JQXsn0Cy8nXQfzA', 'string', 'password', '短信配置', '短信服务AccessKey Secret', '请输入AccessKey Secret', '云服务商的AccessKey Secret', NULL, NULL, NULL, NULL, NULL, 52, 1, '1', now()),
('53', 'sms.signName', '短信签名', '星洋智慧', 'string', 'input', '短信配置', '短信签名', '请输入短信签名', '发送短信使用的签名', NULL, NULL, NULL, NULL, NULL, 53, 1, '1', now()),
('54', 'sms.templateCode.login', '登录验证码模板', 'SMS_491985030', 'string', 'input', '短信配置', '登录验证码模板编码', '请输入模板编码', '登录验证码短信模板', NULL, NULL, NULL, NULL, NULL, 54, 1, '1', now()),
('55', 'sms.templateCode.register', '注册验证码模板', 'SMS_491985030', 'string', 'input', '短信配置', '注册验证码模板编码', '请输入模板编码', '注册验证码短信模板', NULL, NULL, NULL, NULL, NULL, 55, 1, '1', now()),
('56', 'sms.timeout', '请求超时时间', '30000', 'integer', 'input', '短信配置', '请求超时时间(毫秒)', '请输入超时时间', 'API请求超时时间', NULL, 5000, 60000, '毫秒', NULL, 56, 1, '1', now());
('50', 'sms.login.enabled', '启用短信登录', 'false', 'boolean', 'switch', '短信配置', '是否启用短信登录', NULL, '关闭后登录页将不显示短信登录选项', NULL, NULL, NULL, NULL, NULL, 50, 1, '1', now()),
('51', 'sms.provider', '短信服务商', 'aliyun', 'string', 'select', '短信配置', '短信服务提供商', NULL, '短信服务提供商类型', NULL, NULL, NULL, NULL, 'aliyun', 51, 1, '1', now()),
('52', 'sms.accessKeyId', 'AccessKey ID', 'LTAI5t68do3qVXx5Rufugt3X', 'string', 'input', '短信配置', '短信服务AccessKey ID', '请输入AccessKey ID', '云服务商的AccessKey ID', NULL, NULL, NULL, NULL, NULL, 52, 1, '1', now()),
('53', 'sms.accessKeySecret', 'AccessKey Secret', '2vD9ToIff49Vph4JQXsn0Cy8nXQfzA', 'string', 'password', '短信配置', '短信服务AccessKey Secret', '请输入AccessKey Secret', '云服务商的AccessKey Secret', NULL, NULL, NULL, NULL, NULL, 53, 1, '1', now()),
('54', 'sms.signName', '短信签名', '星洋智慧', 'string', 'input', '短信配置', '短信签名', '请输入短信签名', '发送短信使用的签名', NULL, NULL, NULL, NULL, NULL, 54, 1, '1', now()),
('55', 'sms.templateCode.login', '登录验证码模板', 'SMS_491985030', 'string', 'input', '短信配置', '登录验证码模板编码', '请输入模板编码', '登录验证码短信模板', NULL, NULL, NULL, NULL, NULL, 55, 1, '1', now()),
('56', 'sms.templateCode.register', '注册验证码模板', 'SMS_491985030', 'string', 'input', '短信配置', '注册验证码模板编码', '请输入模板编码', '注册验证码短信模板', NULL, NULL, NULL, NULL, NULL, 56, 1, '1', now()),
('57', 'sms.timeout', '请求超时时间', '30000', 'integer', 'input', '短信配置', '请求超时时间(毫秒)', '请输入超时时间', 'API请求超时时间', NULL, 5000, 60000, '毫秒', NULL, 57, 1, '1', now());

View File

@@ -119,6 +119,7 @@ school-news:
- "/static/**"
- "/file/download/**"
- "/ai/chat/stream/**"
- "/system/config/baseinfo"
crawler:

View File

@@ -112,4 +112,10 @@ public interface SysConfigService {
* @return 配置项详情
*/
ResultDomain<TbSysConfig> getConfigById(String id);
/**
* 获取系统基础信息(公开接口,用于前端)
* @return 基础配置信息Map
*/
ResultDomain<Map<String, Object>> getBaseInfo();
}

View File

@@ -104,4 +104,13 @@ public class ConfigController {
public ResultDomain<TbSysConfig> getConfigById(@PathVariable String id) {
return sysConfigService.getConfigById(id);
}
/**
* @description 获取系统基础信息(公开接口,无需登录)
* @return ResultDomain<Map> 基础配置信息
*/
@GetMapping("/baseinfo")
public ResultDomain<Map<String, Object>> getBaseInfo() {
return sysConfigService.getBaseInfo();
}
}

View File

@@ -11,6 +11,7 @@ import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.dto.system.TbSysConfig;
import org.xyzh.system.mapper.SysConfigMapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -371,4 +372,31 @@ public class SysConfigServiceImpl implements SysConfigService {
return resultDomain;
}
}
@Override
public ResultDomain<Map<String, Object>> getBaseInfo() {
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
try {
Map<String, Object> baseInfo = new HashMap<>();
// 基础信息配置
baseInfo.put("systemName", getStringConfig("system.name"));
baseInfo.put("systemShortName", getStringConfig("system.shortName"));
baseInfo.put("loginLogo", getStringConfig("system.logo.login"));
baseInfo.put("homeLogo", getStringConfig("system.logo.home"));
baseInfo.put("adminLogo", getStringConfig("system.logo.admin"));
baseInfo.put("favicon", getStringConfig("system.favicon"));
// 登录相关开关(从各自分组获取)
baseInfo.put("smsLoginEnabled", getBooleanConfig("sms.login.enabled"));
baseInfo.put("emailLoginEnabled", getBooleanConfig("email.login.enabled"));
resultDomain.success("获取成功", baseInfo);
return resultDomain;
} catch (Exception e) {
logger.error("获取系统基础信息失败", e);
resultDomain.fail("获取系统基础信息失败: " + e.getMessage());
return resultDomain;
}
}
}

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">
<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;
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%;
}
flex-direction: column;
background: #F9FAFB;
overflow: hidden;
height: 100vh;
}
.user-avatar {
/* width: 20px; */
height: 80%;
border-radius: 50%;
object-fit: cover;
.top-nav {
display: flex;
height: 76px;
justify-content: flex-end;
align-items: center;
padding: 12px 20px;
background: #FFFFFF;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
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);
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 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">
<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,6 +734,37 @@ 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;
}
}

View File

@@ -27,15 +27,30 @@
<h2 class="forgot-password-title">找回密码</h2>
</div>
<!-- 不支持密码找回提示 -->
<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">
<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')"
>
@@ -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
<div class="search-box">
<input
v-model="localSearchKeyword"
type="text"
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-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;
}
}
}