refactor: 整合仪表盘功能到监控模块,清理冗余代码

- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块
- 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name
- 优化搜索工具函数,移除重复导入
- 调整参数配置模型字段长度限制,移除config_value的max_length约束
- 清理冗余的常量定义和导入语句
- 修复批量状态设置接口的redis依赖注入
- 增强OAuth登录安全性,添加租户默认归属和state一次性消费
- 优化资源目录缓存逻辑,减少重复计算
- 新增API Token模块基础框架
- 完善用户token版本管理,支持主动失效JWT
- 调整AI模型配置缓存过期时间
- 修复菜单类型字段索引,提升查询性能
- 简化前端刷新token调用逻辑
- 新增滑块验证完成接口和忘记密码验证码校验
- 调整系统配置默认值,添加操作日志保留天数和接口白名单配置
- 限制Mock支付回调仅在开发环境可用
- 重构websocket认证方式,支持更安全的subprotocol传参
This commit is contained in:
zhangtao
2026-07-13 01:14:20 +08:00
parent 6a5f8cf0dd
commit cf88ab8897
102 changed files with 4078 additions and 3777 deletions
@@ -13,7 +13,6 @@
<!-- 进度条 -->
<div
class="dv_progress_bar"
:class="{ goFirst2: isOk }"
ref="progressBar"
:style="progressBarStyle"
></div>
@@ -28,13 +27,12 @@
<!-- 滑块处理器 -->
<div
class="dv_handler dv_handler_bg"
:class="{ goFirst: isOk }"
@mousedown="dragStart"
@touchstart="dragStart"
ref="handler"
:style="handlerStyle"
>
<FaSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></FaSvgIcon>
<FaSvgIcon :icon="modelValue ? successIcon : handlerIcon" class="text-g-600"></FaSvgIcon>
</div>
</div>
</template>
@@ -45,12 +43,10 @@ import { useWindowSize } from "@vueuse/core";
defineOptions({ name: "FaDragVerify" });
// 事件定义
const emit = defineEmits(["handlerMove", "update:value", "passCallback"]);
const emit = defineEmits(["handlerMove", "passCallback"]);
// 组件属性接口定义
interface Props {
/** 是否通过验证 */
value: boolean;
/** 组件宽度 */
width?: number | string;
/** 组件高度 */
@@ -83,7 +79,6 @@ interface Props {
// 属性默认值设置
const props = withDefaults(defineProps<Props>(), {
value: false,
width: "100%",
height: 40,
text: "按住滑块拖动",
@@ -105,51 +100,42 @@ const effectiveHeight = computed(() =>
props.height === 40 && winWidth.value < 768 ? 24 : props.height
);
// 组件状态接口定义
interface StateType {
isMoving: boolean; // 是否正在拖拽
x: number; // 拖拽起始位置
isOk: boolean; // 是否验证成功
}
// 响应式状态定义
const state = reactive(<StateType>{
isMoving: false,
x: 0,
isOk: false,
});
// 解构响应式状态
const { isOk } = toRefs(state);
// ----- 响应式状态(替换原来的 state 对象) -----
const modelValue = defineModel<boolean>("value", { default: false });
const sliderPosition = ref(0);
const isDragging = ref(false);
const startX = ref(0);
const currentX = ref(0);
// DOM 元素引用
const dragVerify = ref();
const messageRef = ref();
const handler = ref();
const progressBar = ref();
const dragVerify = ref<HTMLElement>();
const messageRef = ref<HTMLElement>();
const handler = ref<HTMLElement>();
const progressBar = ref<HTMLElement>();
// 触摸事件变量 - 用于禁止页面滑动
let startX: number, startY: number, moveX: number, moveY: number;
let touchStartX = 0;
let touchStartY = 0;
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX;
startY = e.targetTouches[0].pageY;
const onTouchStart = (e: TouchEvent) => {
const touch = e.targetTouches[0];
if (!touch) return;
touchStartX = touch.pageX;
touchStartY = touch.pageY;
};
/**
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
* @param e 触摸事件对象
*/
const onTouchMove = (e: any) => {
moveX = e.targetTouches[0].pageX;
moveY = e.targetTouches[0].pageY;
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
const onTouchMove = (e: TouchEvent) => {
const touch = e.targetTouches[0];
if (!touch) return;
const moveX = touch.pageX;
const moveY = touch.pageY;
if (Math.abs(moveX - touchStartX) > Math.abs(moveY - touchStartY)) {
e.preventDefault();
}
};
@@ -157,7 +143,6 @@ const onTouchMove = (e: any) => {
// 获取数值形式的宽度
const getNumericWidth = (): number => {
if (typeof props.width === "string") {
// 如果是字符串,尝试从DOM元素获取实际宽度
return dragVerify.value?.offsetWidth || 260;
}
return props.width;
@@ -173,17 +158,12 @@ const getStyleWidth = (): string => {
// 组件挂载后的初始化
onMounted(() => {
// 设置 CSS 自定义属性
dragVerify.value?.style.setProperty("--textColor", props.textColor);
// 等待DOM更新后设置宽度相关属性
nextTick(() => {
const numericWidth = getNumericWidth();
dragVerify.value?.style.setProperty("--width", Math.floor(numericWidth / 2) + "px");
dragVerify.value?.style.setProperty("--pwidth", -Math.floor(numericWidth / 2) + "px");
});
// 注册 touch 事件监听器,由 onBeforeUnmount 统一清理
document.addEventListener("touchstart", onTouchStart);
document.addEventListener("touchmove", onTouchMove, { passive: false });
});
@@ -194,15 +174,15 @@ onBeforeUnmount(() => {
document.removeEventListener("touchmove", onTouchMove);
});
// 滑块样式计算
// ----- 样式计算 -----
const handlerStyle = computed(() => ({
left: "0",
left: sliderPosition.value + "px",
width: effectiveHeight.value + "px",
height: effectiveHeight.value + "px",
background: props.handlerBg,
transition: isDragging.value ? "none" : "left 0.3s",
}));
// 主容器样式计算
const dragVerifyStyle = computed(() => ({
width: getStyleWidth(),
height: effectiveHeight.value + "px",
@@ -211,13 +191,14 @@ const dragVerifyStyle = computed(() => ({
borderRadius: props.circle ? effectiveHeight.value / 2 + "px" : props.radius,
}));
// 进度条样式计算
const progressBarStyle = computed(() => ({
background: props.progressBarBg,
width: sliderPosition.value + effectiveHeight.value / 2 + "px",
background: modelValue.value ? props.completedBg : props.progressBarBg,
height: effectiveHeight.value + "px",
borderRadius: props.circle
? effectiveHeight.value / 2 + "px 0 0 " + effectiveHeight.value / 2 + "px"
: props.radius,
transition: isDragging.value ? "none" : "width 0.3s",
}));
// 文本样式计算
@@ -227,108 +208,75 @@ const textStyle = computed(() => ({
// 显示消息计算属性
const message = computed(() => {
return props.value ? props.successText : props.text;
return modelValue.value ? props.successText : props.text;
});
/**
* 拖拽开始处理函数
* @param e 鼠标或触摸事件对象
*/
const dragStart = (e: any) => {
if (!props.value) {
state.isMoving = true;
handler.value.style.transition = "none";
// 计算拖拽起始位置
state.x =
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace("px", ""), 10);
}
// ----- 拖拽逻辑 -----
const dragStart = (e: MouseEvent | TouchEvent) => {
if (modelValue.value) return;
isDragging.value = true;
const pageX = "touches" in e ? (e.touches[0]?.pageX ?? 0) : (e as MouseEvent).pageX;
if (typeof pageX !== "number") return;
startX.value = pageX;
currentX.value = sliderPosition.value;
emit("handlerMove");
};
/**
* 拖拽移动处理函数
* @param e 鼠标或触摸事件对象
*/
const dragMoving = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth();
// 计算当前位置
const _x = (e.pageX || e.touches[0].pageX) - state.x;
const dragMoving = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value || modelValue.value) return;
// 在有效范围内移动
if (_x > 0 && _x <= numericWidth - props.height) {
handler.value.style.left = _x + "px";
progressBar.value.style.width = _x + props.height / 2 + "px";
} else if (_x > numericWidth - props.height) {
// 拖拽到末端,触发验证成功
handler.value.style.left = numericWidth - props.height + "px";
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
passVerify();
}
const pageX = "touches" in e ? (e.touches[0]?.pageX ?? 0) : (e as MouseEvent).pageX;
const numericWidth = getNumericWidth();
const maxPosition = numericWidth - effectiveHeight.value;
const newPosition = Math.max(0, Math.min(maxPosition, currentX.value + (pageX - startX.value)));
if (newPosition >= maxPosition) {
// 拖拽到末端,验证成功
sliderPosition.value = maxPosition;
modelValue.value = true;
isDragging.value = false;
emit("passCallback");
} else {
sliderPosition.value = newPosition;
}
};
const dragFinish = () => {
if (!isDragging.value) return;
isDragging.value = false;
const numericWidth = getNumericWidth();
const maxPosition = numericWidth - effectiveHeight.value;
if (sliderPosition.value < maxPosition) {
// 未到末端,复位
sliderPosition.value = 0;
} else {
// 到末端,验证成功
sliderPosition.value = maxPosition;
modelValue.value = true;
emit("passCallback");
}
};
/**
* 拖拽结束处理函数
* @param e 鼠标或触摸事件对象
*/
const dragFinish = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth();
// 计算最终位置
const _x = (e.pageX || e.changedTouches[0].pageX) - state.x;
if (_x < numericWidth - props.height) {
// 未拖拽到末端,重置位置
state.isOk = true;
handler.value.style.left = "0";
handler.value.style.transition = "all 0.2s";
progressBar.value.style.width = "0";
state.isOk = false;
} else {
// 拖拽到末端,保持验证成功状态
handler.value.style.transition = "none";
handler.value.style.left = numericWidth - props.height + "px";
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
passVerify();
}
state.isMoving = false;
}
};
/**
* 验证通过处理函数
*/
const passVerify = () => {
emit("update:value", true);
state.isMoving = false;
// 更新样式为成功状态
progressBar.value.style.background = props.completedBg;
messageRef.value.style["-webkit-text-fill-color"] = "unset";
messageRef.value.style.animation = "slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite";
messageRef.value.style.color = "#fff";
emit("passCallback");
};
/**
* 重置验证状态函数
* 重置验证状态
*/
const reset = () => {
// 重置滑块位置
handler.value.style.left = "0";
progressBar.value.style.width = "0";
progressBar.value.style.background = props.progressBarBg;
// 重置文本样式
messageRef.value.style["-webkit-text-fill-color"] = "transparent";
messageRef.value.style.animation = "slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite";
messageRef.value.style.color = props.background;
// 重置状态
emit("update:value", false);
state.isOk = false;
state.isMoving = false;
state.x = 0;
sliderPosition.value = 0;
modelValue.value = false;
};
// 当外部将 modelValue 设为 false 时(如 getCaptcha 重置),同步复位滑块位置
watch(modelValue, (val) => {
if (!val && sliderPosition.value > 0) {
sliderPosition.value = 0;
}
});
// 暴露重置方法给父组件
defineExpose({
reset,
@@ -351,6 +299,7 @@ defineExpose({
align-items: center;
justify-content: center;
cursor: move;
z-index: 9;
i {
padding-left: 0;
@@ -366,7 +315,6 @@ defineExpose({
.dv_progress_bar {
position: absolute;
width: 0;
height: 34px;
}
@@ -397,16 +345,6 @@ defineExpose({
}
}
}
.goFirst {
left: 0 !important;
transition: left 0.5s;
}
.goFirst2 {
width: 0 !important;
transition: width 0.5s;
}
</style>
<style lang="scss">
@@ -240,14 +240,14 @@ const menuStore = useMenuStore();
const configStore = useConfigStore();
const noticeStore = useNoticeStore();
/** 租户配置:tenant_logo / tenant_name */
/** 租户配置:logo_url / name */
const headerLogoSrc = computed(() => {
const raw = configStore.configData.tenant_logo?.config_value;
const raw = configStore.configData.logo_url?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const headerSystemName = computed(() => {
const raw = configStore.configData.tenant_name?.config_value;
const raw = configStore.configData.name?.config_value;
if (typeof raw === "string" && raw.trim()) return raw.trim();
return AppConfig.systemInfo.name;
});
@@ -11,56 +11,6 @@
<ElTabPane label="AI 模型" name="aiModel">
<FaAiModelConfigPanel />
</ElTabPane>
<ElTabPane label="接口白名单" name="apiWhitelist">
<ElForm :model="configState" label-suffix=":" label-width="100px" label-position="right">
<!-- 系统配置 -->
<ElDivider>接口白名单</ElDivider>
<div v-for="(item, key) in apiWhitelistConfigs" :key="key">
<ElFormItem :label="item?.config_name">
<div class="space-y-2">
<div
v-for="listItem in apiWhitelistItems"
:key="listItem.id"
class="flex items-center gap-2"
>
<ElInput
v-model="listItem.value"
:placeholder="'/api/v1/users/get'"
clearable
@input="markModified(key)"
@blur="
{
if (!isValidApiPath(listItem.value) && listItem.value.trim()) {
ElMessage.warning('请输入有效的接口路径格式(以/开头)');
}
}
"
/>
<ElButton
type="danger"
icon="minus"
circle
size="small"
@click="removeApiWhitelistItem(listItem.id)"
/>
</div>
<ElButton
type="primary"
icon="plus"
size="small"
:style="'margin-top: 10px'"
@click="addApiWhitelistItem"
>
添加接口路径
</ElButton>
<div class="text-xs text-gray-500 mt-2">
配置说明:添加到白名单的接口路径无需登录即可访问,支持完整路径配置。
</div>
</div>
</ElFormItem>
</div>
</ElForm>
</ElTabPane>
<ElTabPane label="IP黑名单" name="ipBlacklist">
<ElForm :model="configState" label-suffix=":" label-width="100px" label-position="right">
<!-- 系统配置 -->
@@ -228,7 +178,7 @@ interface ListItem {
// 生成唯一ID
const generateId = () => {
return Math.random().toString(36).substr(2, 9);
return Math.random().toString(36).substring(2, 11);
};
// IP地址验证函数
@@ -238,20 +188,14 @@ const isValidIp = (ip: string): boolean => {
return ipRegex.test(ip);
};
// 接口路径验证函数
const isValidApiPath = (path: string): boolean => {
const pathRegex = /^\/[\w\-/]+$/;
return pathRegex.test(path);
};
const drawerSize = ref("60%");
const t = useI18n().t;
const configStore = useConfigStore();
const activeTabRef = ref("apiWhitelist");
const activeTabRef = ref("ipBlacklist");
// 与父组件的 v-model 同步
// 配置状态管理与父组件的 v-model 同步
interface Props {
modelValue: boolean;
}
@@ -295,24 +239,7 @@ const submitChanges = async () => {
if (keysToSubmit.length === 0) return;
try {
// 准备提交数据
// 1. 处理接口白名单
if (
"white_api_list_path" in modifiedFields &&
apiWhitelistConfigs.value.white_api_list_path?.id
) {
const apiWhitelistArray = apiWhitelistItems.value
.map((item) => item.value.trim())
.filter(Boolean);
// 转换为JSON字符串格式保存
const apiWhitelistJson = JSON.stringify(apiWhitelistArray);
await ParamsAPI.updateParams(apiWhitelistConfigs.value.white_api_list_path.id, {
...apiWhitelistConfigs.value.white_api_list_path,
config_value: apiWhitelistJson,
});
}
// 2. 处理IP黑名单
// 1. 处理IP黑名单
if ("ip_black_list" in modifiedFields && ipBlacklistConfigs.value.ip_black_list?.id) {
const ipBlacklistArray = ipBlacklistItems.value
.map((item) => item.value.trim())
@@ -340,7 +267,7 @@ const submitChanges = async () => {
// 4. 处理其他配置项(已迁移到租户管理的配置不再处理)
const otherKeys = keysToSubmit.filter(
(key) => !["white_api_list_path", "ip_black_list", "ip_white_list"].includes(key)
(key) => !["ip_black_list", "ip_white_list"].includes(key)
);
const otherUpdatePromises = otherKeys.map((key) => {
const item = demoConfigs.value[key as keyof typeof demoConfigs.value];
@@ -393,8 +320,6 @@ async function onDrawerClosed() {
await resetForm();
}
// 接口白名单配置 - 动态管理
const apiWhitelistItems = ref<ListItem[]>([]);
// IP黑名单配置 - 动态管理
const ipBlacklistItems = ref<ListItem[]>([]);
// IP白名单配置 - 动态管理
@@ -402,34 +327,6 @@ const demoIpWhitelistItems = ref<ListItem[]>([]);
// 从配置数据初始化列表
const initializeLists = () => {
// 初始化接口白名单
const apiWhitelistStr = configStore.configData.white_api_list_path?.config_value || "";
try {
// 尝试解析为JSON数组
const apiWhitelistArray = JSON.parse(apiWhitelistStr);
if (Array.isArray(apiWhitelistArray)) {
apiWhitelistItems.value = apiWhitelistArray
.filter((item) => typeof item === "string" && item.trim())
.map((item) => ({ id: generateId(), value: item.trim() }));
} else {
// 如果不是数组,回退到按换行符分割
apiWhitelistItems.value = apiWhitelistStr
? apiWhitelistStr
.split("\n")
.filter((item) => item.trim())
.map((item) => ({ id: generateId(), value: item.trim() }))
: [{ id: generateId(), value: "" }];
}
} catch {
// 解析失败,回退到按换行符分割
apiWhitelistItems.value = apiWhitelistStr
? apiWhitelistStr
.split("\n")
.filter((item) => item.trim())
.map((item) => ({ id: generateId(), value: item.trim() }))
: [{ id: generateId(), value: "" }];
}
// 初始化IP黑名单
const ipBlacklistStr = configStore.configData.ip_black_list?.config_value || "";
try {
@@ -487,22 +384,6 @@ const initializeLists = () => {
}
};
// 添加接口白名单项
const addApiWhitelistItem = () => {
apiWhitelistItems.value.push({ id: generateId(), value: "" });
markModified("white_api_list_path");
};
// 移除接口白名单项
const removeApiWhitelistItem = (id: string) => {
if (apiWhitelistItems.value.length <= 1) {
ElMessage.warning("至少需要保留一个接口白名单配置");
return;
}
apiWhitelistItems.value = apiWhitelistItems.value.filter((item) => item.id !== id);
markModified("white_api_list_path");
};
// 添加IP黑名单项
const addIpBlacklistItem = () => {
ipBlacklistItems.value.push({ id: generateId(), value: "" });
@@ -535,11 +416,6 @@ const removeDemoIpWhitelistItem = (id: string) => {
markModified("ip_white_list");
};
// 接口白名单配置项
const apiWhitelistConfigs = computed(() => ({
white_api_list_path: configStore.configData.white_api_list_path as ConfigTable | undefined,
}));
// IP黑名单配置项
const ipBlacklistConfigs = computed(() => ({
ip_black_list: configStore.configData.ip_black_list as ConfigTable | undefined,
@@ -160,14 +160,14 @@ const settingStore = useSettingsStore();
const configStore = useConfigStore();
const userStore = useUserStore();
/** 租户配置:tenant_logo / tenant_name */
/** 租户配置:logo_url / name */
const sidebarLogoSrc = computed(() => {
const raw = configStore.configData.tenant_logo?.config_value;
const raw = configStore.configData.logo_url?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const sidebarTitle = computed(() => {
const raw = configStore.configData.tenant_name?.config_value;
const raw = configStore.configData.name?.config_value;
if (typeof raw === "string" && raw.trim()) return raw.trim();
return AppConfig.systemInfo.name;
});
@@ -108,18 +108,18 @@ withDefaults(defineProps<Props>(), {
const configStore = useConfigStore();
/** 接口 tenant_logo,空则 FaLogo 内置默认图 */
/** 接口 logo_url,空则 FaLogo 内置默认图 */
const webLogoSrc = computed(
() => configStore.configData.tenant_logo?.config_value?.trim() || undefined
() => configStore.configData.logo_url?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.tenant_name?.config_value?.trim() || AppConfig.systemInfo.name
() => configStore.configData.name?.config_value?.trim() || AppConfig.systemInfo.name
);
const DEFAULT_APP_VERSION = "3.0.0";
const displayVersion = computed(() => {
const raw = configStore.configData.tenant_version?.config_value?.trim();
const raw = configStore.configData.version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
@@ -13,7 +13,7 @@
<ElFormItem>
<ElSelect
:model-value="demoAccountKey"
class="w-full"
class="custom-height w-full"
:placeholder="$t('login.quickSelectAccount')"
@update:model-value="$emit('setupAccount', $event as AccountKey)"
>
@@ -60,41 +60,6 @@
</ElFormItem>
</ElTooltip>
<ElFormItem v-if="captchaState.enable" prop="captcha" class="login-captcha-row">
<div class="flex w-full items-center gap-2.5">
<ElInput
v-model.trim="loginForm.captcha"
class="custom-height flex-1"
clearable
:placeholder="$t('login.captchaCode')"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><Unlock /></ElIcon>
</template>
</ElInput>
<div
class="login-captcha-img flex h-10 max-md:h-6 w-[100px] max-md:w-[80px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded"
role="button"
:title="$t('login.captchaClickHint')"
@click="$emit('getCaptcha')"
>
<ElIcon v-if="codeLoading" class="is-loading" :size="20">
<Loading />
</ElIcon>
<ElImage
v-else-if="captchaState.img_base"
class="h-full w-full object-cover"
fit="cover"
:src="captchaState.img_base"
/>
<ElText v-else type="info" size="small">
{{ $t("login.captchaClickHint") }}
</ElText>
</div>
</div>
</ElFormItem>
<div class="login-form-tail flex flex-col gap-[1.1rem]">
<div class="relative pb-3">
<div
@@ -108,7 +73,7 @@
:text-color="dragVerifyTextColor"
:success-text="$t('login.sliderSuccessText')"
progress-bar-bg="var(--el-color-success)"
:background="isDark ? '#26272F' : '#F1F1F4'"
:background="isDark ? '#26272F' : 'var(--el-border-color-light)'"
handler-bg="var(--default-box-color)"
/>
</div>
@@ -168,7 +133,7 @@
</template>
<script setup lang="ts">
import { Loading, Lock, Unlock, User } from "@element-plus/icons-vue";
import { Lock, User } from "@element-plus/icons-vue";
import type { CaptchaInfo, LoginFormData } from "@/api/module_system/auth";
import type { FormRules } from "element-plus";
import type { Account, AccountKey } from "@views/module_system/auth/login/types";
@@ -229,4 +194,10 @@ defineExpose({
<style scoped lang="scss">
@use "../fa-login";
.el-select.custom-height {
:deep(.el-select__wrapper) {
height: 40px;
}
}
</style>
@@ -18,6 +18,7 @@
<div
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
>
<div class="color-picker-expandable relative flex items-center max-sm:hidden!">
<div
class="color-dots absolute right-0 rounded-full flex items-center gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
@@ -105,12 +106,38 @@
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
<!-- 租户切换 -->
<ElDropdown
@command="onTenantChange"
popper-class="langDropDownStyle"
trigger="hover"
>
<div
class="btn tenant-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
:title="currentTenantName"
>
<FaSvgIcon icon="ri:building-line" class="text-xl transition-colors duration-300 text-g-800" />
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="t in tenantOptions" :key="t.id" class="lang-btn-item">
<ElDropdownItem
:command="t.id"
:class="{ 'is-selected': t.id === (currentTenantId || 1) }"
>
<span class="menu-txt">{{ t.name }}</span>
<FaSvgIcon icon="ri:check-fill" class="text-base" v-if="t.id === (currentTenantId || 1)" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import { useSettingsStore, useUserStore, useConfigStore } from "@stores";
@@ -120,6 +147,7 @@ import { languageOptions } from "@/locales";
import { LanguageEnum } from "@/enums/appEnum";
import AppConfig from "@/config";
import { LoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
import AuthAPI from "@/api/module_system/auth";
defineOptions({ name: "AuthTopBar" });
@@ -134,6 +162,7 @@ const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:panelAlign": [value: LoginPanelAlign];
"tenantChange": [tenantId: number];
}
const emit = defineEmits<Emits>();
@@ -172,15 +201,15 @@ const mainColors = AppConfig.systemMainColor;
const themeColorForCss = computed(() => systemThemeColor.value);
const webLogoSrc = computed(
() => configStore.configData.tenant_logo?.config_value?.trim() || undefined
() => configStore.configData.logo_url?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.tenant_name?.config_value?.trim() || AppConfig.systemInfo.name
() => configStore.configData.name?.config_value?.trim() || AppConfig.systemInfo.name
);
const displayVersion = computed(() => {
const raw = configStore.configData.tenant_version?.config_value?.trim();
const raw = configStore.configData.version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
@@ -191,6 +220,41 @@ const changeLanguage = (lang: LanguageEnum) => {
userStore.setLanguage(lang);
};
// ── 租户切换 ──
interface TenantItem {
id: number;
name: string;
code: string;
}
const tenantOptions = ref<TenantItem[]>([]);
const currentTenantId = computed(() => {
const val = configStore.configData?.tenant_id?.config_value;
return val ? Number(val) : undefined;
});
const currentTenantName = computed(() => {
return configStore.configData?.name?.config_value?.trim() || "选择租户";
});
async function fetchTenantOptions() {
try {
const { data } = await AuthAPI.getTenantOptions();
tenantOptions.value = data?.data || [];
} catch {
tenantOptions.value = [];
}
}
function onTenantChange(tenantId: number) {
const numId = Number(tenantId);
if (numId === currentTenantId.value) return;
emit("tenantChange", numId);
}
onMounted(() => {
fetchTenantOptions();
});
const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return;
settingStore.setElementTheme(color);