mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 05:02:57 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/monitor/dashboard";
|
||||
const API_PATH = "/monitor/online";
|
||||
|
||||
export interface RecentLoginItem {
|
||||
username: string;
|
||||
|
||||
@@ -68,62 +68,12 @@ const TenantAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
/** 套餐变更影响预览 */
|
||||
getPackageChangePreview(tenantId: number, newPackageId: number) {
|
||||
/** 套餐变更预览 */
|
||||
previewPackageChange(packageId: number) {
|
||||
return request<ApiResponse<PackageChangePreview>>({
|
||||
url: `${API_PATH}/${tenantId}/package-change-preview`,
|
||||
url: `${API_PATH}/package/preview`,
|
||||
method: "get",
|
||||
params: { new_package_id: newPackageId },
|
||||
});
|
||||
},
|
||||
|
||||
getTenantUsers(tenantId: number) {
|
||||
return request<ApiResponse<TenantUser[]>>({
|
||||
url: `${API_PATH}/${tenantId}/users`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
addTenantUser(tenantId: number, body: TenantUserAddForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${tenantId}/users`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
removeTenantUser(tenantId: number, userId: number) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${tenantId}/users/${userId}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 公开接口:无需登录即可获取租户配置(用于登录页等场景) */
|
||||
getTenantConfigInfo(tenantId: number) {
|
||||
return request<ApiResponse<TenantConfigItem[]>>({
|
||||
url: `${API_PATH}/${tenantId}/config/info`,
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: NO_AUTH_FLAG,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取租户个性化配置 */
|
||||
getTenantConfig(tenantId: number) {
|
||||
return request<ApiResponse<TenantConfigItem[]>>({
|
||||
url: `${API_PATH}/${tenantId}/config`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 批量更新租户个性化配置 */
|
||||
updateTenantConfig(tenantId: number, body: TenantConfigItem[]) {
|
||||
return request<ApiResponse<TenantConfigItem[]>>({
|
||||
url: `${API_PATH}/${tenantId}/config`,
|
||||
method: "put",
|
||||
data: body,
|
||||
params: { target_package_id: packageId },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -135,12 +85,14 @@ const TenantAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
/** 套餐变更预览 */
|
||||
previewPackageChange(packageId: number) {
|
||||
return request<ApiResponse<PackageChangePreview>>({
|
||||
url: `${API_PATH}/package/preview`,
|
||||
/** 公开接口:无需登录即可获取租户配置(用于登录页等场景) */
|
||||
getTenantConfigInfo(tenantId: number) {
|
||||
return request<ApiResponse<TenantConfigItem[]>>({
|
||||
url: `${API_PATH}/${tenantId}/config/info`,
|
||||
method: "get",
|
||||
params: { target_package_id: packageId },
|
||||
headers: {
|
||||
Authorization: NO_AUTH_FLAG,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -291,40 +243,6 @@ export interface TenantUpdateForm extends BaseFormType {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 套餐变更影响预览 */
|
||||
export interface PackageChangePreview {
|
||||
new_package_id: number;
|
||||
new_package_name: string;
|
||||
affected_roles: Record<string, unknown>[];
|
||||
removed_menus: Record<string, unknown>[];
|
||||
added_menus: Record<string, unknown>[];
|
||||
quota_changes: Record<string, unknown>;
|
||||
total_affected_users: number;
|
||||
}
|
||||
|
||||
export interface TenantUser {
|
||||
id: number;
|
||||
user_id: number;
|
||||
tenant_id: number;
|
||||
role: string;
|
||||
is_default: number;
|
||||
create_time?: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TenantUserAddForm {
|
||||
user_id: number;
|
||||
role: string;
|
||||
is_default: number;
|
||||
}
|
||||
|
||||
/** 租户配置项 */
|
||||
export interface TenantConfigItem {
|
||||
config_key: string;
|
||||
config_value: string | null;
|
||||
}
|
||||
|
||||
export interface AvailablePackage {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -340,6 +258,12 @@ export interface AvailablePackage {
|
||||
available_actions: string[];
|
||||
}
|
||||
|
||||
/** 租户配置项 */
|
||||
export interface TenantConfigItem {
|
||||
config_key: string;
|
||||
config_value: string | null;
|
||||
}
|
||||
|
||||
export interface PackageChangePreview {
|
||||
current_package: string;
|
||||
target_package: string;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/system/token";
|
||||
|
||||
const ApiTokenAPI = {
|
||||
/** 创建 API Token */
|
||||
createToken(body: ApiTokenCreateForm) {
|
||||
return request<ApiResponse<ApiTokenCreatedSchema>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页列表 */
|
||||
listToken(query: ApiTokenPageQuery) {
|
||||
return request<ApiResponse<PageResult<ApiTokenTable>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
/** 详情 */
|
||||
detailToken(id: number) {
|
||||
return request<ApiResponse<ApiTokenTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 重置 token */
|
||||
resetToken(id: number, body: { name?: string; description?: string }) {
|
||||
return request<ApiResponse<ApiTokenCreatedSchema>>({
|
||||
url: `${API_PATH}/${id}/reset`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 启用/禁用 */
|
||||
setTokenStatus(id: number, body: { status: number }) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${id}/status`,
|
||||
method: "patch",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除 */
|
||||
deleteToken(id: number) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 查看明文(二次验证) */
|
||||
revealToken(id: number, body: { password: string }) {
|
||||
return request<ApiResponse<ApiTokenRevealSchema>>({
|
||||
url: `${API_PATH}/${id}/reveal`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default ApiTokenAPI;
|
||||
|
||||
export interface ApiTokenPageQuery extends PageQuery {
|
||||
name?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ApiTokenTable extends BaseType {
|
||||
name?: string;
|
||||
token_prefix?: string;
|
||||
scopes?: string[];
|
||||
expires_at?: string;
|
||||
rate_limit?: number;
|
||||
status?: number;
|
||||
last_used_at?: string;
|
||||
used_count?: number;
|
||||
description?: string;
|
||||
tenant_id?: number;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreateForm extends BaseFormType {
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
expires_at?: string;
|
||||
rate_limit?: number;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreatedSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
/** 创建时完整返回,请立即保存 */
|
||||
token_plain: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenRevealSchema {
|
||||
id: number;
|
||||
token_plain: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
@@ -22,11 +22,11 @@ const AuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
refreshToken(body: RefreshToekenBody) {
|
||||
refreshToken(refreshToken: string) {
|
||||
return request<ApiResponse<JWTOut>>({
|
||||
url: `${API_PATH}/token/refresh`,
|
||||
method: "post",
|
||||
data: body,
|
||||
data: refreshToken,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -104,6 +104,32 @@ const AuthAPI = {
|
||||
params: { domain },
|
||||
});
|
||||
},
|
||||
|
||||
/** 搜索租户(根据关键字模糊搜索编码或名称) */
|
||||
tenantSearch(q: string) {
|
||||
return request<ApiResponse<TenantOption[]>>({
|
||||
url: `${API_PATH}/tenant-search`,
|
||||
method: "get",
|
||||
params: { q },
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取所有活跃租户选项,用于登录页下拉选择 */
|
||||
getTenantOptions() {
|
||||
return request<ApiResponse<TenantOption[]>>({
|
||||
url: `${API_PATH}/tenant-options`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 滑块验证完成后端标记 */
|
||||
sliderComplete(captchaKey: string) {
|
||||
return request<ApiResponse<{ captcha_key: string; verified: boolean }>>({
|
||||
url: `${API_PATH}/captcha/slider/complete`,
|
||||
method: "post",
|
||||
data: { captcha_key: captchaKey },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AuthAPI;
|
||||
@@ -132,7 +158,6 @@ export interface TenantRegisterResult {
|
||||
export interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
captcha?: string;
|
||||
captcha_key?: string;
|
||||
remember?: boolean;
|
||||
login_type?: string;
|
||||
@@ -151,11 +176,6 @@ export interface LoginResult extends JWTOut {
|
||||
tenants?: TenantOption[];
|
||||
}
|
||||
|
||||
/** 刷新 Token 请求体 */
|
||||
export interface RefreshToekenBody {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
/** 退出登录请求体 */
|
||||
export interface LogoutBody {
|
||||
token: string;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -64,6 +64,11 @@ export const useConfigStore = defineStore(
|
||||
}
|
||||
configLoading.value = true;
|
||||
try {
|
||||
// 强制刷新时先清空,避免遗留旧租户的配置
|
||||
if (force) {
|
||||
configData.value = {};
|
||||
}
|
||||
|
||||
// 1. 获取系统级配置(演示模式、IP黑白名单等)
|
||||
const response = await ParamsAPI.getInitConfig();
|
||||
const list = response?.data?.data;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
import { router } from "@/router";
|
||||
import { useSettingsStore } from "./setting.store";
|
||||
import { useWorktabStore } from "./worktab.store";
|
||||
import { useMenuStore } from "./menu.store";
|
||||
import { useConfigStore } from "./config.store";
|
||||
@@ -84,12 +83,6 @@ export const useUserStore = defineStore(
|
||||
|
||||
// 计算属性:基础用户信息
|
||||
const basicInfo = computed(() => info.value as UserInfoLike);
|
||||
// 计算属性:获取设置状态
|
||||
const getSettingState = computed(() => useSettingsStore().$state);
|
||||
// 计算属性:获取工作台状态
|
||||
const getWorktabState = computed(() => useWorktabStore().$state);
|
||||
// 计算属性:获取基础信息
|
||||
const getBasicInfo = computed(() => info.value as UserInfoLike);
|
||||
// 计算属性:获取路由列表
|
||||
const getRouteList = computed(() => routeList.value);
|
||||
// 计算属性:获取权限列表
|
||||
@@ -484,7 +477,7 @@ export const useUserStore = defineStore(
|
||||
throw new Error("没有有效的刷新令牌");
|
||||
}
|
||||
|
||||
const response = await AuthAPI.refreshToken({ refresh_token: currentRefreshToken });
|
||||
const response = await AuthAPI.refreshToken(currentRefreshToken);
|
||||
const data = response.data.data;
|
||||
// 更新令牌,保持当前记住我状态
|
||||
Auth.setTokens(data.access_token, data.refresh_token, Auth.getRememberMe());
|
||||
@@ -519,10 +512,7 @@ export const useUserStore = defineStore(
|
||||
hasGetRoute,
|
||||
rememberMe,
|
||||
getUserInfo,
|
||||
getSettingState,
|
||||
getWorktabState,
|
||||
basicInfo,
|
||||
getBasicInfo,
|
||||
getRouteList,
|
||||
getPerms,
|
||||
getHasGetRoute,
|
||||
|
||||
@@ -324,9 +324,7 @@ request.interceptors.response.use(
|
||||
isRefreshing = true;
|
||||
try {
|
||||
// 直接请求刷新令牌接口,避免动态导入 user.store 造成循环依赖
|
||||
const refreshResp = await AuthAPI.refreshToken({
|
||||
refresh_token: Auth.getRefreshToken(),
|
||||
});
|
||||
const refreshResp = await AuthAPI.refreshToken(Auth.getRefreshToken());
|
||||
const tokenData = refreshResp.data.data;
|
||||
const newAccessToken = tokenData?.access_token || "";
|
||||
const newRefreshToken = tokenData?.refresh_token || "";
|
||||
|
||||
@@ -237,14 +237,13 @@
|
||||
<FaDataListCard
|
||||
class="mb-5"
|
||||
:maxCount="4"
|
||||
:list="dataList"
|
||||
title="最近活动"
|
||||
subtitle="近期活动列表"
|
||||
:list="healthList"
|
||||
title="系统健康"
|
||||
subtitle="实时 · 30s"
|
||||
:showMoreButton="true"
|
||||
@more="handleMore"
|
||||
/>
|
||||
<TodoList class="mb-5" />
|
||||
<HealthStatus />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
@@ -288,7 +287,7 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "Home", inheritAttrs: false });
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useUserStore } from "@stores";
|
||||
import TenantAPI from "@/api/module_platform/tenant";
|
||||
@@ -305,6 +304,54 @@ const workspace = ref<WorkspaceData | null>(null);
|
||||
const dashboardStats = ref<DashboardStats | null>(null);
|
||||
const userStore = useUserStore();
|
||||
|
||||
const healthList = ref<{ icon: string; class: string; title: string; status: string; time: string }[]>([]);
|
||||
let healthEventSource: EventSource | null = null;
|
||||
|
||||
function connectHealth() {
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
|
||||
const es = new EventSource(`${baseURL}/common/health/stream`);
|
||||
|
||||
es.addEventListener("health", (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const deps = data.dependencies || {};
|
||||
const dbOk = deps.database?.status;
|
||||
const redisOk = deps.redis?.status;
|
||||
const diskOk = (data.disk_usage ?? 100) < 90;
|
||||
healthList.value = [
|
||||
{
|
||||
icon: "ri:database-2-line",
|
||||
class: dbOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
|
||||
title: "数据库",
|
||||
status: dbOk ? "正常" : "异常",
|
||||
time: dbOk ? `${deps.database.latency_ms || 0}ms` : "离线",
|
||||
},
|
||||
{
|
||||
icon: "ri:server-line",
|
||||
class: redisOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
|
||||
title: "Redis",
|
||||
status: redisOk ? "正常" : "异常",
|
||||
time: redisOk ? `${deps.redis.latency_ms || 0}ms` : "离线",
|
||||
},
|
||||
{
|
||||
icon: "ri:hard-drive-2-line",
|
||||
class: diskOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
|
||||
title: "磁盘",
|
||||
status: diskOk ? "正常" : "异常",
|
||||
time: `${data.disk_usage ?? "-"}%`,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
};
|
||||
healthEventSource = es;
|
||||
}
|
||||
|
||||
const isTenantMode = computed(() => userStore.workspaceMode === "tenant");
|
||||
|
||||
const tenantStatusStyle = computed(() => {
|
||||
@@ -390,6 +437,11 @@ onMounted(() => {
|
||||
loading.value = false;
|
||||
loadWorkspace();
|
||||
loadDashboardStats();
|
||||
connectHealth();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
healthEventSource?.close();
|
||||
});
|
||||
import FaCardBanner from "@/components/banners/fa-card-banner/index.vue";
|
||||
import FaImageCard from "@/components/cards/fa-image-card/index.vue";
|
||||
@@ -406,7 +458,6 @@ import TodoList from "./modules/todo-list.vue";
|
||||
import CardList from "./modules/card-list.vue";
|
||||
import AboutProject from "./modules/about-project.vue";
|
||||
import QuickLinks from "./modules/quick-links.vue";
|
||||
import HealthStatus from "./modules/health-status.vue";
|
||||
|
||||
function handleBannerDemoConfirm() {
|
||||
// TODO: 接入真实操作
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<ElCard shadow="hover" class="health-card">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<FaSvgIcon icon="ri:heart-pulse-line" class="text-base" />
|
||||
<span class="font-medium text-sm">系统健康</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">实时 · 30s</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="row in items" :key="row.label" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full"
|
||||
:class="row.status === 1 ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-sm">{{ row.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs" :class="row.status === 1 ? 'text-gray-400' : 'text-red-500'">{{
|
||||
row.value
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
defineOptions({ name: "HealthStatus" });
|
||||
|
||||
interface Item {
|
||||
label: string;
|
||||
value: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const items = ref<Item[]>([
|
||||
{ label: "数据库", value: "检查中…", status: 0 },
|
||||
{ label: "Redis", value: "检查中…", status: 0 },
|
||||
{ label: "磁盘", value: "检查中…", status: 0 },
|
||||
]);
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
function connect() {
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
|
||||
const es = new EventSource(`${baseURL}/common/health/stream`);
|
||||
|
||||
es.addEventListener("health", (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const deps = data.dependencies || {};
|
||||
items.value = [
|
||||
{
|
||||
label: "数据库",
|
||||
value: deps.database?.status ? `${deps.database.latency_ms || 0}ms` : "异常",
|
||||
status: deps.database?.status ? 1 : 0,
|
||||
},
|
||||
{
|
||||
label: "Redis",
|
||||
value: deps.redis?.status ? `${deps.redis.latency_ms || 0}ms` : "异常",
|
||||
status: deps.redis?.status ? 1 : 0,
|
||||
},
|
||||
{
|
||||
label: "磁盘",
|
||||
value: `${data.disk_usage ?? "-"}%`,
|
||||
status: (data.disk_usage ?? 100) < 90 ? 1 : 0,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
/* 静默忽略 */
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
};
|
||||
eventSource = es;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
eventSource?.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-card {
|
||||
--el-card-border-radius: calc(var(--custom-radius) + 2px);
|
||||
|
||||
border: 1px solid var(--fa-card-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<!-- API 令牌管理 CRUD -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
v-model="searchForm"
|
||||
:items="searchItems"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:default-expanded="false"
|
||||
@search="handleSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton v-hasPerm="['module_system:token:create']" type="primary" @click="handleAdd">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
新增 Token
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 新增/编辑/详情弹窗 -->
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="680px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
:show-footer="dialogVisible.type !== 'detail'"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<!-- 详情模式 -->
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions
|
||||
:column="2"
|
||||
:data="detailFormData"
|
||||
:items="detailItems"
|
||||
label-width="120px"
|
||||
max-height="70vh"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<ElTag :type="statusTagType(String((row as any)?.status ?? 0))" effect="plain">
|
||||
{{ statusLabel(String((row as any)?.status ?? 0)) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #scopes="{ row }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<ElTag v-for="s in (row as any)?.scopes || []" :key="s" size="small" type="info">
|
||||
{{ s }}
|
||||
</ElTag>
|
||||
<span v-if="!(row as any)?.scopes?.length" class="text-g-400">无限制</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #token_prefix="{ row }">
|
||||
<span class="font-mono text-sm">{{ (row as any)?.token_prefix || '—' }}</span>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
|
||||
<!-- 表单模式 -->
|
||||
<template v-else>
|
||||
<FaForm
|
||||
:key="formRenderKey"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="formItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
scrollbar
|
||||
max-height="70vh"
|
||||
>
|
||||
<template #status>
|
||||
<ElSelect v-model="formData.status" placeholder="请选择状态">
|
||||
<ElOption :value="0" label="启用" />
|
||||
<ElOption :value="1" label="停用" />
|
||||
</ElSelect>
|
||||
</template>
|
||||
</FaForm>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 查看明文弹窗(二次验证) -->
|
||||
<FaDialog
|
||||
v-model="revealDialog.visible"
|
||||
title="查看 Token 明文"
|
||||
width="520px"
|
||||
:form-mode="'detail'"
|
||||
:confirm-loading="revealLoading"
|
||||
@cancel="revealDialog.visible = false"
|
||||
@confirm="handleReveal"
|
||||
>
|
||||
<ElAlert type="warning" :closable="false" class="mb-4" show-icon>
|
||||
查看明文需要输入当前登录用户的密码以二次验证身份
|
||||
</ElAlert>
|
||||
<FaForm
|
||||
ref="revealFormRef"
|
||||
v-model="revealForm"
|
||||
:items="revealFormItems"
|
||||
:rules="revealRules"
|
||||
label-suffix=":"
|
||||
:label-width="80"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
>
|
||||
<template #password>
|
||||
<ElInput
|
||||
v-model="revealForm.password"
|
||||
type="password"
|
||||
placeholder="请输入当前用户密码"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
/>
|
||||
</template>
|
||||
</FaForm>
|
||||
<template v-if="revealResult" #extra>
|
||||
<ElDivider />
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-sm text-g-500 mb-1">Token 明文</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInput
|
||||
:model-value="revealResult.token_plain"
|
||||
readonly
|
||||
class="font-mono"
|
||||
/>
|
||||
<ElButton type="primary" @click="copyTokenPlain">复制</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="revealResult.expires_at" class="text-xs text-g-400">
|
||||
过期时间:{{ revealResult.expires_at }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 创建成功弹窗 -->
|
||||
<FaDialog
|
||||
v-model="createdDialog.visible"
|
||||
title="Token 创建成功"
|
||||
width="520px"
|
||||
:form-mode="'detail'"
|
||||
:show-footer="false"
|
||||
@cancel="createdDialog.visible = false"
|
||||
>
|
||||
<ElAlert type="success" :closable="false" class="mb-4" show-icon>
|
||||
Token 已创建成功,请立即复制保存。关闭后不再显示完整 Token。
|
||||
</ElAlert>
|
||||
<div>
|
||||
<div class="text-sm text-g-500 mb-1">Token</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInput
|
||||
:model-value="createdDialog.tokenPlain"
|
||||
readonly
|
||||
class="font-mono"
|
||||
/>
|
||||
<ElButton type="primary" @click="copyCreatedToken">复制</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { confirmDelete } from "@/hooks/core/useConfirm";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
|
||||
import ApiTokenAPI, {
|
||||
type ApiTokenTable,
|
||||
type ApiTokenCreateForm,
|
||||
type ApiTokenRevealSchema,
|
||||
} from "@/api/module_system/api-token";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
defineOptions({
|
||||
name: "ApiToken",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
// ─── 常量 ───
|
||||
const TOKEN_STATUS_OPTIONS = [
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
{ label: "已过期", value: 2 },
|
||||
] as const;
|
||||
|
||||
const TOKEN_STATUS_MAP: Record<string, string> = {
|
||||
"0": "启用",
|
||||
"1": "停用",
|
||||
"2": "已过期",
|
||||
};
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return TOKEN_STATUS_MAP[s] || s;
|
||||
}
|
||||
|
||||
function statusTagType(s: string): "success" | "info" | "danger" | undefined {
|
||||
return { "0": "success" as const, "1": "info" as const, "2": "danger" as const }[s];
|
||||
}
|
||||
|
||||
const createInitialFormData = (): ApiTokenCreateForm => ({
|
||||
id: undefined,
|
||||
name: "",
|
||||
scopes: [],
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
description: "",
|
||||
});
|
||||
|
||||
// ─── 搜索 ───
|
||||
type TokenSearchFormParams = { name?: string; status?: number } & AuditSearchFormParams;
|
||||
|
||||
const searchForm = ref<TokenSearchFormParams>({
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
|
||||
const searchItems = computed(() => [
|
||||
{
|
||||
label: "名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入名称", clearable: true },
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: TOKEN_STATUS_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
// ─── 表格 ───
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: ApiTokenAPI.listToken,
|
||||
apiParams: { page_no: 1, page_size: 10, name: undefined, status: undefined },
|
||||
columnsFactory: (): ColumnOption<ApiTokenTable>[] => [
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "名称", minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "token_prefix",
|
||||
label: "前缀",
|
||||
minWidth: 120,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ApiTokenTable) =>
|
||||
row.token_prefix ? (
|
||||
h("span", { class: "font-mono text-sm" }, row.token_prefix + "***")
|
||||
) : (
|
||||
h("span", { class: "text-g-400" }, "—")
|
||||
),
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 90,
|
||||
status: {
|
||||
0: { type: "success", text: "启用" },
|
||||
1: { type: "info", text: "停用" },
|
||||
2: { type: "danger", text: "已过期" },
|
||||
},
|
||||
},
|
||||
{ prop: "used_count", label: "已用次数", width: 100, align: "center" },
|
||||
{ prop: "expires_at", label: "过期时间", width: 170, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 320,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: ApiTokenTable) => renderTokenOperationCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
|
||||
function renderTokenOperationCell(row: ApiTokenTable) {
|
||||
return renderTableOperationCell(buildTokenRowActions(row), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
function buildTokenRowActions(row: ApiTokenTable): TableOperationAction[] {
|
||||
return [
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
run: () => void openDetailDialog(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
run: () => void openEditDialog(row),
|
||||
},
|
||||
{
|
||||
key: "reveal",
|
||||
label: "查看明文",
|
||||
artType: "more",
|
||||
run: () => void openRevealDialog(row),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "变更状态",
|
||||
artType: "more",
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
run: () => deleteTokenRow(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 主弹窗 ───
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
|
||||
const detailFormData = ref<ApiTokenTable>({});
|
||||
|
||||
const detailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
|
||||
{ label: "名称", prop: "name" },
|
||||
{ label: "前缀", prop: "token_prefix", slot: "token_prefix" },
|
||||
{ label: "状态", prop: "status", slot: "status" },
|
||||
{ label: "权限范围", prop: "scopes", slot: "scopes" },
|
||||
{ label: "过期时间", prop: "expires_at" },
|
||||
{ label: "每小时限额", prop: "rate_limit" },
|
||||
{ label: "已用次数", prop: "used_count" },
|
||||
{ label: "最近使用", prop: "last_used_at" },
|
||||
{ label: "描述", prop: "description", span: 2 },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
const formItems: FormItem[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "名称",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入 Token 名称", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
key: "expires_at",
|
||||
label: "过期时间",
|
||||
type: "date",
|
||||
span: 12,
|
||||
props: { placeholder: "留空=永不过期", valueFormat: "YYYY-MM-DD HH:mm:ss" },
|
||||
},
|
||||
{
|
||||
key: "rate_limit",
|
||||
label: "每小时限额",
|
||||
type: "number",
|
||||
span: 12,
|
||||
props: { placeholder: "留空=不限", controlsPosition: "right", min: 0, max: 100000 },
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: { placeholder: "请选择状态" },
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { type: "textarea", rows: 3, placeholder: "请输入描述" },
|
||||
},
|
||||
];
|
||||
|
||||
const formData = ref<ApiTokenCreateForm>(createInitialFormData());
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const dataFormRef = ref<{
|
||||
resetFields: () => void;
|
||||
clearValidate: () => void;
|
||||
validate: (cb: (valid: boolean) => void) => void;
|
||||
} | null>(null);
|
||||
|
||||
const submitLoading = ref(false);
|
||||
const formRenderKey = ref(0);
|
||||
|
||||
// ─── 查看明文弹窗 ───
|
||||
const revealDialog = reactive({ visible: false, row: null as ApiTokenTable | null });
|
||||
const revealLoading = ref(false);
|
||||
const revealForm = reactive({ password: "" });
|
||||
const revealResult = ref<ApiTokenRevealSchema | null>(null);
|
||||
const revealFormRef = ref<{
|
||||
resetFields: () => void;
|
||||
clearValidate: () => void;
|
||||
validate: (cb: (valid: boolean) => void) => void;
|
||||
} | null>(null);
|
||||
|
||||
const revealFormItems: FormItem[] = [
|
||||
{
|
||||
key: "password",
|
||||
label: "登录密码",
|
||||
type: "input",
|
||||
span: 24,
|
||||
},
|
||||
];
|
||||
|
||||
const revealRules = reactive({
|
||||
password: [{ required: true, message: "请输入当前用户密码", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// ─── 创建成功弹窗 ───
|
||||
const createdDialog = reactive({ visible: false, tokenPlain: "" });
|
||||
|
||||
// ─── 搜索事件 ───
|
||||
const handleSearch = async (params: TokenSearchFormParams) => {
|
||||
replaceSearchParams({
|
||||
name: params.name ?? undefined,
|
||||
status: params.status ?? undefined,
|
||||
} as Record<string, unknown>);
|
||||
getData();
|
||||
};
|
||||
|
||||
const onResetSearch = async () => {
|
||||
searchForm.value = { name: undefined, status: undefined };
|
||||
await resetSearchParams();
|
||||
};
|
||||
|
||||
// ─── 主弹窗事件 ───
|
||||
async function openDetailDialog(row: ApiTokenTable) {
|
||||
if (!row.id) return;
|
||||
const response = await ApiTokenAPI.detailToken(row.id);
|
||||
dialogVisible.type = "detail";
|
||||
dialogVisible.title = "Token 详情";
|
||||
detailFormData.value = response.data.data ?? { ...row };
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
await openEditDialog();
|
||||
}
|
||||
|
||||
async function openEditDialog(row?: ApiTokenTable) {
|
||||
dialogVisible.type = row ? "update" : "create";
|
||||
dialogVisible.title = row ? "编辑 Token" : "新增 Token";
|
||||
formRenderKey.value += 1;
|
||||
if (row) {
|
||||
const response = await ApiTokenAPI.detailToken(row.id!);
|
||||
const data = response.data.data ?? {};
|
||||
Object.assign(formData.value, data);
|
||||
} else {
|
||||
Object.assign(formData.value, createInitialFormData());
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData.value, createInitialFormData());
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return;
|
||||
const id = formData.value.id;
|
||||
try {
|
||||
if (id) {
|
||||
// 编辑模式:调用 reset API 更新基本信息
|
||||
const res = await ApiTokenAPI.resetToken(id, {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
});
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
await refreshUpdate();
|
||||
// 如果 reset 也返回了新 token,展示创建成功弹窗
|
||||
if (res.data.data?.token_plain) {
|
||||
createdDialog.tokenPlain = res.data.data.token_plain;
|
||||
createdDialog.visible = true;
|
||||
}
|
||||
} else {
|
||||
// 新增模式
|
||||
const res = await ApiTokenAPI.createToken(formData.value);
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
await refreshCreate();
|
||||
const created = res.data.data;
|
||||
if (created?.token_plain) {
|
||||
createdDialog.tokenPlain = created.token_plain;
|
||||
createdDialog.visible = true;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 删除事件 ───
|
||||
const deleteTokenRow = async (row: ApiTokenTable) => {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await confirmDelete(`确定删除 Token「${row.name ?? row.id}」吗?此操作不可恢复!`);
|
||||
await ApiTokenAPI.deleteToken(row.id!);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 查看明文事件 ───
|
||||
async function openRevealDialog(row: ApiTokenTable) {
|
||||
revealDialog.row = row;
|
||||
revealForm.password = "";
|
||||
revealResult.value = null;
|
||||
revealDialog.visible = true;
|
||||
}
|
||||
|
||||
async function handleReveal() {
|
||||
revealFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid || !revealDialog.row?.id) return;
|
||||
revealLoading.value = true;
|
||||
try {
|
||||
const res = await ApiTokenAPI.revealToken(revealDialog.row.id, {
|
||||
password: revealForm.password,
|
||||
});
|
||||
revealResult.value = res.data.data ?? null;
|
||||
} catch {
|
||||
// 错误由全局拦截处理
|
||||
} finally {
|
||||
revealLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { copy } = useClipboard();
|
||||
|
||||
function copyTokenPlain() {
|
||||
if (revealResult.value?.token_plain) {
|
||||
copy(revealResult.value.token_plain);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
}
|
||||
}
|
||||
|
||||
function copyCreatedToken() {
|
||||
if (createdDialog.tokenPlain) {
|
||||
copy(createdDialog.tokenPlain);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fa-full-height {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="login-page-root flex h-screen w-full flex-col overflow-hidden" :style="loginBgStyle">
|
||||
<FaLoginCenterBackdrop v-if="panelAlign === 'center'" viewport-fixed />
|
||||
<FaAuthTopBar v-model:panel-align="panelAlign" />
|
||||
<FaAuthTopBar v-model:panel-align="panelAlign" @tenant-change="handleTopBarTenantChange" />
|
||||
|
||||
<div
|
||||
class="login-auth-split relative z-1 flex min-h-0 flex-1 overflow-hidden"
|
||||
@@ -42,92 +42,6 @@
|
||||
<p class="sub-title">{{ panelSubTitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 租户品牌卡片(位置在 form-intro 上方) -->
|
||||
<div
|
||||
v-if="isTenantResolved"
|
||||
class="tenant-brand-card mb-5 p-4 rounded-lg border border-(--el-border-color-light) bg-(--el-fill-color-blank)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="tenantLogo"
|
||||
:src="tenantLogo"
|
||||
class="size-10 rounded-lg object-contain shrink-0"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-semibold text-(--el-text-color-primary) truncate"
|
||||
>
|
||||
{{ tenantDisplayName }}
|
||||
</div>
|
||||
<div class="text-xs text-(--el-text-color-secondary) mt-0.5">
|
||||
当前租户
|
||||
</div>
|
||||
</div>
|
||||
<ElButton text size="small" type="primary" @click="switchTenant">
|
||||
切换
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 切换面板 -->
|
||||
<div
|
||||
v-if="showTenantInput"
|
||||
class="mt-3 pt-3 border-t border-(--el-border-color-light)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInput
|
||||
v-model="tenantCode"
|
||||
size="default"
|
||||
placeholder="输入租户编码"
|
||||
clearable
|
||||
@keyup.enter="handleLookupTenant"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="tenantLookupLoading"
|
||||
@click="handleLookupTenant"
|
||||
>
|
||||
确定
|
||||
</ElButton>
|
||||
</div>
|
||||
<p v-if="tenantLookupError" class="mt-1 text-xs text-danger">
|
||||
{{ tenantLookupError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未识别租户时显示的小型选择器 -->
|
||||
<div v-else class="tenant-picker mb-4">
|
||||
<ElButton
|
||||
text
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="showTenantInput = !showTenantInput"
|
||||
>
|
||||
<template #icon><FaSvgIcon icon="ri:building-line" /></template>
|
||||
{{ showTenantInput ? "取消" : "选择租户" }}
|
||||
</ElButton>
|
||||
<div v-if="showTenantInput" class="flex items-center gap-2 mt-2">
|
||||
<ElInput
|
||||
v-model="tenantCode"
|
||||
size="default"
|
||||
placeholder="输入租户编码"
|
||||
clearable
|
||||
@keyup.enter="handleLookupTenant"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="tenantLookupLoading"
|
||||
@click="handleLookupTenant"
|
||||
>
|
||||
确定
|
||||
</ElButton>
|
||||
</div>
|
||||
<p v-if="tenantLookupError" class="mt-1 text-xs text-danger">
|
||||
{{ tenantLookupError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="authPanel === 'login'">
|
||||
<template v-if="loginFlowMode === 'account'">
|
||||
<FaLoginAccountForm
|
||||
@@ -350,7 +264,6 @@ function backToAccountLogin() {
|
||||
loginFlowMode.value = "account";
|
||||
nextTick(() => {
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
isPassing.value = false;
|
||||
isClickPass.value = false;
|
||||
@@ -416,7 +329,6 @@ watch(authPanel, (panel) => {
|
||||
if (panel !== "login") return;
|
||||
if (loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
isPassing.value = false;
|
||||
isClickPass.value = false;
|
||||
@@ -557,23 +469,14 @@ const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({
|
||||
const loginForm = reactive<LoginFormData>({
|
||||
username: "",
|
||||
password: "",
|
||||
captcha: "",
|
||||
captcha_key: "",
|
||||
remember: true,
|
||||
login_type: "PC端",
|
||||
});
|
||||
|
||||
// ── 租户品牌 ──
|
||||
const tenantCode = ref("");
|
||||
const showTenantInput = ref(false);
|
||||
const tenantLookupLoading = ref(false);
|
||||
const tenantLookupError = ref("");
|
||||
const tenantLogo = computed(() => configStore.configData?.tenant_logo?.config_value?.trim() || "");
|
||||
const tenantDisplayName = computed(
|
||||
() => configStore.configData?.tenant_name?.config_value?.trim() || "系统平台"
|
||||
);
|
||||
const loginBgStyle = computed(() => {
|
||||
const bg = configStore.configData?.tenant_login_bg?.config_value?.trim();
|
||||
const bg = configStore.configData?.login_bg?.config_value?.trim();
|
||||
return bg
|
||||
? { backgroundImage: `url(${bg})`, backgroundSize: "cover", backgroundPosition: "center" }
|
||||
: {};
|
||||
@@ -646,8 +549,6 @@ async function autoDetectTenant() {
|
||||
|
||||
/** 根据编码查询租户并加载配置 */
|
||||
async function loadTenantByCode(code: string, markResolved = true) {
|
||||
tenantLookupLoading.value = true;
|
||||
tenantLookupError.value = "";
|
||||
try {
|
||||
const { data: res } = await AuthAPI.lookupTenant(code);
|
||||
const info = res?.data as Record<string, any> | undefined;
|
||||
@@ -655,38 +556,14 @@ async function loadTenantByCode(code: string, markResolved = true) {
|
||||
currentTenantId.value = Number(info.id);
|
||||
await configStore.getConfig(true, currentTenantId.value);
|
||||
if (markResolved) isTenantResolved.value = true;
|
||||
showTenantInput.value = false;
|
||||
tenantCode.value = "";
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
tenantLookupLoading.value = false;
|
||||
}
|
||||
if (markResolved) isTenantResolved.value = false;
|
||||
}
|
||||
|
||||
/** 手动查询租户(点击确定按钮) */
|
||||
async function handleLookupTenant() {
|
||||
const code = tenantCode.value.trim();
|
||||
if (!code) {
|
||||
tenantLookupError.value = "请输入租户编码";
|
||||
return;
|
||||
}
|
||||
await loadTenantByCode(code);
|
||||
if (!isTenantResolved.value) {
|
||||
tenantLookupError.value = "未找到该租户";
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换租户 */
|
||||
function switchTenant() {
|
||||
showTenantInput.value = !showTenantInput.value;
|
||||
tenantCode.value = "";
|
||||
tenantLookupError.value = "";
|
||||
}
|
||||
|
||||
const captchaState = reactive<CaptchaInfo>({
|
||||
enable: false,
|
||||
key: "",
|
||||
@@ -715,15 +592,6 @@ const rules = computed<FormRules>(() => {
|
||||
},
|
||||
],
|
||||
};
|
||||
if (captchaState.enable) {
|
||||
base.captcha = [
|
||||
{
|
||||
required: true,
|
||||
trigger: "blur",
|
||||
message: t("login.message.captchaCode.required"),
|
||||
},
|
||||
];
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
@@ -742,15 +610,40 @@ async function getCaptcha() {
|
||||
loginForm.captcha_key = data.key;
|
||||
captchaState.img_base = data.img_base;
|
||||
captchaState.enable = data.enable;
|
||||
// 重置滑块状态
|
||||
isPassing.value = false;
|
||||
isClickPass.value = false;
|
||||
} catch {
|
||||
captchaState.enable = false;
|
||||
loginForm.captcha = "";
|
||||
loginForm.captcha_key = "";
|
||||
} finally {
|
||||
codeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 滑块验证完成后通知后端标记 */
|
||||
async function handleSliderPass(passed: boolean) {
|
||||
if (!passed || !loginForm.captcha_key) return;
|
||||
try {
|
||||
await AuthAPI.sliderComplete(loginForm.captcha_key);
|
||||
} catch {
|
||||
isPassing.value = false;
|
||||
await getCaptcha();
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听滑块通过状态 */
|
||||
watch(isPassing, (val) => {
|
||||
handleSliderPass(val);
|
||||
});
|
||||
|
||||
/** 顶部栏租户切换 */
|
||||
async function handleTopBarTenantChange(tenantId: number) {
|
||||
currentTenantId.value = tenantId;
|
||||
isTenantResolved.value = true;
|
||||
await configStore.getConfig(true, tenantId);
|
||||
}
|
||||
|
||||
function resolveRedirectTarget(query: LocationQuery): RouteLocationRaw {
|
||||
const defaultPath = "/";
|
||||
const rawRedirect = (query.redirect as string) || defaultPath;
|
||||
@@ -801,7 +694,6 @@ onMounted(async () => {
|
||||
onActivated(() => {
|
||||
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -815,7 +707,6 @@ watch(
|
||||
() => {
|
||||
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
}
|
||||
);
|
||||
|
||||
@@ -840,6 +731,8 @@ const handleSubmit = async () => {
|
||||
appStore.showGuide(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// 自增 formKey 强制重新挂载表单(滑块自动重置为初始状态)
|
||||
formKey.value++;
|
||||
await getCaptcha();
|
||||
if (!(error instanceof HttpError)) {
|
||||
console.error("[Login] Unexpected error:", error);
|
||||
@@ -851,7 +744,6 @@ const handleSubmit = async () => {
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user