feat: 新增扫码登录功能并优化 SSE、请求拦截与多页面适配

This commit is contained in:
Ray.Hao committed 2026-08-23 16:24:59 +08:00
1 parent 484e7047f5
commit 907b07f552
30 files changed
+259 -142

No files matched your search

+2 -2
View File
@@ -292,8 +292,8 @@ export interface UserForm {
mobile?: string;
/** 昵称 */
nickname?: string;
/** 角色ID集合 */
roleIds: number[];
/** 角色ID集合(后端 Long 序列化为字符串,兼容数字) */
roleIds: (number | string)[];
/** 用户状态(1:正常;0:禁用) */
status?: number;
/** 用户名 */
+1 -1
View File
@@ -63,7 +63,7 @@ const handleConfirm = () => {
const startDate = dayjs(dateRange.value[0]).format("YYYY-MM-DD");
const endDate = dayjs(dateRange.value[1]).format("YYYY-MM-DD");
let newVal: any = [startDate, endDate];
let newVal: string | string[] = [startDate, endDate];
// #ifdef MP-WEIXIN
newVal = `${startDate},${endDate}`;
+2 -1
View File
@@ -30,8 +30,9 @@
</view>
<view class="custom-navbar__right" :style="{ width: rightWidth + 'px' }">
<view class="custom-navbar__capsule-space" :style="{ width: capsuleSpaceWidth + 'px' }" />
<slot name="right" />
<!-- 微信端胶囊按钮占位,保证插槽内容不会顶到胶囊下方 -->
<view class="custom-navbar__capsule-space" :style="{ width: capsuleSpaceWidth + 'px' }" />
</view>
</view>
</view>
+2 -2
View File
@@ -71,7 +71,7 @@
import { ref, computed, watch } from "vue";
export interface TreeOption {
[key: string]: any;
[key: string]: unknown;
value: string;
label: string;
children?: TreeOption[];
@@ -163,7 +163,7 @@ const flatNodes = computed(() => {
result.push({
...node,
value: String(node[props.valueKey]),
label: node[props.labelKey],
label: node[props.labelKey] as string,
level,
hasChildren: !!(children && children.length > 0),
raw: node,
-1
View File
@@ -2,7 +2,6 @@ import { checkLogin } from "@/utils/auth";
interface NavItem {
url?: string;
[key: string]: any;
}
export function useNavigation() {
+28
View File
@@ -0,0 +1,28 @@
import { checkLogin } from "@/utils/auth";
/**
* 扫码登录 PC 端
*
* 调起相机扫描 PC 端登录二维码,校验票据后跳转扫码确认页。
* 未登录时跳转登录页,用户取消扫码静默处理。
*/
export function useScanLogin() {
const scanLogin = async () => {
if (!checkLogin()) return;
try {
const res = await uni.scanCode({ scanType: ["qrCode"] });
const ticket = (res.result || "").trim();
if (!ticket) {
uni.showToast({ title: "无效的二维码", icon: "none" });
return;
}
uni.navigateTo({
url: `/pages/mine/scan-confirm/index?ticket=${encodeURIComponent(ticket)}`,
});
} catch {
// 用户取消扫码,静默处理
}
};
return { scanLogin };
}
+3 -3
View File
@@ -9,7 +9,7 @@ export interface UseSseOptions {
maxReconnectAttempts?: number;
}
type EventHandler = (data: any) => void;
type EventHandler = (data: unknown) => void;
export enum SseConnectionState {
DISCONNECTED = "DISCONNECTED",
@@ -44,8 +44,8 @@ function createSseConnection(options: UseSseOptions = {}) {
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 log = (...args: unknown[]) => config.debug && console.log("[SSE]", ...args);
const logError = (...args: unknown[]) => console.error("[SSE]", ...args);
const clearTimer = (timer: typeof connectionTimeoutTimer) => {
if (timer) {
+6 -1
View File
@@ -1,6 +1,11 @@
<template>
<view :class="`wot-theme-${theme}`" :style="themeCSSVars">
<wd-config-provider :theme-vars="themeVars" :theme="theme === 'dark' ? 'dark' : ''" :button="{ type: 'primary' }" :tag="{ variant: 'plain' }">
<wd-config-provider
:theme-vars="themeVars"
:theme="theme === 'dark' ? 'dark' : ''"
:button="{ type: 'primary' }"
:tag="{ variant: 'plain' }"
>
<slot />
<wd-tabbar
v-model="active"
+21 -19
View File
@@ -120,9 +120,7 @@
<!-- 登录按钮 -->
<view class="login__form-item">
<wd-button block :loading="isLoading" @click="handleLogin">
登 录
</wd-button>
<wd-button block :loading="isLoading" @click="handleLogin">登 录</wd-button>
</view>
<!-- 切换登录方式 -->
@@ -243,9 +241,7 @@
<text class="login__demo-hint-text">演示环境验证码:123456</text>
</view>
<wd-button block :loading="isBindLoading" @click="handleBindMobile">
确认绑定
</wd-button>
<wd-button block :loading="isBindLoading" @click="handleBindMobile">确认绑定</wd-button>
</view>
</view>
</wd-popup>
@@ -264,6 +260,7 @@ import type { FormSchema } from "@wot-ui/ui/components/wd-form/types";
import { useUserStore } from "@/store/modules/user";
import { useCountdown } from "@/composables/useCountdown";
import { getErrorMessage } from "@/utils";
import AuthAPI from "@/api/auth";
definePage({
@@ -432,8 +429,8 @@ async function doFormLogin() {
await userStore.getInfo();
toast.success("登录成功");
setTimeout(() => uni.reLaunch({ url: redirect.value }), 800);
} catch (error: any) {
toast.error(error?.message || "登录失败");
} catch (error) {
toast.error(getErrorMessage(error, "登录失败"));
if (loginMode.value === "PASSWORD") fetchCaptcha();
} finally {
isLoading.value = false;
@@ -475,13 +472,18 @@ const handleSendCode = async () => {
await AuthAPI.sendSmsLoginCode(mobile);
toast.success("验证码已发送");
startSmsCountdown();
} catch (error: any) {
toast.error(error?.message || "发送失败");
} catch (error) {
toast.error(getErrorMessage(error, "发送失败"));
}
};
// 微信登录
const handleWechatPhoneLogin = async (e: any) => {
/** 微信手机号快捷填充按钮回调事件 */
interface WxPhoneLoginEvent {
detail: { code?: string };
}
const handleWechatPhoneLogin = async (e: WxPhoneLoginEvent) => {
const phoneCode = e.detail.code;
if (!isAgreePolicy.value) {
openPolicyDialog("WECHAT_PHONE", phoneCode);
@@ -518,7 +520,7 @@ const handleWechatSilentLogin = async () => {
isLoading.value = true;
try {
const { code } = await uni.login();
const result: any = await userStore.loginByWxMa(code);
const result = await userStore.loginByWxMa(code);
if (result.needBindMobile && result.openid) {
wechatOpenid.value = result.openid;
showBindMobilePopup.value = true;
@@ -527,8 +529,8 @@ const handleWechatSilentLogin = async () => {
toast.success("登录成功");
setTimeout(() => uni.reLaunch({ url: redirect.value }), 800);
}
} catch (error: any) {
toast.error(error?.message || "微信登录失败");
} catch (error) {
toast.error(getErrorMessage(error, "微信登录失败"));
} finally {
isLoading.value = false;
}
@@ -546,8 +548,8 @@ const handleSendBindCode = async () => {
await AuthAPI.sendSmsLoginCode(mobile);
toast.success("验证码已发送");
startBindSmsCountdown();
} catch (error: any) {
toast.error(error?.message || "发送失败");
} catch (error) {
toast.error(getErrorMessage(error, "发送失败"));
}
};
@@ -575,8 +577,8 @@ const handleBindMobile = async () => {
resetBindForm();
toast.success("绑定成功");
setTimeout(() => uni.reLaunch({ url: redirect.value }), 800);
} catch (error: any) {
toast.error(error?.message || "绑定失败");
} catch (error) {
toast.error(getErrorMessage(error, "绑定失败"));
} finally {
isBindLoading.value = false;
}
@@ -597,7 +599,7 @@ const handleBack = () => {
};
// 生命周期
onLoad((options: any) => {
onLoad((options) => {
const fromQuery = options?.redirect ? decodeURIComponent(options.redirect) : "";
if (fromQuery && fromQuery !== "/pages/login/index") redirect.value = fromQuery;
uni.setNavigationBarTitle({ title: "" });
+23 -31
View File
@@ -11,6 +11,11 @@
:show-back="false"
:placeholder="false"
>
<template #left>
<view v-if="isLogin" class="mine-navbar__scan" aria-label="扫一扫" @click="scanLogin">
<wd-icon name="scan" size="40rpx" color="var(--color-text-inverse)" />
</view>
</template>
<template #center>
<text class="mine-navbar__title">个人中心</text>
</template>
@@ -65,9 +70,7 @@
</view>
<view v-if="!isLogin" class="profile-card__action" @click.stop="navigateToLogin()">
<wd-button custom-class="profile-card__button" size="small">
立即登录
</wd-button>
<wd-button custom-class="profile-card__button" size="small">立即登录</wd-button>
</view>
</view>
</view>
@@ -132,16 +135,6 @@
</view>
<wd-icon name="right" size="16" color="var(--color-text-placeholder)" />
</view>
<view v-if="isLogin" class="menu-row" @click="handleScan">
<view class="menu-row__icon menu-row__icon--info">
<wd-icon name="scan" size="18" color="var(--color-info)" />
</view>
<view class="menu-row__main">
<text class="menu-row__title">扫一扫</text>
<text class="menu-row__desc">扫描二维码登录 PC</text>
</view>
<wd-icon name="right" size="16" color="var(--color-text-placeholder)" />
</view>
<view class="menu-row" @click="handleClearCache">
<view class="menu-row__icon menu-row__icon--danger">
<wd-icon name="delete" size="18" color="var(--color-danger)" />
@@ -211,6 +204,7 @@ import { onShow } from "@dcloudio/uni-app";
import { useUserStore, useThemeStore } from "@/store";
import { useRouter } from "uni-mini-router";
import { useNavbar } from "@/composables/useNavbar";
import { useScanLogin } from "@/composables/useScanLogin";
import { getAccessToken } from "@/utils/auth";
import { formatBytes } from "@/utils/format";
@@ -388,24 +382,8 @@ const handleClearCache = async () => {
}
};
// 扫一扫:扫描 PC 端二维码后跳转确认页
const handleScan = async () => {
if (!isLogin.value) {
navigateToLogin();
return;
}
try {
const res = await uni.scanCode({ scanType: ["qrCode"] });
const ticket = (res.result || "").trim();
if (!ticket) {
uni.showToast({ title: "无效的二维码", icon: "none" });
return;
}
router.push({ path: "/pages/mine/scan-confirm/index", query: { ticket: encodeURIComponent(ticket) } });
} catch {
// 用户取消扫码,静默处理
}
};
// 扫一扫:扫描 PC 端二维码后跳转确认页(逻辑收敛在 useScanLogin)
const { scanLogin } = useScanLogin();
// 有来技术公众号
const openOfficialAccount = () => {
@@ -455,6 +433,20 @@ const openOfficialAccount = () => {
letter-spacing: 2rpx;
}
/* 扫码入口:渐变背景上的白色图标,64rpx 圆形热区 */
.mine-navbar__scan {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
&:active {
background-color: rgba(255, 255, 255, 0.2);
}
}
.profile-tag {
display: inline-flex;
gap: 8rpx;
+17 -6
View File
@@ -133,6 +133,7 @@ import { ref, reactive, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { useDialog, useToast } from "@wot-ui/ui";
import { useUserStore } from "@/store";
import { getErrorMessage } from "@/utils";
import UserAPI, { type UserProfileForm } from "@/api/user";
import FileAPI, { type FileInfo } from "@/api/file";
@@ -165,7 +166,7 @@ const canComplete = computed(() => {
return profileForm.nickname && profileForm.mobile;
});
onLoad((options: any) => {
onLoad((options) => {
if (options?.redirect) {
redirect.value = decodeURIComponent(options.redirect);
}
@@ -173,11 +174,21 @@ onLoad((options: any) => {
if (userInfo) {
profileForm.nickname = userInfo.nickname || "";
profileForm.avatar = userInfo.avatar || "";
profileForm.gender = (userInfo as any).gender || 1;
profileForm.gender = userInfo.gender || 1;
}
});
const onChooseAvatar = async (e: any) => {
/** 微信头像选择按钮回调事件(open-type="chooseAvatar") */
interface WxChooseAvatarEvent {
detail: { avatarUrl: string };
}
/** wd-img-cropper 裁剪确认事件 */
interface ImgCropperConfirmEvent {
tempFilePath: string;
}
const onChooseAvatar = async (e: WxChooseAvatarEvent) => {
try {
const { avatarUrl } = e.detail;
toast.loading("上传中...");
@@ -220,7 +231,7 @@ const chooseAvatar = () => {
// #endif
};
const handleAvatarConfirm = async (event: any) => {
const handleAvatarConfirm = async (event: ImgCropperConfirmEvent) => {
try {
const { tempFilePath } = event;
toast.loading("上传中...");
@@ -260,8 +271,8 @@ const handleComplete = async () => {
setTimeout(() => {
uni.reLaunch({ url: redirect.value });
}, 1000);
} catch (error: any) {
toast.error(error?.message || "完善信息失败");
} catch (error) {
toast.error(getErrorMessage(error, "完善信息失败"));
} finally {
isLoading.value = false;
}
+6 -1
View File
@@ -99,7 +99,12 @@ function handleAvatarUpload() {
});
}
// 头像裁剪完成
function handleAvatarConfirm(event: any) {
/** wd-img-cropper 裁剪确认事件 */
interface ImgCropperConfirmEvent {
tempFilePath: string;
}
function handleAvatarConfirm(event: ImgCropperConfirmEvent) {
const { tempFilePath } = event;
FileAPI.upload(tempFilePath).then((fileInfo: FileInfo) => {
const avatarForm: UserProfileForm = {
+6 -5
View File
@@ -35,6 +35,7 @@
import { onLoad } from "@dcloudio/uni-app";
import { useToast } from "@wot-ui/ui";
import { useUserStore } from "@/store/modules/user";
import { getErrorMessage } from "@/utils";
import type { QrCodeScanResult } from "@/api/auth";
definePage({
@@ -61,8 +62,8 @@ const scanResult = ref<QrCodeScanResult | null>(null);
const markScanned = async (code: string) => {
try {
scanResult.value = await userStore.qrScan(code);
} catch (error: any) {
toast.error(error?.message || "二维码无效或已过期");
} catch (error) {
toast.error(getErrorMessage(error, "二维码无效或已过期"));
setTimeout(() => uni.navigateBack(), TOAST_DURATION);
}
};
@@ -75,8 +76,8 @@ const handleConfirm = async () => {
await userStore.qrConfirm(ticket.value);
toast.success("已确认登录");
setTimeout(() => uni.navigateBack(), TOAST_DURATION);
} catch (error: any) {
toast.error(error?.message || "确认失败");
} catch (error) {
toast.error(getErrorMessage(error, "确认失败"));
} finally {
isLoading.value = false;
}
@@ -96,7 +97,7 @@ const handleCancel = async () => {
}
};
onLoad((options: any) => {
onLoad((options) => {
const code = options?.ticket ? decodeURIComponent(options.ticket) : "";
if (!code) {
toast.error("二维码无效");
+1 -3
View File
@@ -67,9 +67,7 @@
<!-- 操作按钮 -->
<view class="mt-32rpx">
<wd-button plain size="large" block @click="handleResetTheme">
重置为默认主题
</wd-button>
<wd-button plain size="large" block @click="handleResetTheme">重置为默认主题</wd-button>
</view>
<!-- 自定义颜色输入弹窗 -->
+2 -2
View File
@@ -15,8 +15,8 @@ definePage({
const url = ref("");
onLoad((query: any) => {
if (query.url) {
onLoad((query) => {
if (query?.url) {
url.value = decodeURIComponent(query.url);
}
});
+3 -5
View File
@@ -72,9 +72,7 @@
</wd-form>
<view class="popup-actions">
<wd-button type="info" variant="plain" @click="closeConfigDialog">取消</wd-button>
<wd-button :loading="isSubmitting" @click="submitConfigForm">
保存
</wd-button>
<wd-button :loading="isSubmitting" @click="submitConfigForm">保存</wd-button>
</view>
</view>
</wd-popup>
@@ -154,7 +152,7 @@ function loadConfigList() {
function fetchConfigList() {
loadMoreState.value = "loading";
ConfigAPI.getPage(queryParams)
.then((data: any) => {
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
@@ -205,7 +203,7 @@ function closeConfigDialog() {
}
// 操作菜单分发
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
const value = item.name;
const configItem = currentActionItem.value;
if (value === "编辑") {
+17 -8
View File
@@ -104,9 +104,10 @@ import { onLoad } from "@dcloudio/uni-app";
import { toFormSchema } from "@/utils/form";
import { findOptionChain } from "@/utils/tree";
import { useToast, useDialog } from "@wot-ui/ui";
import type { CascaderOption } from "@wot-ui/ui/components/wd-cascader/types";
import DeptAPI, { type DeptQuery, DeptItem, DeptForm } from "@/api/dept";
import { hasPermission } from "@/utils/permission";
import CustomTree from "@/components/custom-tree/index.vue";
import CustomTree, { type TreeOption } from "@/components/custom-tree/index.vue";
definePage({
name: "dept",
@@ -140,10 +141,10 @@ const formData = reactive<DeptForm>({ ...initialFormData });
// 转换为树组件数据格式
const treeData = computed(() => deptList.value.map((dept) => transformDeptToTree(dept)));
function transformDeptToTree(dept: DeptItem): any {
function transformDeptToTree(dept: DeptItem): TreeOption {
return {
value: String(dept.id),
label: dept.name,
label: dept.name ?? "",
id: dept.id,
name: dept.name,
status: dept.status,
@@ -158,10 +159,16 @@ const parentOptions = ref<OptionType[]>([]);
const parentLabel = ref("");
// 上级部门确认选择
const handleParentConfirm = ({ value, selectedOptions }: any) => {
const handleParentConfirm = ({
value,
selectedOptions,
}: {
value: string | number;
selectedOptions: CascaderOption[];
}) => {
parentSelected.value = value;
formData.parentId = Number(value) || 0;
parentLabel.value = selectedOptions.map((item: any) => item.label).join("/");
parentLabel.value = selectedOptions.map((item) => String(item.label ?? "")).join("/");
};
const rules = toFormSchema({
@@ -185,7 +192,7 @@ function loadDeptList() {
}
// 操作菜单分发
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
const value = item.name;
const dept = currentActionItem.value;
if (value === "新增子部门") {
@@ -206,7 +213,7 @@ function handleActionSelect({ item }: { item: any }) {
}
// 处理节点操作按钮点击
function handleNodeAction(node: any) {
function handleNodeAction(node: TreeOption) {
const dept: DeptItem = {
id: node.id,
name: node.label,
@@ -272,7 +279,9 @@ function handleAddChild(dept: DeptItem) {
formData.parentId = dept.id!;
parentSelected.value = dept.id!;
parentLabel.value =
findOptionChain(treeData.value, dept.id!)?.map((option) => option.label).join("/") ||
findOptionChain(treeData.value, dept.id!)
?.map((option) => option.label)
.join("/") ||
dept.name ||
"";
}
+2 -2
View File
@@ -147,7 +147,7 @@ function loadDictTypeList() {
function fetchDictTypeList() {
loadMoreState.value = "loading";
DictAPI.getPage(queryParams)
.then((data: any) => {
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
@@ -249,7 +249,7 @@ function showDictActions(item: DictTypeItem) {
actionSheetVisible.value = true;
}
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
pendingAction.value[item.name]?.();
}
+5 -5
View File
@@ -163,7 +163,7 @@ function fetchItemList() {
loadMoreState.value = "loading";
DictAPI.getItemPage(dictCode.value, queryParams)
.then((data: any) => {
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
@@ -264,7 +264,7 @@ function showItemActions(item: DictDataItem) {
actionSheetVisible.value = true;
}
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
pendingAction.value[item.name]?.();
}
@@ -276,9 +276,9 @@ onReachBottom(() => {
}
});
onLoad((query: any) => {
dictCode.value = String((query as any)?.dictCode || "");
pageTitle.value = String((query as any)?.title || "字典数据");
onLoad((query) => {
dictCode.value = String(query?.dictCode || "");
pageTitle.value = String(query?.title || "字典数据");
if (pageTitle.value) {
uni.setNavigationBarTitle({ title: pageTitle.value });
}
+24 -11
View File
@@ -81,7 +81,11 @@
<wd-radio value="B">按钮</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item v-if="formData.type === 'C' || formData.type === 'M'" prop="routePath" title="路由路径">
<wd-form-item
v-if="formData.type === 'C' || formData.type === 'M'"
prop="routePath"
title="路由路径"
>
<wd-input v-model="formData.routePath" placeholder="system 或 /system" />
</wd-form-item>
<wd-form-item v-if="formData.type === 'E'" prop="externalUrl" title="外链地址">
@@ -136,7 +140,8 @@ import { findOptionChain } from "@/utils/tree";
import { useToast, useDialog } from "@wot-ui/ui";
import MenuAPI, { type MenuQuery, MenuItem, MenuForm } from "@/api/menu";
import { hasPermission } from "@/utils/permission";
import CustomTree from "@/components/custom-tree/index.vue";
import CustomTree, { type TreeOption } from "@/components/custom-tree/index.vue";
import type { CascaderOption } from "@wot-ui/ui/components/wd-cascader/types";
definePage({
name: "menu",
@@ -175,10 +180,10 @@ const formData = reactive<MenuForm>({ ...initialFormData });
// 转换为树组件数据格式
const treeData = computed(() => menuList.value.map((menu: MenuItem) => transformMenuToTree(menu)));
function transformMenuToTree(menu: MenuItem): any {
function transformMenuToTree(menu: MenuItem): TreeOption {
return {
value: menu.id,
label: menu.name,
value: menu.id ?? "",
label: menu.name ?? "",
id: menu.id,
name: menu.name,
type: menu.type,
@@ -221,10 +226,16 @@ const parentOptions = ref<OptionType[]>([]);
const parentLabel = ref("");
// 上级菜单确认选择
const handleParentConfirm = ({ value, selectedOptions }: any) => {
const handleParentConfirm = ({
value,
selectedOptions,
}: {
value: string | number;
selectedOptions: CascaderOption[];
}) => {
parentSelected.value = value;
formData.parentId = String(value) || "0";
parentLabel.value = selectedOptions.map((item: any) => item.label).join("/");
parentLabel.value = selectedOptions.map((item) => String(item.label ?? "")).join("/");
};
const rules = toFormSchema({
@@ -236,13 +247,13 @@ const rules = toFormSchema({
const handleSearch = () => loadMenuList();
function loadMenuList() {
MenuAPI.getList(queryParams).then((data: any) => {
MenuAPI.getList(queryParams).then((data) => {
menuList.value = data;
});
}
// 操作菜单分发
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
const value = item.name;
const menu = currentActionItem.value;
if (value === "新增子菜单") {
@@ -263,7 +274,7 @@ function handleActionSelect({ item }: { item: any }) {
}
// 处理节点操作按钮点击
function handleNodeAction(node: any) {
function handleNodeAction(node: TreeOption) {
const menu: MenuItem = {
id: node.id,
name: node.label,
@@ -330,7 +341,9 @@ function handleAddChild(menu: MenuItem) {
formData.parentId = menu.id!;
parentSelected.value = menu.id!;
parentLabel.value =
findOptionChain(treeData.value, menu.id!)?.map((option) => option.label).join("/") ||
findOptionChain(treeData.value, menu.id!)
?.map((option) => option.label)
.join("/") ||
menu.name ||
"";
}
+2 -4
View File
@@ -134,9 +134,7 @@
</wd-form>
<view class="popup-actions">
<wd-button type="info" variant="plain" @click="closeNoticeForm">取消</wd-button>
<wd-button :loading="isSubmitting" @click="submitNoticeForm">
保存
</wd-button>
<wd-button :loading="isSubmitting" @click="submitNoticeForm">保存</wd-button>
</view>
</view>
</wd-popup>
@@ -390,7 +388,7 @@ function showNoticeActions(item: NoticeItem) {
actionSheetVisible.value = true;
}
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
pendingAction.value[item.name]?.();
}
+3 -1
View File
@@ -10,7 +10,9 @@
<view class="action-bar">
<view class="action-bar__left">
<wd-button type="" size="small" variant="plain" @click="handleSelectAll">全选</wd-button>
<wd-button type="" size="small" variant="plain" @click="handleClearAll">取消全选</wd-button>
<wd-button type="" size="small" variant="plain" @click="handleClearAll">
取消全选
</wd-button>
</view>
</view>
</view>
+2 -2
View File
@@ -254,7 +254,7 @@ function closeRoleDialog() {
}
// 操作菜单分发
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
const value = item.name;
const role = currentActionItem.value;
if (value === "编辑") {
@@ -267,7 +267,7 @@ function handleActionSelect({ item }: { item: any }) {
msg: `确定要删除角色「${role.name}」吗?`,
headerImage: "warning",
}).then(async () => {
await RoleAPI.deleteByIds(String(item.id));
await RoleAPI.deleteByIds(String(role.id));
toast.success("删除成功");
loadRoleList();
});
+25 -15
View File
@@ -202,10 +202,7 @@
<wd-button type="info" variant="plain" @click="resetPwdDialog.visible = false">
取消
</wd-button>
<wd-button
:loading="resetPwdDialog.isSubmitting"
@click="handleResetPassword"
>
<wd-button :loading="resetPwdDialog.isSubmitting" @click="handleResetPassword">
确认
</wd-button>
</view>
@@ -217,6 +214,7 @@
<script lang="ts" setup>
import { onLoad, onReachBottom } from "@dcloudio/uni-app";
import { LoadMoreState } from "@wot-ui/ui/components/wd-loadmore/types";
import type { CascaderOption } from "@wot-ui/ui/components/wd-cascader/types";
import { toFormSchema } from "@/utils/form";
import { findOptionChain } from "@/utils/tree";
import { useQueue, useToast, useDialog } from "@wot-ui/ui";
@@ -261,7 +259,7 @@ const initialFormData: UserForm = {
};
const formData = reactive<UserForm>({ ...initialFormData });
const roleOptions = ref<Record<string, any>[]>([]);
const roleOptions = ref<OptionType[]>([]);
const deptOptions = ref<OptionType[]>([]);
// 部门选择器状态
@@ -274,14 +272,25 @@ const showRolePicker = ref(false);
const roleLabel = ref("");
// 部门确认选择
const handleDeptConfirm = ({ value, selectedOptions }: any) => {
formData.deptId = value;
deptLabel.value = selectedOptions.map((item: any) => item.label).join("/");
const handleDeptConfirm = ({
value,
selectedOptions,
}: {
value: string | number;
selectedOptions: CascaderOption[];
}) => {
formData.deptId = Number(value);
deptLabel.value = selectedOptions.map((item) => String(item.label ?? "")).join("/");
};
// 角色确认选择
const handleRoleConfirm = ({ selectedItems }: any) => {
roleLabel.value = selectedItems.map((item: any) => item.label).join("、");
const handleRoleConfirm = ({
selectedItems,
}: {
value: (string | number)[];
selectedItems: OptionType[];
}) => {
roleLabel.value = selectedItems.map((item) => item.label).join("、");
};
const rules = toFormSchema({
@@ -336,7 +345,7 @@ function resetUserFilter() {
function fetchUserList() {
loadMoreState.value = "loading";
UserAPI.getPage(queryParams)
.then((data: any) => {
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
@@ -371,11 +380,12 @@ async function openUserDialog(user?: UserItem) {
const chain = findOptionChain(deptData, data.deptId);
deptLabel.value = chain ? chain.map((option) => option.label).join("/") : user.deptName || "";
}
// 回显角色
// 回显角色(roleIds 与选项 value 统一转字符串比较,兼容数字/字符串ID)
if (data.roleIds && data.roleIds.length > 0) {
const roleIds = data.roleIds.map(String);
const names = roleOptions.value
.filter((r: any) => data.roleIds.includes(r.value))
.map((r: any) => r.label);
.filter((r) => roleIds.includes(String(r.value)))
.map((r) => r.label);
roleLabel.value = names.join("、");
}
}
@@ -464,7 +474,7 @@ function showUserActions(item: UserItem) {
actionSheetVisible.value = true;
}
function handleActionSelect({ item }: { item: any }) {
function handleActionSelect({ item }: { item: { name: string } }) {
pendingAction.value[item.name]?.();
}
+16 -3
View File
@@ -3,9 +3,22 @@ import { getAccessToken } from "@/utils/auth";
import { createRouter } from "uni-mini-router";
import { useUserStore } from "@/store";
/** virtual:uni-pages 生成的页面路由描述 */
interface PageMetaRoute {
path: string;
meta?: Record<string, unknown>;
[key: string]: unknown;
}
/** 分包路由描述 */
interface SubPackageRoute {
root: string;
pages: PageMetaRoute[];
}
// 生成路由配置
function generateRoutes() {
const routes = pages.map((page: { path: string; [key: string]: any }) => {
const routes = pages.map((page: PageMetaRoute) => {
const newPath = `/${page.path}`;
const meta = page.meta ?? undefined;
return { ...page, path: newPath, meta };
@@ -13,8 +26,8 @@ function generateRoutes() {
// 处理分包路由
if (subPackages && subPackages.length > 0) {
subPackages.forEach((subPackage: { root: string; pages: any[] }) => {
const subRoutes = subPackage.pages.map((page: any) => {
subPackages.forEach((subPackage: SubPackageRoute) => {
const subRoutes = subPackage.pages.map((page) => {
const newPath = `/${subPackage.root}/${page.path}`;
const meta = page.meta ?? undefined;
return { ...page, path: newPath, meta };
+6
View File
@@ -37,12 +37,14 @@ declare global {
const dict: typeof import('../api/dict')['default']
const effectScope: typeof import('vue')['effectScope']
const file: typeof import('../api/file')['default']
const findOptionChain: typeof import('../utils/tree')['findOptionChain']
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']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getErrorMessage: typeof import('../utils/index')['getErrorMessage']
const getRefreshToken: typeof import('../utils/auth')['getRefreshToken']
const getToken: typeof import('../utils/storage')['getToken']
const getUserInfo: typeof import('../utils/storage')['getUserInfo']
@@ -188,6 +190,7 @@ declare global {
const useRoute: typeof import('uni-mini-router')['useRoute']
const useRouter: typeof import('uni-mini-router')['useRouter']
const useScanCode: typeof import('@uni-helper/uni-use')['useScanCode']
const useScanLogin: typeof import('../composables/useScanLogin')['useScanLogin']
const useScreenBrightness: typeof import('@uni-helper/uni-use')['useScreenBrightness']
const useSelectorQuery: typeof import('@uni-helper/uni-use')['useSelectorQuery']
const useSlots: typeof import('vue')['useSlots']
@@ -253,12 +256,14 @@ 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 findOptionChain: UnwrapRef<typeof import('../utils/tree')['findOptionChain']>
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']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getErrorMessage: UnwrapRef<typeof import('../utils/index')['getErrorMessage']>
readonly getRefreshToken: UnwrapRef<typeof import('../utils/auth')['getRefreshToken']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hasAllPermissions: UnwrapRef<typeof import('../utils/permission')['hasAllPermissions']>
@@ -360,6 +365,7 @@ declare module 'vue' {
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 useScanLogin: UnwrapRef<typeof import('../composables/useScanLogin')['useScanLogin']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSse: UnwrapRef<typeof import('../composables/useSse')['useSse']>
readonly useTabbar: UnwrapRef<typeof import('../composables/useTabbar')['useTabbar']>
+1 -1
View File
@@ -7,7 +7,6 @@ type _LocationUrl =
"/pages/index/index" |
"/pages/login/index" |
"/pages/mine/index" |
"/pages/mine/scan-confirm/index" |
"/pages/webview/index" |
"/pages/work/index" |
"/pages/mine/about/index" |
@@ -15,6 +14,7 @@ type _LocationUrl =
"/pages/mine/official/index" |
"/pages/mine/profile/complete-profile" |
"/pages/mine/profile/index" |
"/pages/mine/scan-confirm/index" |
"/pages/mine/settings/index" |
"/pages/work/config/index" |
"/pages/work/dept/index" |
+1 -1
View File
@@ -65,7 +65,7 @@ function getCurrentPagePath(): string {
const currentPage = pages[pages.length - 1];
const route = currentPage.route || "";
const options = (currentPage as any).options || {};
const options = (currentPage as { options?: Record<string, string> }).options || {};
const query = Object.entries(options)
.map(([key, value]) => `${key}=${value}`)
+24 -2
View File
@@ -4,9 +4,9 @@
* @param delay 延迟时间
* @returns
*/
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
const debounce = <T extends (...args: any[]) => unknown>(fn: T, delay: number) => {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>) {
return function (this: unknown, ...args: Parameters<T>) {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
@@ -16,3 +16,25 @@ const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
};
export { debounce };
/**
* 从 catch 捕获的 unknown 错误中提取提示文案
* @param error catch 捕获的错误对象
* @param fallback 无有效信息时的兜底文案
* @returns 错误提示文案
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
if (typeof error === "object" && error !== null) {
const { message, msg, errMsg } = error as {
message?: unknown;
msg?: unknown;
errMsg?: unknown;
};
if (typeof message === "string" && message) return message;
if (typeof msg === "string" && msg) return msg;
if (typeof errMsg === "string" && errMsg) return errMsg;
}
return fallback;
}
+8 -4
View File
@@ -68,9 +68,13 @@ function request<T = any>(options: RequestOptions): Promise<T> {
header,
timeout: options.timeout || 30000,
responseType: options.responseType,
success: (res: any) => {
const serverCode = res?.data?.code;
const serverMsg = res?.data?.msg || res?.data?.message;
success: (res: UniApp.RequestSuccessCallbackResult) => {
// 后端统一响应体(data 在泛型 T 下无法精确描述,这里只取业务层字段)
const body = res?.data as
| { code?: string; msg?: string; message?: string; data?: unknown }
| undefined;
const serverCode = body?.code;
const serverMsg = body?.msg || body?.message;
// 令牌无效/过期(业务码 A023x 或 HTTP 401):清除 token 并跳转登录页(防抖)
const isTokenError =
@@ -94,7 +98,7 @@ function request<T = any>(options: RequestOptions): Promise<T> {
// HTTP 成功:校验业务码
if (res.statusCode >= 200 && res.statusCode < 300) {
if (!serverCode || serverCode === ApiCode.SUCCESS) {
resolve(res.data.data);
resolve(body?.data as T);
} else {
reject(new RequestError(serverMsg || "请求失败", res.statusCode, serverCode));
}