refactor: 界面优化和使用sse替换websocket

This commit is contained in:
Ray.Hao
2026-04-26 14:11:05 +08:00
parent 0288b5a08d
commit c9fbc441bd
31 changed files with 546 additions and 916 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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;
}
/** 修改密码表单 */

View 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,
};
}

View 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
View 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;
}
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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);
}
}
// 卡片头部

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;
}
};

View File

@@ -161,11 +161,6 @@ onLoad(() => {
loadUserProfile();
});
onMounted(() => {
// 在onMounted中不再重复检查登录状态和加载用户信息
// 如果需要检查登录状态和加载用户信息请使用onLoad中的逻辑
});
// 页面销毁前移除事件监听
onBeforeUnmount(() => {
// #ifdef H5

View File

@@ -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;
}
};

View File

@@ -25,7 +25,7 @@
v-if="currentThemeColor === color.primary"
name="check"
size="14"
color="#fff"
color="var(--color-text-inverse)"
/>
</view>
</view>

View File

@@ -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;
});
});
}

View File

@@ -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;
});
});
}

View File

@@ -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;
});
});
}

View File

@@ -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;
});
});
}

View File

@@ -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">

View File

@@ -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;
});
});
}

View File

@@ -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;
});
});
}

View File

@@ -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;
}
}

View File

@@ -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;
});
});
}

View File

@@ -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;
}
}

View File

@@ -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
View File

@@ -14,11 +14,6 @@ interface ImportMetaEnv {
/** 应用版本号 */
readonly VITE_APP_VERSION: string;
/**
* WebSocket 端点
*/
readonly VITE_APP_WS_ENDPOINT?: string;
}
interface ImportMeta {

View File

@@ -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
View 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);
}

View File

@@ -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");
}

View File

@@ -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));