refactor: 界面优化和使用sse替换websocket
This commit is contained in:
@@ -9,6 +9,3 @@ VITE_APP_BASE_API = '/dev-api'
|
||||
# API 服务器的 URL
|
||||
# VITE_APP_API_URL = http://localhost:8000
|
||||
VITE_APP_API_URL = https://api.youlai.tech
|
||||
|
||||
# WebSocket 服务器的 URL,不配置默认关闭 WebSocket
|
||||
#VITE_APP_WS_ENDPOINT= ws://localhost:4096/ws
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4020420240722002",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4020420240722002",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4020420240722002",
|
||||
"@stomp/stompjs": "^7.2.0",
|
||||
"@uni-helper/uni-use": "^0.19.15",
|
||||
"@vueuse/core": "9.13.0",
|
||||
"pinia": "^2.3.1",
|
||||
|
||||
@@ -160,38 +160,24 @@ const UserAPI = {
|
||||
};
|
||||
export default UserAPI;
|
||||
|
||||
interface BaseUser {
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
gender?: number;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
deptName?: string;
|
||||
}
|
||||
|
||||
/** 登录用户信息 */
|
||||
export interface UserInfo {
|
||||
export interface UserInfo extends BaseUser {
|
||||
/** 用户ID */
|
||||
userId?: number;
|
||||
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
|
||||
/** 头像URL */
|
||||
avatar?: string;
|
||||
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
|
||||
/** 性别 */
|
||||
gender?: number;
|
||||
|
||||
/** 部门名称 */
|
||||
deptName?: string;
|
||||
|
||||
/** 角色编码集合 */
|
||||
roles?: string[];
|
||||
|
||||
/** 权限标识集合 */
|
||||
perms?: string[];
|
||||
|
||||
/** 角色名称(前端计算字段,取 roles[0] 的中文映射) */
|
||||
roleName?: string;
|
||||
}
|
||||
@@ -246,60 +232,22 @@ export interface UserItem {
|
||||
}
|
||||
|
||||
/** 个人中心用户信息 */
|
||||
export interface UserProfile {
|
||||
export interface UserProfile extends BaseUser {
|
||||
/** 用户ID */
|
||||
id?: number;
|
||||
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
|
||||
/** 头像URL */
|
||||
avatar?: string;
|
||||
|
||||
/** 性别 */
|
||||
gender?: number;
|
||||
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
|
||||
/** 部门名称 */
|
||||
deptName?: string;
|
||||
|
||||
/** 角色名称,多个使用英文逗号(,)分割 */
|
||||
roleNames?: string;
|
||||
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
}
|
||||
|
||||
/** 个人中心用户信息表单 */
|
||||
export interface UserProfileForm {
|
||||
export interface UserProfileForm extends Pick<
|
||||
BaseUser,
|
||||
"username" | "nickname" | "avatar" | "gender" | "mobile" | "email"
|
||||
> {
|
||||
/** 用户ID */
|
||||
id?: number;
|
||||
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
|
||||
/** 头像URL */
|
||||
avatar?: string;
|
||||
|
||||
/** 性别 */
|
||||
gender?: number;
|
||||
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** 修改密码表单 */
|
||||
|
||||
39
src/composables/useCountdown.ts
Normal file
39
src/composables/useCountdown.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref, onUnmounted } from "vue";
|
||||
|
||||
export function useCountdown(duration = 60) {
|
||||
const countdown = ref(0);
|
||||
const isRunning = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (isRunning.value) return;
|
||||
|
||||
countdown.value = duration;
|
||||
isRunning.value = true;
|
||||
|
||||
timer = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) {
|
||||
stop();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
countdown.value = 0;
|
||||
isRunning.value = false;
|
||||
};
|
||||
|
||||
onUnmounted(() => stop());
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
38
src/composables/useNavigation.ts
Normal file
38
src/composables/useNavigation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { checkLogin } from "@/utils/auth";
|
||||
|
||||
interface NavItem {
|
||||
url?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function useNavigation() {
|
||||
const handleNavClick = (item: NavItem) => {
|
||||
if (!item.url) {
|
||||
uni.showToast({ title: "功能开发中", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkLogin()) return;
|
||||
|
||||
if (item.url.startsWith("http://") || item.url.startsWith("https://")) {
|
||||
// #ifdef H5
|
||||
window.open(item.url, "_blank");
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.navigateTo({
|
||||
url: `/pages/webview/index?url=${encodeURIComponent(item.url)}`,
|
||||
});
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: item.url,
|
||||
fail: () => {
|
||||
uni.showToast({ title: "页面不存在", icon: "none" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { handleNavClick };
|
||||
}
|
||||
214
src/composables/useSse.ts
Normal file
214
src/composables/useSse.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { getAccessToken } from "@/utils/auth";
|
||||
|
||||
export interface UseSseOptions {
|
||||
url?: string;
|
||||
debug?: boolean;
|
||||
connectionTimeout?: number;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
type EventHandler = (data: any) => void;
|
||||
|
||||
export enum SseConnectionState {
|
||||
DISCONNECTED = "DISCONNECTED",
|
||||
CONNECTING = "CONNECTING",
|
||||
CONNECTED = "CONNECTED",
|
||||
}
|
||||
|
||||
let globalInstance: ReturnType<typeof createSseConnection> | null = null;
|
||||
|
||||
function createSseConnection(options: UseSseOptions = {}) {
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||
const defaultUrl = `${baseUrl}/api/v1/sse/connect`;
|
||||
|
||||
const config = {
|
||||
url: options.url ?? defaultUrl,
|
||||
debug: options.debug ?? false,
|
||||
connectionTimeout: options.connectionTimeout ?? 10000,
|
||||
reconnectInterval: options.reconnectInterval ?? 5000,
|
||||
maxReconnectInterval: options.maxReconnectInterval ?? 120000,
|
||||
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
|
||||
};
|
||||
|
||||
const connectionState = ref<SseConnectionState>(SseConnectionState.DISCONNECTED);
|
||||
const isConnected = computed(() => connectionState.value === SseConnectionState.CONNECTED);
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isManualDisconnect = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let currentReconnectInterval = config.reconnectInterval;
|
||||
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
const log = (...args: any[]) => config.debug && console.log("[SSE]", ...args);
|
||||
const logError = (...args: any[]) => console.error("[SSE]", ...args);
|
||||
|
||||
const clearTimer = (timer: typeof connectionTimeoutTimer) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
return timer;
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (isManualDisconnect) return;
|
||||
if (config.maxReconnectAttempts > 0 && reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
log(`已达到最大重试次数 ${config.maxReconnectAttempts},停止重连`);
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttempts++;
|
||||
log(`将在 ${currentReconnectInterval}ms 后重试(${reconnectAttempts})`);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect();
|
||||
currentReconnectInterval = Math.min(
|
||||
currentReconnectInterval * 2,
|
||||
config.maxReconnectInterval
|
||||
);
|
||||
}, currentReconnectInterval);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
isManualDisconnect = false;
|
||||
|
||||
if (connectionState.value !== SseConnectionState.DISCONNECTED) {
|
||||
log("SSE 已连接或正在连接中,跳过重复连接");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
log("未检测到有效令牌,跳过 SSE 连接");
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState.value = SseConnectionState.CONNECTING;
|
||||
|
||||
connectionTimeoutTimer = setTimeout(() => {
|
||||
if (connectionState.value === SseConnectionState.CONNECTING) {
|
||||
log("SSE 连接超时");
|
||||
disconnect();
|
||||
}
|
||||
}, config.connectionTimeout);
|
||||
|
||||
log("正在建立 SSE 连接...");
|
||||
|
||||
// SSE 连接地址附带 token 参数(EventSource 不支持自定义 Header)
|
||||
const separator = config.url.includes("?") ? "&" : "?";
|
||||
const fullUrl = `${config.url}${separator}accessToken=${encodeURIComponent(token)}`;
|
||||
|
||||
eventSource = new EventSource(fullUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connectionTimeoutTimer = clearTimer(connectionTimeoutTimer);
|
||||
connectionState.value = SseConnectionState.CONNECTED;
|
||||
reconnectAttempts = 0;
|
||||
currentReconnectInterval = config.reconnectInterval;
|
||||
log("SSE 连接已建立");
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
logError("SSE 连接错误");
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
cleanupEventSource();
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
// 监听所有命名事件
|
||||
for (const [eventName] of eventHandlers) {
|
||||
bindEvent(eventName);
|
||||
}
|
||||
|
||||
// 默认监听 message 事件
|
||||
bindEvent("message");
|
||||
};
|
||||
|
||||
const bindEvent = (eventName: string) => {
|
||||
if (!eventSource) return;
|
||||
eventSource.addEventListener(eventName, (event: MessageEvent) => {
|
||||
const handlers = eventHandlers.get(eventName);
|
||||
if (handlers) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handlers.forEach((h) => h(data));
|
||||
} catch {
|
||||
handlers.forEach((h) => h(event.data));
|
||||
}
|
||||
}
|
||||
log(`收到事件[${eventName}]:`, event.data);
|
||||
});
|
||||
};
|
||||
|
||||
const on = (eventName: string, handler: EventHandler): (() => void) => {
|
||||
if (!eventHandlers.has(eventName)) {
|
||||
eventHandlers.set(eventName, new Set());
|
||||
// 如果已连接,动态绑定新事件
|
||||
if (eventSource) {
|
||||
bindEvent(eventName);
|
||||
}
|
||||
}
|
||||
eventHandlers.get(eventName)!.add(handler);
|
||||
log(`已订阅事件: ${eventName}`);
|
||||
|
||||
return () => {
|
||||
const handlers = eventHandlers.get(eventName);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
eventHandlers.delete(eventName);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupEventSource = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
isManualDisconnect = true;
|
||||
connectionTimeoutTimer = clearTimer(connectionTimeoutTimer);
|
||||
reconnectTimer = clearTimer(reconnectTimer);
|
||||
cleanupEventSource();
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
log("SSE 连接已断开");
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
disconnect();
|
||||
eventHandlers.clear();
|
||||
log("SSE 资源已清理");
|
||||
};
|
||||
|
||||
return {
|
||||
connectionState: readonly(connectionState),
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
cleanup,
|
||||
on,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSse(options: UseSseOptions = {}) {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createSseConnection(options);
|
||||
}
|
||||
return globalInstance;
|
||||
}
|
||||
|
||||
export function cleanupSse() {
|
||||
if (globalInstance) {
|
||||
globalInstance.cleanup();
|
||||
globalInstance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
import { Client, type IMessage, type StompSubscription } from "@stomp/stompjs";
|
||||
import { getAccessToken } from "@/utils/auth";
|
||||
|
||||
export interface UseStompOptions {
|
||||
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
||||
brokerURL?: string;
|
||||
/** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */
|
||||
token?: string;
|
||||
/** 重连延迟,单位毫秒,默认为 8000 */
|
||||
reconnectDelay?: number;
|
||||
/** 连接超时时间,单位毫秒,默认为 10000 */
|
||||
connectionTimeout?: number;
|
||||
/** 是否开启指数退避重连策略 */
|
||||
useExponentialBackoff?: boolean;
|
||||
/** 最大重连次数,默认为 5 */
|
||||
maxReconnectAttempts?: number;
|
||||
/** 最大重连延迟,单位毫秒,默认为 60000 */
|
||||
maxReconnectDelay?: number;
|
||||
/** 是否开启调试日志 */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP WebSocket连接组合式函数
|
||||
* 用于管理WebSocket连接的建立、断开、重连和消息订阅
|
||||
*/
|
||||
export function useStomp(options: UseStompOptions = {}) {
|
||||
// 默认值:brokerURL 从环境变量中获取,token 从 getAccessToken() 获取
|
||||
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
|
||||
|
||||
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
|
||||
// 默认配置参数
|
||||
const reconnectDelay = options.reconnectDelay ?? 15000; // 默认15秒重连间隔
|
||||
const connectionTimeout = options.connectionTimeout ?? 10000;
|
||||
const useExponentialBackoff = options.useExponentialBackoff ?? false;
|
||||
const maxReconnectAttempts = options.maxReconnectAttempts ?? 3; // 最多重连3次
|
||||
const maxReconnectDelay = options.maxReconnectDelay ?? 60000;
|
||||
|
||||
// 连接状态标记
|
||||
const isConnected = ref(false);
|
||||
// 重连尝试次数
|
||||
const reconnectCount = ref(0);
|
||||
// 重连计时器
|
||||
let reconnectTimer: any = null;
|
||||
// 连接超时计时器
|
||||
let connectionTimeoutTimer: any = null;
|
||||
// 存储所有订阅
|
||||
const subscriptions = new Map<string, StompSubscription>();
|
||||
|
||||
// 用于保存 STOMP 客户端的实例
|
||||
const client = ref<Client | null>(null);
|
||||
// 防止重复连接的标志
|
||||
let isConnecting = false;
|
||||
let isManualDisconnect = false;
|
||||
|
||||
/**
|
||||
* 初始化 STOMP 客户端
|
||||
*/
|
||||
const initializeClient = () => {
|
||||
// 如果客户端已存在且正在连接或已连接,直接返回
|
||||
if (client.value && (client.value.active || client.value.connected)) {
|
||||
console.log("STOMP客户端已存在且处于活动状态,跳过初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查WebSocket端点是否配置
|
||||
if (!brokerURL.value) {
|
||||
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次连接前重新获取最新令牌,不依赖之前的token值
|
||||
const currentToken = getAccessToken();
|
||||
|
||||
// 检查令牌是否为空,如果为空则不进行连接
|
||||
if (!currentToken) {
|
||||
console.error("WebSocket连接失败:授权令牌为空,请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有旧的客户端,先清理
|
||||
if (client.value) {
|
||||
try {
|
||||
client.value.deactivate();
|
||||
} catch (error) {
|
||||
console.warn("清理旧客户端时出错:", error);
|
||||
}
|
||||
client.value = null;
|
||||
}
|
||||
|
||||
// 创建 STOMP 客户端
|
||||
client.value = new Client({
|
||||
brokerURL: brokerURL.value,
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
debug: options.debug ? console.log : () => {},
|
||||
reconnectDelay: 0, // 禁用内置重连机制,使用自定义重连
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
});
|
||||
|
||||
// 设置连接监听器
|
||||
client.value.onConnect = () => {
|
||||
isConnected.value = true;
|
||||
isConnecting = false;
|
||||
reconnectCount.value = 0;
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
clearTimeout(reconnectTimer);
|
||||
console.log("WebSocket连接已建立");
|
||||
};
|
||||
|
||||
// 设置断开连接监听器
|
||||
client.value.onDisconnect = () => {
|
||||
isConnected.value = false;
|
||||
isConnecting = false;
|
||||
console.log("WebSocket连接已断开");
|
||||
|
||||
// 如果不是手动断开且未达到最大重连次数,则尝试重连
|
||||
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
|
||||
handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 Web Socket 关闭监听器
|
||||
client.value.onWebSocketClose = (event: CloseEvent) => {
|
||||
isConnected.value = false;
|
||||
isConnecting = false;
|
||||
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
|
||||
|
||||
// 如果是手动断开,不要重连
|
||||
if (isManualDisconnect) {
|
||||
console.log("手动断开连接,不进行重连");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是授权问题导致的关闭,尝试重连
|
||||
if (
|
||||
(event?.code === 1000 || event?.code === 1006 || event?.code === 1008) &&
|
||||
reconnectCount.value < maxReconnectAttempts
|
||||
) {
|
||||
console.log("检测到连接异常关闭,将尝试重连");
|
||||
|
||||
// 通过 handleReconnect 统一处理重连,避免重复计数
|
||||
handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// 设置错误监听器
|
||||
client.value.onStompError = (frame: any) => {
|
||||
console.error("STOMP错误:", frame.headers, frame.body);
|
||||
isConnecting = false;
|
||||
|
||||
// 检查是否是授权错误
|
||||
if (
|
||||
frame.headers?.message?.includes("Unauthorized") ||
|
||||
frame.body?.includes("Unauthorized") ||
|
||||
frame.body?.includes("Token")
|
||||
) {
|
||||
console.warn("WebSocket授权错误,请检查登录状态");
|
||||
// 授权错误不进行重连
|
||||
isManualDisconnect = true;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理重连逻辑
|
||||
*/
|
||||
const handleReconnect = () => {
|
||||
// 如果已经在连接中或手动断开,不重连
|
||||
if (isConnecting || isManualDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectCount.value >= maxReconnectAttempts) {
|
||||
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectCount.value++;
|
||||
console.log(`准备重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
|
||||
|
||||
// 使用指数退避策略增加重连间隔
|
||||
const delay = useExponentialBackoff
|
||||
? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay)
|
||||
: reconnectDelay;
|
||||
|
||||
// 清除之前的计时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
|
||||
// 设置重连计时器
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!isConnected.value && !isManualDisconnect && !isConnecting) {
|
||||
console.log(`开始重连...`);
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 监听 brokerURL 的变化,若地址改变则重新初始化
|
||||
watch(brokerURL, (newURL, oldURL) => {
|
||||
if (newURL !== oldURL) {
|
||||
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
|
||||
// 断开当前连接,重新激活客户端
|
||||
if (client.value && client.value.connected) {
|
||||
client.value.deactivate();
|
||||
}
|
||||
brokerURL.value = newURL;
|
||||
initializeClient(); // 重新初始化客户端
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化客户端
|
||||
initializeClient();
|
||||
|
||||
/**
|
||||
* 激活连接(如果已经连接或正在激活则直接返回)
|
||||
*/
|
||||
const connect = () => {
|
||||
// 重置手动断开标志
|
||||
isManualDisconnect = false;
|
||||
|
||||
// 检查是否有配置WebSocket端点
|
||||
if (!brokerURL.value) {
|
||||
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复连接
|
||||
if (isConnecting) {
|
||||
console.log("WebSocket正在连接中,跳过重复连接请求");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.value) {
|
||||
initializeClient();
|
||||
}
|
||||
|
||||
if (!client.value) {
|
||||
console.error("STOMP客户端初始化失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免重复连接:检查是否已连接
|
||||
if (client.value.connected) {
|
||||
console.log("WebSocket已经连接,跳过重复连接");
|
||||
isConnected.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置连接标志
|
||||
isConnecting = true;
|
||||
|
||||
// 设置连接超时
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
connectionTimeoutTimer = setTimeout(() => {
|
||||
if (!isConnected.value && isConnecting) {
|
||||
console.warn("WebSocket连接超时");
|
||||
isConnecting = false;
|
||||
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
|
||||
handleReconnect();
|
||||
}
|
||||
}
|
||||
}, connectionTimeout);
|
||||
|
||||
try {
|
||||
client.value.activate();
|
||||
console.log("正在建立WebSocket连接...");
|
||||
} catch (error) {
|
||||
console.error("激活WebSocket连接失败:", error);
|
||||
isConnecting = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅指定主题
|
||||
* @param destination 目标主题地址
|
||||
* @param callback 接收到消息时的回调函数
|
||||
* @returns 返回订阅 id,用于后续取消订阅
|
||||
*/
|
||||
const subscribe = (destination: string, callback: (_message: IMessage) => void): string => {
|
||||
if (!client.value || !client.value.connected) {
|
||||
console.warn(`尝试订阅 ${destination} 失败: 客户端未连接`);
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = client.value.subscribe(destination, callback);
|
||||
const subscriptionId = subscription.id;
|
||||
subscriptions.set(subscriptionId, subscription);
|
||||
console.log(`订阅成功: ${destination}, ID: ${subscriptionId}`);
|
||||
return subscriptionId;
|
||||
} catch (error) {
|
||||
console.error(`订阅 ${destination} 失败:`, error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param subscriptionId 订阅 id
|
||||
*/
|
||||
const unsubscribe = (subscriptionId: string) => {
|
||||
const subscription = subscriptions.get(subscriptionId);
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
subscriptions.delete(subscriptionId);
|
||||
console.log(`已取消订阅: ${subscriptionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
const disconnect = () => {
|
||||
// 设置手动断开标志
|
||||
isManualDisconnect = true;
|
||||
|
||||
// 清除所有计时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (connectionTimeoutTimer) {
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
connectionTimeoutTimer = null;
|
||||
}
|
||||
|
||||
// 清除所有订阅
|
||||
for (const [id, subscription] of subscriptions.entries()) {
|
||||
try {
|
||||
subscription.unsubscribe();
|
||||
} catch (error) {
|
||||
console.warn(`取消订阅 ${id} 时出错:`, error);
|
||||
}
|
||||
}
|
||||
subscriptions.clear();
|
||||
|
||||
// 断开连接
|
||||
if (client.value) {
|
||||
try {
|
||||
if (client.value.connected || client.value.active) {
|
||||
client.value.deactivate();
|
||||
console.log("WebSocket连接已主动断开");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("断开WebSocket连接时出错:", error);
|
||||
}
|
||||
client.value = null;
|
||||
}
|
||||
|
||||
isConnected.value = false;
|
||||
isConnecting = false;
|
||||
reconnectCount.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -2,14 +2,7 @@
|
||||
<view class="page page--tabbar">
|
||||
<!-- 轮播图 -->
|
||||
<view class="relative">
|
||||
<wd-swiper
|
||||
v-model:current="current"
|
||||
custom-class="swiper-box"
|
||||
:list="swiperList"
|
||||
autoplay
|
||||
@click="handleSwiperClick"
|
||||
@change="handleSwiperChange"
|
||||
/>
|
||||
<wd-swiper v-model:current="current" custom-class="swiper-box" :list="swiperList" autoplay />
|
||||
<view class="hero-fade"></view>
|
||||
</view>
|
||||
|
||||
@@ -20,7 +13,7 @@
|
||||
v-for="(item, index) in quickNavList"
|
||||
:key="index"
|
||||
use-slot
|
||||
@itemclick="handleNavClick(item)"
|
||||
@itemclick="handleNavClickWithGuard(item)"
|
||||
>
|
||||
<view class="nav-item">
|
||||
<image class="nav-item__icon" :src="item.icon" mode="aspectFit" />
|
||||
@@ -100,8 +93,9 @@ import { onReady, onShow } from "@dcloudio/uni-app";
|
||||
import { dayjs } from "wot-design-uni";
|
||||
import { useRouter } from "uni-mini-router";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useNavigation } from "@/composables/useNavigation";
|
||||
import { menuConfig } from "@/config/menu";
|
||||
import { checkLogin, isLoggedIn } from "@/utils/auth";
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { hasPermission } from "@/utils/permission";
|
||||
import LogAPI, { type VisitOverview as ApiVisitOverview, type VisitTrend } from "@/api/log";
|
||||
import NoticeAPI, { type NoticeItem } from "@/api/notice";
|
||||
@@ -117,6 +111,7 @@ interface NavItem {
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { handleNavClick } = useNavigation();
|
||||
// custom-navbar 组件内部已处理导航栏高度与胶囊避让
|
||||
|
||||
const current = ref(0);
|
||||
@@ -136,17 +131,12 @@ const visitOverviewData = ref<VisitOverviewVO>({
|
||||
totalPvCount: 0,
|
||||
});
|
||||
|
||||
const appVersion = ref<string>("");
|
||||
|
||||
const noticeList = ref<NoticeItem[]>([]);
|
||||
const noticeText = computed(() => {
|
||||
if (!noticeList.value.length) {
|
||||
return "暂无通知";
|
||||
}
|
||||
const titles = noticeList.value
|
||||
.map((n: NoticeItem) => n.title)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2) as string[];
|
||||
.slice(0, 2);
|
||||
return titles.length ? titles.join(" ") : "暂无通知";
|
||||
});
|
||||
|
||||
@@ -214,17 +204,6 @@ const chartOpts = ref({
|
||||
},
|
||||
});
|
||||
|
||||
function loadAppVersion() {
|
||||
try {
|
||||
const p: any = (globalThis as any).plus;
|
||||
if (p?.runtime?.version) {
|
||||
appVersion.value = `v${p.runtime.version}`;
|
||||
}
|
||||
} catch {
|
||||
appVersion.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNoticeData() {
|
||||
// 未登录时不调用通知接口
|
||||
if (!isLogged.value) {
|
||||
@@ -269,49 +248,24 @@ async function loadVisitTrendData() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavClick(item: NavItem) {
|
||||
// 未登录 / 无权限时:展示默认导航,但点击统一跳登录
|
||||
function handleNavClickWithGuard(item: NavItem) {
|
||||
if (!isLogged.value || !hasAnyPerm.value) {
|
||||
uni.navigateTo({ url: "/pages/login/index" });
|
||||
return;
|
||||
}
|
||||
// 已登录但访问受限时,仍做一次登录校验(防 token 过期)
|
||||
if (!checkLogin()) return;
|
||||
|
||||
// 外部链接处理
|
||||
if (item.url.startsWith("http://") || item.url.startsWith("https://")) {
|
||||
const isH5 = typeof window !== "undefined";
|
||||
if (isH5) {
|
||||
uni.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(item.url)}` });
|
||||
} else {
|
||||
try {
|
||||
const p = (globalThis as any).plus;
|
||||
p?.runtime?.openURL(item.url);
|
||||
} catch {
|
||||
uni.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(item.url)}` });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ path: item.url });
|
||||
handleNavClick(item);
|
||||
}
|
||||
|
||||
function handleNoticeClick() {
|
||||
router.push({ path: "/pages/work/notice/index" });
|
||||
}
|
||||
|
||||
function handleSwiperClick(_e: any) {}
|
||||
|
||||
function handleSwiperChange(_e: any) {}
|
||||
|
||||
function handleDataRangeChange({ value }: { value: number }) {
|
||||
recentDaysRange.value = value;
|
||||
loadVisitTrendData();
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
loadAppVersion();
|
||||
loadNoticeData();
|
||||
loadVisitOverviewData();
|
||||
loadVisitTrendData();
|
||||
@@ -436,7 +390,6 @@ onShow(() => {
|
||||
|
||||
&__header {
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -478,7 +431,6 @@ onShow(() => {
|
||||
|
||||
&__num {
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="login__form-item">
|
||||
<wd-button type="primary" block :loading="loading" @click="handleLogin">
|
||||
<wd-button type="primary" block :loading="isLoading" @click="handleLogin">
|
||||
登 录
|
||||
</wd-button>
|
||||
</view>
|
||||
@@ -228,7 +228,7 @@
|
||||
<text class="login__demo-hint-text">演示环境验证码:123456</text>
|
||||
</view>
|
||||
|
||||
<wd-button type="primary" block :loading="bindLoading" @click="handleBindMobile">
|
||||
<wd-button type="primary" block :loading="isBindLoading" @click="handleBindMobile">
|
||||
确认绑定
|
||||
</wd-button>
|
||||
</view>
|
||||
@@ -260,9 +260,10 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { useToast, useMessage } from "wot-design-uni";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { useCountdown } from "@/composables/useCountdown";
|
||||
import AuthAPI from "@/api/auth";
|
||||
|
||||
const toast = useToast();
|
||||
@@ -274,11 +275,10 @@ const statusBarHeight = ref(20);
|
||||
const navBarHeight = ref(44);
|
||||
|
||||
// 表单状态
|
||||
const loading = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isAgreePolicy = ref(false);
|
||||
const loginMode = ref<"PASSWORD" | "SMS" | "WECHAT">("PASSWORD");
|
||||
const smsCountdown = ref(0);
|
||||
const smsTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const { countdown: smsCountdown, start: startSmsCountdown } = useCountdown(60);
|
||||
|
||||
const formData = ref({
|
||||
username: "admin",
|
||||
@@ -290,15 +290,14 @@ const formData = ref({
|
||||
// 图形验证码
|
||||
const captchaId = ref("");
|
||||
const captchaBase64 = ref("");
|
||||
const captchaLoading = ref(false);
|
||||
const isCaptchaLoading = ref(false);
|
||||
|
||||
const redirect = ref("/pages/index/index");
|
||||
|
||||
// 绑定手机号
|
||||
const showBindMobilePopup = ref(false);
|
||||
const bindLoading = ref(false);
|
||||
const bindSmsCountdown = ref(0);
|
||||
const bindSmsTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const isBindLoading = ref(false);
|
||||
const { countdown: bindSmsCountdown, start: startBindSmsCountdown } = useCountdown(60);
|
||||
const wechatOpenid = ref("");
|
||||
|
||||
const bindMobileForm = ref({
|
||||
@@ -333,9 +332,9 @@ const canSubmit = computed(() => {
|
||||
|
||||
// 图形验证码
|
||||
const fetchCaptcha = async () => {
|
||||
if (captchaLoading.value) return;
|
||||
if (isCaptchaLoading.value) return;
|
||||
try {
|
||||
captchaLoading.value = true;
|
||||
isCaptchaLoading.value = true;
|
||||
captchaBase64.value = "";
|
||||
const res = await AuthAPI.getCaptcha();
|
||||
captchaId.value = res.captchaId;
|
||||
@@ -343,29 +342,10 @@ const fetchCaptcha = async () => {
|
||||
} catch {
|
||||
// 获取验证码失败由 API 层处理
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
isCaptchaLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 短信倒计时
|
||||
const startSmsCountdown = (
|
||||
countdown: Ref<number>,
|
||||
timer: Ref<ReturnType<typeof setInterval> | null>
|
||||
) => {
|
||||
countdown.value = 60;
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
timer.value = setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) {
|
||||
countdown.value = 0;
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 切换登录方式
|
||||
const toggleLoginMode = () => {
|
||||
if (loginMode.value === "PASSWORD") {
|
||||
@@ -412,8 +392,8 @@ async function doFormLogin() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (loginMode.value === "PASSWORD") {
|
||||
await userStore.login({
|
||||
@@ -435,7 +415,7 @@ async function doFormLogin() {
|
||||
toast.error(error?.message || "登录失败");
|
||||
if (loginMode.value === "PASSWORD") fetchCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +441,7 @@ const handleSendCode = async () => {
|
||||
try {
|
||||
await AuthAPI.sendSmsLoginCode(mobile);
|
||||
toast.success("验证码已发送");
|
||||
startSmsCountdown(smsCountdown, smsTimer);
|
||||
startSmsCountdown();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "发送失败");
|
||||
}
|
||||
@@ -486,7 +466,7 @@ async function doWechatPhoneLogin(phoneCode: string) {
|
||||
await handleWechatSilentLogin();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { code: loginCode } = await uni.login();
|
||||
await userStore.loginByWxMaPhone({ loginCode, phoneCode });
|
||||
@@ -497,12 +477,12 @@ async function doWechatPhoneLogin(phoneCode: string) {
|
||||
toast.info("正在尝试其他登录方式...");
|
||||
await handleWechatSilentLogin();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatSilentLogin = async () => {
|
||||
loading.value = true;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { code } = await uni.login();
|
||||
const result: any = await userStore.loginByWxMa(code);
|
||||
@@ -517,7 +497,7 @@ const handleWechatSilentLogin = async () => {
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "微信登录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,7 +512,7 @@ const handleSendBindCode = async () => {
|
||||
try {
|
||||
await AuthAPI.sendSmsLoginCode(mobile);
|
||||
toast.success("验证码已发送");
|
||||
startSmsCountdown(bindSmsCountdown, bindSmsTimer);
|
||||
startBindSmsCountdown();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "发送失败");
|
||||
}
|
||||
@@ -541,14 +521,10 @@ const handleSendBindCode = async () => {
|
||||
const resetBindForm = () => {
|
||||
bindMobileForm.value = { mobile: "", code: "" };
|
||||
bindSmsCountdown.value = 0;
|
||||
if (bindSmsTimer.value) {
|
||||
clearInterval(bindSmsTimer.value);
|
||||
bindSmsTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBindMobile = async () => {
|
||||
if (bindLoading.value) return;
|
||||
if (isBindLoading.value) return;
|
||||
const { mobile, code } = bindMobileForm.value;
|
||||
if (!isValidMobile(mobile)) {
|
||||
toast.error("请输入正确的手机号");
|
||||
@@ -558,7 +534,7 @@ const handleBindMobile = async () => {
|
||||
toast.error("请输入验证码");
|
||||
return;
|
||||
}
|
||||
bindLoading.value = true;
|
||||
isBindLoading.value = true;
|
||||
try {
|
||||
await userStore.bindMobileForWxMa({ openid: wechatOpenid.value, mobile, smsCode: code });
|
||||
await userStore.getInfo();
|
||||
@@ -569,7 +545,7 @@ const handleBindMobile = async () => {
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "绑定失败");
|
||||
} finally {
|
||||
bindLoading.value = false;
|
||||
isBindLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -606,11 +582,6 @@ onLoad((options: any) => {
|
||||
});
|
||||
|
||||
onShow(() => uni.setNavigationBarTitle({ title: "" }));
|
||||
|
||||
onUnload(() => {
|
||||
if (smsTimer.value) clearInterval(smsTimer.value);
|
||||
if (bindSmsTimer.value) clearInterval(bindSmsTimer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -744,10 +715,15 @@ onUnload(() => {
|
||||
.login__card {
|
||||
width: 100%;
|
||||
padding: 64rpx;
|
||||
background-color: var(--color-bg-alpha-95);
|
||||
backdrop-filter: blur(24px);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: 48rpx;
|
||||
box-shadow: 0 20rpx 50rpx -10rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
@supports (backdrop-filter: blur(24px)) or (-webkit-backdrop-filter: blur(24px)) {
|
||||
background-color: var(--color-bg-alpha-95);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片头部
|
||||
|
||||
@@ -161,8 +161,9 @@
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useToast, useMessage } from "wot-design-uni";
|
||||
import { useCountdown } from "@/composables/useCountdown";
|
||||
import UserAPI, {
|
||||
PasswordChangeForm,
|
||||
MobileBindingForm,
|
||||
@@ -219,11 +220,8 @@ const passwordChangeFormRef = ref();
|
||||
const mobileBindingFormRef = ref();
|
||||
const emailBindingFormRef = ref();
|
||||
|
||||
const mobileCountdown = ref(0);
|
||||
const mobileTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const emailCountdown = ref(0);
|
||||
const emailTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { countdown: mobileCountdown, start: startMobileCountdown } = useCountdown(60);
|
||||
const { countdown: emailCountdown, start: startEmailCountdown } = useCountdown(60);
|
||||
|
||||
const handleUnbindWechat = async () => {
|
||||
try {
|
||||
@@ -280,14 +278,7 @@ const handleSendVerificationCode = async (contactType: string) => {
|
||||
if (valid) {
|
||||
UserAPI.sendVerificationCode(mobileBindingForm.mobile!, "MOBILE").then(() => {
|
||||
uni.showToast({ title: "验证码已发送", icon: "none" });
|
||||
mobileCountdown.value = 60;
|
||||
mobileTimer.value = setInterval(() => {
|
||||
if (mobileCountdown.value > 0) {
|
||||
mobileCountdown.value -= 1;
|
||||
} else {
|
||||
clearInterval(mobileTimer.value!);
|
||||
}
|
||||
}, 1000);
|
||||
startMobileCountdown();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -296,14 +287,7 @@ const handleSendVerificationCode = async (contactType: string) => {
|
||||
if (valid) {
|
||||
UserAPI.sendVerificationCode(emailBindingForm.email!, "EMAIL").then(() => {
|
||||
uni.showToast({ title: "验证码已发送", icon: "none" });
|
||||
emailCountdown.value = 60;
|
||||
emailTimer.value = setInterval(() => {
|
||||
if (emailCountdown.value > 0) {
|
||||
emailCountdown.value -= 1;
|
||||
} else {
|
||||
clearInterval(emailTimer.value!);
|
||||
}
|
||||
}, 1000);
|
||||
startEmailCountdown();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -347,15 +331,4 @@ function handleSubmit() {
|
||||
onMounted(() => {
|
||||
loadUserProfile();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mobileTimer.value) {
|
||||
clearInterval(mobileTimer.value);
|
||||
mobileTimer.value = null;
|
||||
}
|
||||
if (emailTimer.value) {
|
||||
clearInterval(emailTimer.value as unknown as number);
|
||||
emailTimer.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
class="profile-card__avatar"
|
||||
:src="isLogin && userInfo?.avatar ? userInfo.avatar : defaultAvatar"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
/>
|
||||
<view v-if="isLogin" class="profile-card__online-dot" />
|
||||
<view v-if="genderIconName" class="profile-card__gender" :class="genderIconClass">
|
||||
@@ -57,13 +58,18 @@
|
||||
</view>
|
||||
|
||||
<view v-if="isLogin" class="profile-card__actions">
|
||||
<view class="profile-card__action-btn" @click.stop="openNotifications">
|
||||
<view
|
||||
class="profile-card__action-btn"
|
||||
aria-label="通知"
|
||||
@click.stop="openNotifications"
|
||||
>
|
||||
<wd-icon name="notification" size="16" color="var(--color-text-inverse)" />
|
||||
<view v-if="notificationCount > 0" class="profile-card__notify-badge">
|
||||
{{ notificationCount }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-card__action-btn" @click.stop="openThemeSettings">
|
||||
<view
|
||||
class="profile-card__action-btn"
|
||||
aria-label="主题设置"
|
||||
@click.stop="openThemeSettings"
|
||||
>
|
||||
<wd-icon name="setting1" size="16" color="var(--color-text-inverse)" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -183,8 +189,8 @@
|
||||
</view>
|
||||
|
||||
<wd-loading
|
||||
v-if="clearing"
|
||||
v-model="clearing"
|
||||
v-if="isClearing"
|
||||
v-model="isClearing"
|
||||
text="正在清理..."
|
||||
mask
|
||||
custom-class="loading-center"
|
||||
@@ -208,8 +214,8 @@ import { useToast, useMessage } from "wot-design-uni";
|
||||
import { useUserStore, useThemeStore } from "@/store";
|
||||
import { useRouter } from "uni-mini-router";
|
||||
import { useNavbar } from "@/composables/useNavbar";
|
||||
import type { UserInfo } from "@/api/user";
|
||||
import { getAccessToken } from "@/utils/auth";
|
||||
import { formatBytes } from "@/utils/format";
|
||||
|
||||
const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
@@ -220,7 +226,6 @@ const currentThemeColor = computed(() => themeStore.themeVars.colorTheme);
|
||||
const userInfo = computed(() => userStore.userInfo);
|
||||
const defaultAvatar = "/static/images/default-avatar.png";
|
||||
|
||||
const hasAccessToken = ref(!!getAccessToken());
|
||||
const isLogin = computed(() => !!getAccessToken());
|
||||
|
||||
const headerBackground = computed(() => {
|
||||
@@ -231,7 +236,6 @@ const headerBackground = computed(() => {
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const notificationCount = computed(() => 0);
|
||||
const appVersion = ref("1.0.0");
|
||||
const navbar = useNavbar({ hasTabbar: true });
|
||||
|
||||
@@ -265,12 +269,8 @@ const genderIconClass = computed(() => {
|
||||
return "";
|
||||
});
|
||||
|
||||
const syncAuthState = () => {
|
||||
hasAccessToken.value = !!getAccessToken();
|
||||
};
|
||||
|
||||
const fetchUserInfoIfNeeded = async () => {
|
||||
if (!hasAccessToken.value || hasUserProfile.value) return;
|
||||
if (!isLogin.value || hasUserProfile.value) return;
|
||||
try {
|
||||
await userStore.getInfo();
|
||||
} catch {
|
||||
@@ -285,7 +285,6 @@ const syncMiniProgramVersion = () => {
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
syncAuthState();
|
||||
await fetchUserInfoIfNeeded();
|
||||
await fetchCacheSize();
|
||||
syncMiniProgramVersion();
|
||||
@@ -347,19 +346,9 @@ const openAbout = () => {
|
||||
router.push({ path: "/pages/mine/about/index" });
|
||||
};
|
||||
|
||||
const clearing = ref(false);
|
||||
const isClearing = ref(false);
|
||||
const cacheSize = ref<string>("计算中...");
|
||||
|
||||
const formatBytes = (size: number) => {
|
||||
if (size < 1024) {
|
||||
return size + "B";
|
||||
} else if (size < 1024 * 1024) {
|
||||
return (size / 1024).toFixed(2) + "KB";
|
||||
} else {
|
||||
return (size / 1024 / 1024).toFixed(2) + "MB";
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCacheSize = async () => {
|
||||
try {
|
||||
// #ifdef MP-WEIXIN
|
||||
@@ -396,12 +385,12 @@ const handleClearCache = async () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (clearing.value) {
|
||||
if (isClearing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
clearing.value = true;
|
||||
isClearing.value = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await uni.clearStorage();
|
||||
await fetchCacheSize();
|
||||
@@ -415,7 +404,7 @@ const handleClearCache = async () => {
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
clearing.value = false;
|
||||
isClearing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -492,14 +481,16 @@ const openOfficialAccount = () => {
|
||||
position: relative;
|
||||
padding: 28rpx 28rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--color-glass) 0%, var(--color-glass-light) 100%);
|
||||
backdrop-filter: blur(20px);
|
||||
background: var(--color-bg-alpha-95);
|
||||
border: 1rpx solid var(--color-border-glass);
|
||||
border-radius: 28rpx;
|
||||
box-shadow:
|
||||
0 8rpx 32rpx rgba(0, 0, 0, 0.08),
|
||||
0 2rpx 8rpx rgba(0, 0, 0, 0.04),
|
||||
inset 0 1rpx 0 var(--color-glass);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px)) {
|
||||
background: linear-gradient(135deg, var(--color-glass) 0%, var(--color-glass-light) 100%);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card__header {
|
||||
@@ -520,7 +511,7 @@ const openOfficialAccount = () => {
|
||||
height: 120rpx;
|
||||
border: 3rpx solid var(--color-border-glass-strong);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profile-card__gender {
|
||||
@@ -531,7 +522,7 @@ const openOfficialAccount = () => {
|
||||
height: 36rpx;
|
||||
border: 2rpx solid var(--color-text-inverse);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profile-card__online-dot {
|
||||
@@ -543,7 +534,7 @@ const openOfficialAccount = () => {
|
||||
background: var(--color-success);
|
||||
border: 3rpx solid var(--color-border-glass-strong);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.12);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profile-card__actions {
|
||||
@@ -562,10 +553,15 @@ const openOfficialAccount = () => {
|
||||
justify-content: center;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
background: var(--color-glass-light);
|
||||
backdrop-filter: blur(10px);
|
||||
background: var(--color-bg-alpha-95);
|
||||
border: 1rpx solid var(--color-border-glass);
|
||||
border-radius: 999rpx;
|
||||
|
||||
@supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) {
|
||||
background: var(--color-glass-light);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card__notify-badge {
|
||||
@@ -670,7 +666,7 @@ const openOfficialAccount = () => {
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.06);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.community-card__bg {
|
||||
@@ -703,7 +699,6 @@ const openOfficialAccount = () => {
|
||||
|
||||
.community-card__logo {
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
box-sizing: border-box;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
@@ -711,19 +706,17 @@ const openOfficialAccount = () => {
|
||||
background: var(--color-bg-alpha-95);
|
||||
border: 1rpx solid var(--color-border-glass);
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.06);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.community-card__body {
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.community-card__arrow {
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -768,7 +761,7 @@ const openOfficialAccount = () => {
|
||||
padding: 28rpx;
|
||||
background: var(--color-bg);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.section-card + .section-card {
|
||||
@@ -790,11 +783,11 @@ const openOfficialAccount = () => {
|
||||
padding: 28rpx 24rpx;
|
||||
background: var(--color-bg);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.quick-card:active {
|
||||
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.06);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -884,7 +877,7 @@ const openOfficialAccount = () => {
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(15, 23, 42, 0.05);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.menu-list--flat {
|
||||
@@ -932,7 +925,8 @@ const openOfficialAccount = () => {
|
||||
}
|
||||
|
||||
.logout-section {
|
||||
padding: 36rpx 32rpx 0;
|
||||
display: flex;
|
||||
padding: 36rpx 28rpx 0;
|
||||
}
|
||||
|
||||
.profile-card__button {
|
||||
@@ -947,14 +941,15 @@ const openOfficialAccount = () => {
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
width: auto;
|
||||
height: 88rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-bg);
|
||||
border: 1rpx solid rgba(239, 68, 68, 0.18);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 12rpx 30rpx var(--color-danger-shadow);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.loading-center {
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:disabled="!canComplete || loading"
|
||||
:loading="loading"
|
||||
:disabled="!canComplete || isLoading"
|
||||
:loading="isLoading"
|
||||
@click="handleComplete"
|
||||
>
|
||||
完成
|
||||
@@ -145,7 +145,7 @@ const rules = {
|
||||
],
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const cropperVisible = ref(false);
|
||||
const originalImageSrc = ref("");
|
||||
const profileFormRef = ref();
|
||||
@@ -231,7 +231,7 @@ const handleComplete = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
isLoading.value = true;
|
||||
|
||||
await UserAPI.updateProfile({
|
||||
nickname: profileForm.nickname,
|
||||
@@ -252,7 +252,7 @@ const handleComplete = async () => {
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "完善信息失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -161,11 +161,6 @@ onLoad(() => {
|
||||
loadUserProfile();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 在onMounted中不再重复检查登录状态和加载用户信息
|
||||
// 如果需要检查登录状态和加载用户信息,请使用onLoad中的逻辑
|
||||
});
|
||||
|
||||
// 页面销毁前移除事件监听
|
||||
onBeforeUnmount(() => {
|
||||
// #ifdef H5
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- 使用wot-design-uni的Loading组件 -->
|
||||
<wd-loading
|
||||
v-if="clearing"
|
||||
v-model="clearing"
|
||||
v-if="isClearing"
|
||||
v-model="isClearing"
|
||||
text="正在清理..."
|
||||
mask
|
||||
custom-class="loading-center"
|
||||
@@ -69,7 +69,7 @@ const navigateToNetworkTest = () => {
|
||||
};
|
||||
|
||||
// 是否正在清理
|
||||
const clearing = ref(false);
|
||||
const isClearing = ref(false);
|
||||
// 缓存大小
|
||||
const cacheSize = ref<string>("计算中...");
|
||||
// 获取缓存大小
|
||||
@@ -121,12 +121,12 @@ const handleClearCache = async () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (clearing.value) {
|
||||
if (isClearing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
clearing.value = true;
|
||||
isClearing.value = true;
|
||||
// 模拟清理过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
// 清除缓存
|
||||
@@ -144,7 +144,7 @@ const handleClearCache = async () => {
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
clearing.value = false;
|
||||
isClearing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
v-if="currentThemeColor === color.primary"
|
||||
name="check"
|
||||
size="14"
|
||||
color="#fff"
|
||||
color="var(--color-text-inverse)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeConfigDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitConfigForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitConfigForm">
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -100,7 +102,7 @@ const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<ConfigPageQuery>({ pageNum: 1, pageSize: 10, keywords: "" });
|
||||
const total = ref(0);
|
||||
@@ -169,7 +171,7 @@ async function openConfigDialog(id?: number) {
|
||||
function submitConfigForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const action = formData.id ? ConfigAPI.update(formData.id, formData) : ConfigAPI.add(formData);
|
||||
action
|
||||
.then(() => {
|
||||
@@ -178,7 +180,7 @@ function submitConfigForm() {
|
||||
loadConfigList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeDeptDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitDeptForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitDeptForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -95,7 +95,7 @@ import CustomTree from "@/components/custom-tree/index.vue";
|
||||
const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<DeptQuery>({ keywords: "" });
|
||||
const deptList = ref<DeptItem[]>([]);
|
||||
@@ -301,7 +301,7 @@ function handleAddChild(dept: DeptItem) {
|
||||
function submitDeptForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const action = formData.id ? DeptAPI.update(formData.id, formData) : DeptAPI.add(formData);
|
||||
action
|
||||
.then(() => {
|
||||
@@ -310,7 +310,7 @@ function submitDeptForm() {
|
||||
loadDeptList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeDictDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitDictForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitDictForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -102,7 +102,7 @@ const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<DictTypePageQuery>({ pageNum: 1, pageSize: 10, keywords: "" });
|
||||
const total = ref(0);
|
||||
@@ -169,7 +169,7 @@ async function openDictDialog(id?: string) {
|
||||
function submitDictForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const id = formData.id;
|
||||
const action = id ? DictAPI.update(id, formData) : DictAPI.create(formData);
|
||||
action
|
||||
@@ -179,7 +179,7 @@ function submitDictForm() {
|
||||
loadDictTypeList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeItemDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitItemForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitItemForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -112,7 +112,7 @@ const pageTitle = ref<string>("字典数据");
|
||||
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<DictItemPageQuery>({ pageNum: 1, pageSize: 10, keywords: "" });
|
||||
const total = ref(0);
|
||||
@@ -187,7 +187,7 @@ function submitItemForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const id = formData.id;
|
||||
const action = id
|
||||
? DictAPI.updateItem(dictCode.value, id, formData)
|
||||
@@ -200,7 +200,7 @@ function submitItemForm() {
|
||||
loadItemList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,18 +23,13 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "uni-mini-router";
|
||||
import { useNavbar } from "@/composables/useNavbar";
|
||||
import { useNavigation } from "@/composables/useNavigation";
|
||||
import { menuConfig } from "@/config/menu";
|
||||
import { checkLogin, isLoggedIn } from "@/utils/auth";
|
||||
import { hasPermission as checkPermission } from "@/utils/permission";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const navbar = useNavbar({ hasTabbar: true });
|
||||
|
||||
// 是否已登录
|
||||
const isLogged = computed(() => isLoggedIn());
|
||||
const navbar = useNavbar();
|
||||
const { handleNavClick } = useNavigation();
|
||||
|
||||
// 检查是否有权限
|
||||
const hasPermission = (perm: string) => {
|
||||
@@ -51,31 +46,6 @@ const visibleGridList = computed(() => {
|
||||
}))
|
||||
.filter((group) => group.children.length > 0);
|
||||
});
|
||||
|
||||
// 处理导航点击
|
||||
function handleNavClick(item: any) {
|
||||
// 未登录时跳转登录页
|
||||
if (!isLogged.value) {
|
||||
uni.navigateTo({ url: "/pages/login/index" });
|
||||
return;
|
||||
}
|
||||
// 已登录但访问受限时,仍做一次登录校验(防 token 过期)
|
||||
if (!checkLogin()) return;
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof item?.url === "string" &&
|
||||
(item.url.startsWith("http://") || item.url.startsWith("https://"))
|
||||
) {
|
||||
uni.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(item.url)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ path: item.url });
|
||||
} catch {
|
||||
// 路由跳转失败已由拦截器处理
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<route lang="json">
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</scroll-view>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeMenuDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitMenuForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitMenuForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -130,7 +130,7 @@ import CustomTree from "@/components/custom-tree/index.vue";
|
||||
const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<MenuQuery>({ keywords: "" });
|
||||
const menuList = ref<MenuItem[]>([]);
|
||||
@@ -364,7 +364,7 @@ function handleAddChild(menu: MenuItem) {
|
||||
function submitMenuForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const action = formData.id ? MenuAPI.update(formData.id, formData) : MenuAPI.add(formData);
|
||||
action
|
||||
.then(() => {
|
||||
@@ -373,7 +373,7 @@ function submitMenuForm() {
|
||||
loadMenuList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,7 +133,9 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeNoticeForm">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitNoticeForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitNoticeForm">
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -169,7 +171,7 @@ const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<NoticePageQuery>({ pageNum: 1, pageSize: 10 });
|
||||
const total = ref(0);
|
||||
@@ -290,7 +292,7 @@ function closeNoticeForm() {
|
||||
function submitNoticeForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const id = formData.id;
|
||||
const action = id ? NoticeAPI.update(id, formData) : NoticeAPI.add(formData);
|
||||
action
|
||||
@@ -300,7 +302,7 @@ function submitNoticeForm() {
|
||||
loadNoticeList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<!-- 固定底部操作栏 -->
|
||||
<view class="bottom-bar">
|
||||
<wd-button type="info" plain @click="handleCancel">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="handleSubmit">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="handleSubmit">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -84,7 +84,7 @@ import CustomTree from "@/components/custom-tree/index.vue";
|
||||
import type { TreeOption } from "@/components/custom-tree/index.vue";
|
||||
|
||||
const toast = useToast();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const roleId = ref<number>(0);
|
||||
const roleName = ref("");
|
||||
const menuList = ref<MenuItem[]>([]);
|
||||
@@ -148,13 +148,13 @@ function handleCancel() {
|
||||
|
||||
// 提交
|
||||
async function handleSubmit() {
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await RoleAPI.updateRoleMenus(roleId.value, checkedKeys.value.map(Number));
|
||||
toast.success("保存成功");
|
||||
uni.navigateBack();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeRoleDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitRoleForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitRoleForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -120,7 +120,7 @@ const toast = useToast();
|
||||
const { messageBox } = useMessage();
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const queryParams = reactive<RolePageQuery>({ pageNum: 1, pageSize: 10, keywords: "" });
|
||||
const total = ref(0);
|
||||
@@ -198,7 +198,7 @@ async function openRoleDialog(id?: number) {
|
||||
function submitRoleForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const action = formData.id ? RoleAPI.update(formData.id, formData) : RoleAPI.add(formData);
|
||||
action
|
||||
.then(() => {
|
||||
@@ -207,7 +207,7 @@ function submitRoleForm() {
|
||||
loadRoleList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<!-- 主信息行 -->
|
||||
<view class="flex-start">
|
||||
<image class="user-card__avatar" :src="item.avatar" mode="aspectFill" />
|
||||
<image class="user-card__avatar" :src="item.avatar" mode="aspectFill" lazy-load />
|
||||
<view class="user-card__main">
|
||||
<view class="flex-start mt-12rpx">
|
||||
<text class="user-card__name">{{ item.nickname }}</text>
|
||||
@@ -149,7 +149,7 @@
|
||||
</wd-form>
|
||||
<view class="popup-actions">
|
||||
<wd-button type="info" plain @click="closeUserDialog">取消</wd-button>
|
||||
<wd-button type="primary" :loading="submitting" @click="submitUserForm">保存</wd-button>
|
||||
<wd-button type="primary" :loading="isSubmitting" @click="submitUserForm">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -187,7 +187,7 @@
|
||||
<wd-button type="info" plain @click="resetPwdDialog.visible = false">取消</wd-button>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="resetPwdDialog.submitting"
|
||||
:loading="resetPwdDialog.isSubmitting"
|
||||
@click="handleResetPassword"
|
||||
>
|
||||
确认
|
||||
@@ -213,7 +213,7 @@ const { messageBox } = useMessage();
|
||||
const { closeOutside } = useQueue();
|
||||
const loadMoreState = ref<LoadMoreState>("loading");
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const sortValue = ref(0);
|
||||
const sortOptions = ref([
|
||||
@@ -394,7 +394,7 @@ async function openUserDialog(id?: number) {
|
||||
function submitUserForm() {
|
||||
formRef.value.validate().then(({ valid }: { valid: boolean }) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
isSubmitting.value = true;
|
||||
const action = formData.id ? UserAPI.update(formData.id, formData) : UserAPI.add(formData);
|
||||
action
|
||||
.then(() => {
|
||||
@@ -403,7 +403,7 @@ function submitUserForm() {
|
||||
loadUserList();
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
isSubmitting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -421,7 +421,7 @@ const pendingAction = ref<Record<string, () => void>>({});
|
||||
|
||||
const resetPwdDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
isSubmitting: false,
|
||||
userId: undefined as number | undefined,
|
||||
});
|
||||
const resetPwdForm = reactive({ password: "" });
|
||||
@@ -493,7 +493,7 @@ async function handleResetPassword() {
|
||||
if (!valid || valid.valid === false) return;
|
||||
if (!resetPwdDialog.userId) return;
|
||||
|
||||
resetPwdDialog.submitting = true;
|
||||
resetPwdDialog.isSubmitting = true;
|
||||
try {
|
||||
await UserAPI.resetPassword(resetPwdDialog.userId, resetPwdForm.password);
|
||||
toast.success("密码重置成功");
|
||||
@@ -501,7 +501,7 @@ async function handleResetPassword() {
|
||||
} catch (error) {
|
||||
// API 已处理错误提示
|
||||
} finally {
|
||||
resetPwdDialog.submitting = false;
|
||||
resetPwdDialog.isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
src/types/auto-imports.d.ts
vendored
19
src/types/auto-imports.d.ts
vendored
@@ -11,12 +11,14 @@ declare global {
|
||||
const PERM_ALL: typeof import('../utils/permission')['PERM_ALL']
|
||||
const ROLE_ROOT: typeof import('../utils/permission')['ROLE_ROOT']
|
||||
const RequestError: typeof import('../utils/request')['RequestError']
|
||||
const SseConnectionState: typeof import('../composables/useSse')['SseConnectionState']
|
||||
const Storage: typeof import('../utils/storage')['Storage']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const applyThemeOnPageShow: typeof import('../utils/theme')['applyThemeOnPageShow']
|
||||
const applyThemeToMiniProgram: typeof import('../utils/theme')['applyThemeToMiniProgram']
|
||||
const auth: typeof import('../api/auth')['default']
|
||||
const checkLogin: typeof import('../utils/auth')['checkLogin']
|
||||
const cleanupSse: typeof import('../composables/useSse')['cleanupSse']
|
||||
const clearAll: typeof import('../utils/storage')['clearAll']
|
||||
const clearTokens: typeof import('../utils/auth')['clearTokens']
|
||||
const colorColumns: typeof import('../composables/types/theme')['colorColumns']
|
||||
@@ -35,6 +37,8 @@ declare global {
|
||||
const dict: typeof import('../api/dict')['default']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const file: typeof import('../api/file')['default']
|
||||
const formatBytes: typeof import('../utils/format')['formatBytes']
|
||||
const formatNumber: typeof import('../utils/format')['formatNumber']
|
||||
const getAccessToken: typeof import('../utils/auth')['getAccessToken']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
@@ -150,6 +154,7 @@ declare global {
|
||||
const useActionSheet: typeof import('@uni-helper/uni-use')['useActionSheet']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useClipboardData: typeof import('@uni-helper/uni-use')['useClipboardData']
|
||||
const useCountdown: typeof import('../composables/useCountdown')['useCountdown']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useDownloadFile: typeof import('@uni-helper/uni-use')['useDownloadFile']
|
||||
@@ -163,6 +168,7 @@ declare global {
|
||||
const useModal: typeof import('@uni-helper/uni-use')['useModal']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useNavbar: typeof import('../composables/useNavbar')['useNavbar']
|
||||
const useNavigation: typeof import('../composables/useNavigation')['useNavigation']
|
||||
const useNetwork: typeof import('@uni-helper/uni-use')['useNetwork']
|
||||
const useNotify: typeof import('wot-design-uni')['useNotify']
|
||||
const useOnline: typeof import('@uni-helper/uni-use')['useOnline']
|
||||
@@ -184,7 +190,7 @@ declare global {
|
||||
const useSelectorQuery: typeof import('@uni-helper/uni-use')['useSelectorQuery']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSocket: typeof import('@uni-helper/uni-use')['useSocket']
|
||||
const useStomp: typeof import('../composables/useStomp')['useStomp']
|
||||
const useSse: typeof import('../composables/useSse')['useSse']
|
||||
const useStorage: typeof import('@uni-helper/uni-use')['useStorage']
|
||||
const useStorageAsync: typeof import('@uni-helper/uni-use')['useStorageAsync']
|
||||
const useStorageSync: typeof import('@uni-helper/uni-use')['useStorageSync']
|
||||
@@ -208,6 +214,9 @@ declare global {
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { SseConnectionState } from '../composables/useSse'
|
||||
import('../composables/useSse')
|
||||
// @ts-ignore
|
||||
export type { RequestError } from '../utils/request'
|
||||
import('../utils/request')
|
||||
}
|
||||
@@ -220,10 +229,12 @@ declare module 'vue' {
|
||||
readonly CommonUtil: UnwrapRef<typeof import('wot-design-uni')['CommonUtil']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly RequestError: UnwrapRef<typeof import('../utils/request')['RequestError']>
|
||||
readonly SseConnectionState: UnwrapRef<typeof import('../composables/useSse')['SseConnectionState']>
|
||||
readonly Storage: UnwrapRef<typeof import('../utils/storage')['Storage']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly auth: UnwrapRef<typeof import('../api/auth')['default']>
|
||||
readonly checkLogin: UnwrapRef<typeof import('../utils/auth')['checkLogin']>
|
||||
readonly cleanupSse: UnwrapRef<typeof import('../composables/useSse')['cleanupSse']>
|
||||
readonly clearAll: UnwrapRef<typeof import('../utils/storage')['clearAll']>
|
||||
readonly clearTokens: UnwrapRef<typeof import('../utils/auth')['clearTokens']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
@@ -240,6 +251,8 @@ declare module 'vue' {
|
||||
readonly dict: UnwrapRef<typeof import('../api/dict')['default']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly file: UnwrapRef<typeof import('../api/file')['default']>
|
||||
readonly formatBytes: UnwrapRef<typeof import('../utils/format')['formatBytes']>
|
||||
readonly formatNumber: UnwrapRef<typeof import('../utils/format')['formatNumber']>
|
||||
readonly getAccessToken: UnwrapRef<typeof import('../utils/auth')['getAccessToken']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
@@ -330,6 +343,7 @@ declare module 'vue' {
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useCountdown: UnwrapRef<typeof import('../composables/useCountdown')['useCountdown']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
@@ -337,13 +351,14 @@ declare module 'vue' {
|
||||
readonly useMessage: UnwrapRef<typeof import('wot-design-uni')['useMessage']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useNavbar: UnwrapRef<typeof import('../composables/useNavbar')['useNavbar']>
|
||||
readonly useNavigation: UnwrapRef<typeof import('../composables/useNavigation')['useNavigation']>
|
||||
readonly useNotify: UnwrapRef<typeof import('wot-design-uni')['useNotify']>
|
||||
readonly usePagination: UnwrapRef<typeof import('../composables/useRequest')['usePagination']>
|
||||
readonly useRequest: UnwrapRef<typeof import('../composables/useRequest')['useRequest']>
|
||||
readonly useRoute: UnwrapRef<typeof import('uni-mini-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('uni-mini-router')['useRouter']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useStomp: UnwrapRef<typeof import('../composables/useStomp')['useStomp']>
|
||||
readonly useSse: UnwrapRef<typeof import('../composables/useSse')['useSse']>
|
||||
readonly useTabbar: UnwrapRef<typeof import('../composables/useTabbar')['useTabbar']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
readonly useTheme: UnwrapRef<typeof import('../composables/useTheme')['useTheme']>
|
||||
|
||||
5
src/types/env.d.ts
vendored
5
src/types/env.d.ts
vendored
@@ -14,11 +14,6 @@ interface ImportMetaEnv {
|
||||
|
||||
/** 应用版本号 */
|
||||
readonly VITE_APP_VERSION: string;
|
||||
|
||||
/**
|
||||
* WebSocket 端点
|
||||
*/
|
||||
readonly VITE_APP_WS_ENDPOINT?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -59,58 +59,40 @@ export function clearTokens(): void {
|
||||
Storage.remove(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function getCurrentPagePath(): string {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length === 0) return "/pages/index/index";
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const route = currentPage.route || "";
|
||||
const options = (currentPage as any).options || {};
|
||||
|
||||
const query = Object.entries(options)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&");
|
||||
|
||||
return query ? `/${route}?${query}` : `/${route}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户登录状态,未登录则跳转到登录页面
|
||||
* @param silent 是否静默检查,不跳转登录页面
|
||||
* @returns 返回用户是否已登录
|
||||
*/
|
||||
export function checkLogin(silent: boolean = false): boolean {
|
||||
const accessToken = getAccessToken();
|
||||
if (getAccessToken()) return true;
|
||||
|
||||
// 检查 token 是否存在
|
||||
const isLoggedIn = !!accessToken;
|
||||
|
||||
if (!isLoggedIn && !silent) {
|
||||
try {
|
||||
// 获取当前页面路径
|
||||
let currentPagePath = "/pages/index/index"; // 默认路径
|
||||
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
if (currentPage && currentPage.route) {
|
||||
currentPagePath = `/${currentPage.route}`;
|
||||
|
||||
// 处理页面参数 - 使用类型断言
|
||||
const pageOptions = (currentPage as any).options;
|
||||
if (pageOptions && Object.keys(pageOptions).length > 0) {
|
||||
const params = new URLSearchParams(pageOptions as Record<string, string>);
|
||||
currentPagePath += `?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到登录页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/login/index?redirect=${encodeURIComponent(currentPagePath)}`,
|
||||
fail: (error) => {
|
||||
console.error("跳转登录页面失败:", error);
|
||||
// 如果 navigateTo 失败,尝试使用 reLaunch
|
||||
uni.reLaunch({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("检查登录状态时发生错误:", error);
|
||||
// 发生错误时,尝试直接跳转到登录页
|
||||
uni.reLaunch({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
}
|
||||
if (!silent) {
|
||||
const redirect = encodeURIComponent(getCurrentPagePath());
|
||||
uni.navigateTo({
|
||||
url: `/pages/login/index?redirect=${redirect}`,
|
||||
fail: () => {
|
||||
uni.reLaunch({ url: "/pages/login/index" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return isLoggedIn;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
18
src/utils/format.ts
Normal file
18
src/utils/format.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function formatBytes(size: number): string {
|
||||
if (size < 1024) {
|
||||
return size + "B";
|
||||
} else if (size < 1024 * 1024) {
|
||||
return (size / 1024).toFixed(2) + "KB";
|
||||
} else {
|
||||
return (size / 1024 / 1024).toFixed(2) + "MB";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + "w";
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "k";
|
||||
}
|
||||
return String(num);
|
||||
}
|
||||
@@ -1,155 +1,42 @@
|
||||
import { useUserStore } from "@/store";
|
||||
import { ROLE_ROOT, PERM_ALL } from "@/constants";
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
*
|
||||
* @param permission 权限标识,支持字符串或字符串数组(满足其一即可)
|
||||
* @returns 是否拥有权限
|
||||
*
|
||||
* @example
|
||||
* hasPermission('sys:user:create') // 检查单个权限
|
||||
* hasPermission(['sys:user:create', 'sys:user:update']) // 满足其一即可
|
||||
*/
|
||||
export function hasPermission(permission: string | string[]): boolean {
|
||||
// 参数校验
|
||||
if (!permission) {
|
||||
console.warn("[Permission] 需要提供权限标识");
|
||||
return false;
|
||||
}
|
||||
type AccessType = "perm" | "role";
|
||||
type CheckMode = "some" | "every";
|
||||
|
||||
// 标准化为数组
|
||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||
function checkAccess(type: AccessType, keys: string | string[], mode: CheckMode = "some"): boolean {
|
||||
if (!keys) return false;
|
||||
|
||||
// 空数组校验
|
||||
if (permissions.length === 0) {
|
||||
console.warn("[Permission] 权限标识数组不能为空");
|
||||
return false;
|
||||
}
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
if (keyArray.length === 0) return false;
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userInfo = userStore.userInfo;
|
||||
|
||||
// 未登录或无用户信息
|
||||
if (!userInfo) {
|
||||
return false;
|
||||
}
|
||||
const userInfo = useUserStore().userInfo;
|
||||
if (!userInfo) return false;
|
||||
|
||||
const { roles = [], perms = [] } = userInfo;
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (roles.includes(ROLE_ROOT)) {
|
||||
return true;
|
||||
}
|
||||
if (roles.includes(ROLE_ROOT)) return true;
|
||||
|
||||
// 检查是否包含全部权限标识
|
||||
if (permissions.includes(PERM_ALL)) {
|
||||
return true;
|
||||
}
|
||||
if (type === "perm" && keyArray.includes(PERM_ALL)) return true;
|
||||
|
||||
// 检查权限:满足其一即可
|
||||
return permissions.some((perm) => perms.includes(perm));
|
||||
const userKeys = type === "perm" ? perms : roles;
|
||||
if (!userKeys || userKeys.length === 0) return false;
|
||||
|
||||
return keyArray[mode]((key) => userKeys.includes(key));
|
||||
}
|
||||
|
||||
export function hasPermission(perm: string | string[]): boolean {
|
||||
return checkAccess("perm", perm, "some");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定角色
|
||||
*
|
||||
* @param role 角色标识,支持字符串或字符串数组(满足其一即可)
|
||||
* @returns 是否拥有角色
|
||||
*
|
||||
* @example
|
||||
* hasRole('ADMIN') // 检查单个角色
|
||||
* hasRole(['ADMIN', 'TEST']) // 满足其一即可
|
||||
*/
|
||||
export function hasRole(role: string | string[]): boolean {
|
||||
// 参数校验
|
||||
if (!role) {
|
||||
console.warn("[Permission] 需要提供角色标识");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标准化为数组
|
||||
const roles = Array.isArray(role) ? role : [role];
|
||||
|
||||
// 空数组校验
|
||||
if (roles.length === 0) {
|
||||
console.warn("[Permission] 角色标识数组不能为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userInfo = userStore.userInfo;
|
||||
|
||||
// 未登录或无用户信息
|
||||
if (!userInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRoles = userInfo.roles || [];
|
||||
|
||||
// 超级管理员拥有所有角色
|
||||
if (userRoles.includes(ROLE_ROOT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查角色:满足其一即可
|
||||
return roles.some((r) => userRoles.includes(r));
|
||||
return checkAccess("role", role, "some");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否拥有所有指定权限(且关系)
|
||||
*
|
||||
* @param permissions 权限标识数组
|
||||
* @returns 是否拥有所有权限
|
||||
*/
|
||||
export function hasAllPermissions(permissions: string[]): boolean {
|
||||
if (!permissions || permissions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userInfo = userStore.userInfo;
|
||||
|
||||
if (!userInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { roles = [], perms = [] } = userInfo;
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (roles.includes(ROLE_ROOT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查权限:必须全部满足
|
||||
return permissions.every((perm) => perms.includes(perm));
|
||||
export function hasAllPermissions(perms: string | string[]): boolean {
|
||||
return checkAccess("perm", perms, "every");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否拥有所有指定角色(且关系)
|
||||
*
|
||||
* @param roles 角色标识数组
|
||||
* @returns 是否拥有所有角色
|
||||
*/
|
||||
export function hasAllRoles(roles: string[]): boolean {
|
||||
if (!roles || roles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userInfo = userStore.userInfo;
|
||||
|
||||
if (!userInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRoles = userInfo.roles || [];
|
||||
|
||||
// 超级管理员拥有所有角色
|
||||
if (userRoles.includes(ROLE_ROOT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查角色:必须全部满足
|
||||
return roles.every((role) => userRoles.includes(role));
|
||||
export function hasAllRoles(roles: string | string[]): boolean {
|
||||
return checkAccess("role", roles, "every");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAccessToken, clearTokens } from "./auth";
|
||||
import { getAccessToken, clearTokens } from "./auth";
|
||||
import { ApiCode } from "@/enums/api-code-enum";
|
||||
|
||||
// 401 跳转防抖锁,避免并发请求多次跳转登录页
|
||||
let isRedirecting401 = false;
|
||||
@@ -92,7 +93,7 @@ function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
|
||||
// HTTP 成功:校验业务码
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (!serverCode || serverCode === "00000") {
|
||||
if (!serverCode || serverCode === ApiCode.SUCCESS) {
|
||||
resolve(res.data.data);
|
||||
} else {
|
||||
reject(new RequestError(serverMsg || "请求失败", res.statusCode, serverCode));
|
||||
|
||||
Reference in New Issue
Block a user