Jelajahi Sumber

添加 登录模块 & 用户权限 & 菜单

cc12458 1 bulan lalu
induk
melakukan
74f7b538d1

+ 1 - 0
@types/router.d.ts

@@ -8,5 +8,6 @@ declare module 'vue-router' {
   interface RouteMeta {
     title?: string;
     scan?: boolean;
+    account?: boolean;
   }
 }

+ 87 - 0
src/modules/system/components/UpdatePassword.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import type { DialogProps, FormInstance } from 'vant';
+import { updateUserPassword } from '@/request/api/manage';
+
+export interface UpdatePasswordProps {
+  pid: string | number;
+  title?: string;
+  token?: string;
+}
+
+const { title = '重置密码', pid, token } = defineProps<UpdatePasswordProps>();
+const emits = defineEmits<{
+  complete: [];
+  cancel: [];
+}>();
+const show = defineModel('show', { default: false });
+const pending = ref(false);
+
+const form = useTemplateRef<FormInstance>('form');
+const model = ref({ password: '', again: '' });
+const dialog = reactive<Partial<DialogProps>>({
+  showCancelButton: true,
+  className: 'reset-password-dialog',
+  width: 480,
+  beforeClose(action) {
+    return action !== 'confirm' || onSubmit();
+  },
+});
+
+const onCancel = () => {
+  if (pending.value) return;
+  form.value?.resetValidation();
+  model.value.password = '';
+  model.value.again = '';
+  emits('cancel');
+};
+const onSubmit = async () => {
+  try {
+    await form.value?.validate();
+    await updateUserPassword({ pid, ...model.value }, token).send(true);
+    pending.value = true;
+    show.value = false;
+    await showConfirmDialog({ title: '请重新登录', showCancelButton: false });
+    emits('complete');
+    return true;
+  } catch {
+    return false;
+  }
+};
+
+const validator: any = {
+  required: { required: true, message: '请输入', trigger: 'onChange' },
+  password: {
+    trigger: ['onBlur', 'onSubmit'],
+    pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{6,18}$/,
+    message: '密码必须由大小写字母和数字三种组合,6-18 位字符',
+    validateEmpty: false,
+  },
+  again: {
+    trigger: ['onChange', 'onBlur', 'onSubmit'],
+    validator(value: string) {
+      return !model.value.password || model.value.password === value;
+    },
+    message: '两次输入的密码不同',
+  },
+};
+</script>
+
+<template>
+  <van-dialog v-bind="dialog" v-model:show="show" :title @closed="onCancel()">
+    <van-form ref="form" @submit="onSubmit" class="py-4">
+      <van-cell-group inset>
+        <van-field v-model="model.password" type="password" name="password" label="新密码" placeholder="请输入新密码" :rules="[validator.required, validator.password]" />
+        <van-field
+          v-model="model.again"
+          type="password"
+          name="again"
+          label="确认密码"
+          placeholder="再次输入新密码"
+          :rules="[validator.required, validator.again, validator.password]"
+        />
+      </van-cell-group>
+    </van-form>
+  </van-dialog>
+</template>
+
+<style scoped lang="scss"></style>

+ 60 - 0
src/modules/system/useUpdatePassword.ts

@@ -0,0 +1,60 @@
+import { defineComponent, h, ref, shallowRef } from 'vue';
+import { type UpdatePasswordProps, default as UpdatePasswordDialog } from './components/UpdatePassword.vue';
+
+export function useUpdatePassword() {
+  let pending: PromiseWithResolvers<boolean> | undefined;
+  const updatePasswordProps = shallowRef<UpdatePasswordProps>();
+  const show = ref(false);
+
+  function settle(ok: boolean) {
+    pending?.resolve(ok);
+    pending = undefined;
+  }
+
+  const api = {
+    open(props: UpdatePasswordProps) {
+      pending?.resolve(false);
+      pending = Promise.withResolvers<boolean>();
+      updatePasswordProps.value = props;
+      show.value = true;
+      return pending.promise;
+    },
+    close() {
+      show.value = false;
+      settle(false);
+    },
+  };
+
+  const Wrapper = defineComponent({
+    name: 'WrapperResetPassword',
+    setup(_, { attrs, slots }) {
+      return () => {
+        const props = updatePasswordProps.value;
+        if (!props) return null;
+
+        return h(
+          UpdatePasswordDialog,
+          {
+            ...attrs,
+            ...props,
+            show: show.value,
+            'onUpdate:show'(v: boolean) {
+              show.value = v;
+            },
+            onComplete() {
+              show.value = false;
+              settle(true);
+            },
+            onCancel() {
+              show.value = false;
+              settle(false);
+            },
+          },
+          slots
+        );
+      };
+    },
+  });
+
+  return [Wrapper, api] as const;
+}

+ 125 - 0
src/pages/510k.page.vue

@@ -0,0 +1,125 @@
+<script setup lang="ts">
+import type { ActionSheetProps, PopoverProps } from 'vant';
+import type { MenuModel } from '@/request/model/manage.model';
+import { tryOnBeforeMount } from '@vueuse/core';
+import { useRequest } from 'alova/client';
+import { getApplicationMethod } from '@/request/api';
+import { useAccountStore } from '@/stores';
+import { startDirective, useStart } from '@/composables/start';
+import { useUpdatePassword } from '@/modules/system/useUpdatePassword';
+
+const { data } = useRequest(getApplicationMethod, { initialData: { image: { copyright: '' } } });
+const title = computed(() => data.value.image.title || import.meta.env.SIX_APP_TITLE);
+const copyright = computed(() => data.value.copyright);
+
+const Account = useAccountStore();
+const { loading, menus, token } = storeToRefs(Account);
+const router = useRouter();
+tryOnBeforeMount(() => {
+  if (!unref(token)) router.replace({ name: 'login' });
+});
+
+/** 右上角菜单 */
+const settingProps = reactive<Partial<PopoverProps> & { onSelect: any }>({
+  placement: 'bottom-end',
+  theme: 'dark',
+  closeOnClickAction: true,
+  actions: [
+    {
+      text: '重置密码',
+      async callback() {
+        const pid = unref(Account.user)?.id;
+        if (pid && (await password.open({ pid }))) logout().then();
+      },
+    },
+    {
+      text: '退出登录',
+      async callback() {
+        await showConfirmDialog({ title: '退出登录' });
+        await logout();
+      },
+    },
+  ],
+  onSelect(action: any) {
+    action?.callback?.();
+  },
+});
+const logout = async () => {
+  await router.replace({ path: '/login' });
+  Account.logout();
+};
+
+const vStart = startDirective;
+const { handle: start, extra } = useStart(true);
+
+const route = useRoute();
+const menuActionSheetProps = reactive<Partial<ActionSheetProps>>({
+  title: '',
+  cancelText: '关闭',
+  closeable: false,
+  actions: [],
+  closeOnClickAction: true,
+});
+const handle = (menu: MenuModel) => {
+  if (menu.children && menu.children.length > 0) {
+    menuActionSheetProps.actions = menu.children.map((child) => ({
+      name: child.meta.title,
+      icon: child.meta.icon,
+      callback: () => handle(child),
+    }));
+    menuActionSheetProps.title = menu.meta.title;
+    menuActionSheetProps.show = true;
+  } else if (menu.path === route.path) start();
+  else router.push({ path: menu.path, query: { title: menu.meta.title } });
+};
+
+const [Password, password] = useUpdatePassword();
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden"></div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <van-popover v-bind="settingProps">
+          <template #reference>
+            <van-icon name="setting-o" style="font-size: 32px" />
+          </template>
+        </van-popover>
+      </div>
+    </div>
+    <div class="page-content flex flex-col">
+      <div class="flex-none h-[106px]">
+        <div class="h-full flex justify-center items-center text-4xl" style="letter-spacing: 0.2em">{{ title }}</div>
+      </div>
+      <div class="flex-auto flex justify-center items-center">
+        <div v-if="loading" class="menu-loading">
+          <van-loading size="24px" vertical>加载中...</van-loading>
+        </div>
+        <van-empty v-else-if="!menus.length" class="menu-empty" description="请联系管理员添加菜单" style="" />
+        <van-grid v-else class="flex-auto max-h-[60vh] overflow-y-auto" direction="horizontal" :column-num="1" clickable gutter="24">
+          <van-grid-item v-for="menu in menus" :key="menu.id" :icon="menu.meta.icon" :text="menu.meta.title" v-start="extra" @click="handle(menu)" />
+        </van-grid>
+        <van-action-sheet v-bind="menuActionSheetProps" v-model:show="menuActionSheetProps.show" />
+      </div>
+      <div class="flex-none text-xl p-2 text-center tracking-widest" v-html="copyright"></div>
+    </div>
+    <Password />
+  </div>
+</template>
+
+<style scoped lang="scss">
+.menu-loading {
+  --van-loading-text-font-size: 24px;
+  --van-loading-spinner-color: hsl(var(--primary-hover) / 1);
+  --van-loading-text-color: hsl(var(--primary-hover) / 0.5);
+}
+.menu-empty {
+  --van-empty-description-font-size: 24px;
+  --van-empty-description-color: hsl(var(--primary-hover) / 0.5);
+}
+.van-grid {
+  --van-grid-item-text-font-size: 1.25rem;
+}
+</style>

+ 67 - 0
src/pages/login.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { useRequest, useSerialRequest } from 'alova/client';
+import { getApplicationMethod } from '@/request/api';
+import { getAccountInfo, login } from '@/request/api/manage';
+import { useAccountStore } from '@/stores';
+import { useUpdatePassword } from '@/modules/system/useUpdatePassword';
+
+const { data } = useRequest(getApplicationMethod, { initialData: { image: { copyright: '' } } });
+const title = computed(() => data.value.image.title || import.meta.env.SIX_APP_TITLE);
+const copyright = computed(() => data.value.copyright);
+
+const resetPassword = ref(false);
+
+const router = useRouter();
+const Account = useAccountStore();
+const model = ref({ account: '', password: '' });
+const { loading, send } = useSerialRequest([(model) => login(model), (token) => getAccountInfo(token)], { immediate: false }).onSuccess(async ({ data }) => {
+  const [token, user] = data;
+  resetPassword.value = user?.resetPassword ?? false;
+  if (resetPassword.value) {
+    while (true) {
+      if (!await tips()) break;
+      if (await password.open({ title: `设置一个新密码`, pid: user.id, token })) {
+        resetPassword.value = false;
+        return;
+      }
+    }
+  }
+  Account.login(...data);
+  await router.push({ name: 'screen', replace: true });
+});
+
+const tips = () => {
+  model.value.password = '';
+  return showConfirmDialog({ title: '您的密码为初始密码请及时修改', confirmButtonText: '修改', cancelButtonText: '忽略', cancelButtonColor: 'red' })
+    .then(() => true)
+    .catch(() => false);
+}
+
+const [Password, password] = useUpdatePassword();
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-content flex flex-col">
+      <div class="flex-none h-[106px]">
+        <div style="min-height: 68px"></div>
+        <div class="h-full flex justify-center items-center text-4xl" style="letter-spacing: 0.2em">{{ title }}</div>
+      </div>
+      <div class="flex-auto flex justify-center items-center">
+        <van-form v-if="!resetPassword" ref="form" @submit="send(model)" :readonly="loading">
+          <van-cell-group inset>
+            <van-field v-model="model.account" type="text" name="account" label="账号" placeholder="请输入账号" :rules="[{ required: true, message: '请输入账号' }]" />
+            <van-field v-model="model.password" type="password" name="password" label="密码" placeholder="请输入密码" :rules="[{ required: true, message: '请输入密码' }]" />
+          </van-cell-group>
+          <div style="margin: 16px">
+            <van-button round block type="primary" native-type="submit" :loading> 登录 </van-button>
+          </div>
+        </van-form>
+      </div>
+      <div class="flex-none text-xl p-2 text-center tracking-widest" v-html="copyright"></div>
+    </div>
+    <Password />
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 3 - 0
src/request/alova.ts

@@ -23,6 +23,9 @@ export default createAlova({
         let result = await response.json();
         if (method.url.startsWith(`${location.origin}${import.meta.env.BASE_URL}database`)) result = { code: 0, data: result };
         /* 接口修正 code */
+        if ( result.ResultCode != null && result.code == null ) result.code = result.ResultCode;
+        if ( result.Data && result.data == null ) result.data = result.Data;
+        if ( result.ResultInfo && result.msg == null ) result.msg = result.ResultInfo;
         if ( result.success === true || result.code === 200 ) result.code = 0;
         const { success = false, code = success ? 0 : -1, data, msg: message = '未知错误', ...props } = result;
         if ( code === 0 ) { return data; }

+ 52 - 0
src/request/api/manage.ts

@@ -0,0 +1,52 @@
+import type { MenuModel, UserModel } from '@/request/model/manage.model';
+
+import HTTP from '@/request/alova';
+import { cacheFor } from '@/request/api/index';
+import { fromMenusData, fromUserData } from '@/request/model/manage.model';
+
+export function login(data: { account: string; password: string }) {
+  return HTTP.Post<string, any>(
+    `/login`,
+    { username: data.account, password: data.password },
+    {
+      meta: { ignoreToken: true },
+      transform(data) {
+        return data.token;
+      },
+    }
+  );
+}
+
+export function getAccountInfo(token: string) {
+  return HTTP.Get<[string, UserModel], any>(`/getInfo`, {
+    headers: {
+      Authorization: token,
+    },
+    transform(data: any) {
+      return [token, fromUserData(data)];
+    },
+  });
+}
+
+export function getMenus() {
+  return HTTP.Get<MenuModel[], any>(`/warrantManage/getMenus`, {
+    transform(data) {
+      const menus = typeof data === 'string' ? JSON.parse(data) : data;
+      return fromMenusData(menus)!;
+    },
+    shareRequest: true,
+    cacheFor,
+  });
+}
+
+export function updateUserPassword(data: { password: string; again: string; pid: string | number }, token?: string) {
+  return HTTP.Post(
+    `/portal/userMgr/UpdatePassWord`,
+    {
+      userPid: data.pid,
+      password: data.password,
+      newPassword: data.again,
+    },
+    { headers: { token } }
+  );
+}

+ 209 - 0
src/request/model/manage.model.ts

@@ -0,0 +1,209 @@
+// ---------------------------------------------------------------------------
+// 角色 / 用户
+// ---------------------------------------------------------------------------
+
+export interface RoleModel {
+  id: string;
+  name: string;
+  menus: string[];
+}
+/**
+ * 将接口返回的角色数据转为前端 `RoleModel`。
+ *
+ * @param data - 原始接口对象(含 `pid`、`rolename`、`menuIds` 等)
+ * @returns 规范化后的角色模型
+ */
+export function fromRoleData(data: Record<string, any>): RoleModel {
+  return {
+    id: data.pid,
+    name: data.rolename,
+    menus: Array.isArray(data.menuIds) ? data.menuIds : [],
+  };
+}
+
+export interface UserModel {
+  id: string;
+  name: string;
+  account: string;
+  roles: RoleModel[];
+  /**
+   * 需要重置密码
+   */
+  resetPassword?: boolean;
+}
+/**
+ * 将接口返回的用户数据转为前端 `UserModel`。
+ *
+ * @param data - 原始接口对象(含 `pid`、`username`、`userid`、`roles`、`isFirst` 等)
+ * @returns 规范化后的用户模型
+ */
+export function fromUserData(data: Record<string, any>): UserModel {
+  return {
+    id: data.pid,
+    name: data.username,
+    account: data.userid,
+    roles: Array.isArray(data.roles) ? data.roles.map(fromRoleData) : [],
+    resetPassword: data.isFirst === 0,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 菜单树(类型、展平、按 id 查找 / 过滤)
+// ---------------------------------------------------------------------------
+
+export interface MenuModel {
+  id: string;
+  label: string;
+  path: string;
+  meta: {
+    title?: string;
+    icon?: string;
+  };
+  children?: MenuModel[];
+}
+/**
+ * 将接口返回的菜单树转为前端 `MenuModel[]`,并生成面包屑式 `label`、按 `meta.order` 排序。
+ *
+ * @param menus - 接口菜单数组;根调用时可不传 `parent`
+ * @param parent - 递归用父节点,用于拼接 `label`
+ * @returns 根调用返回菜单树;子调用无数据时返回 `undefined` 以便省略空 `children`
+ */
+export function fromMenusData(menus?: Record<string, any>[], parent?: MenuModel): MenuModel[] | undefined {
+  if (!Array.isArray(menus) || menus.length === 0) return parent ? void 0 : [];
+  return menus
+    .sort((a, b) => (a.meta?.order ?? 0) - (b.meta?.order ?? 0))
+    .map((item) => {
+      item.meta ??= {};
+      const menu = { ...item, label: [parent?.label, item.meta?.title].filter(Boolean).join(' / ') } as MenuModel;
+      menu.children = fromMenusData(item.children, menu);
+      return menu;
+    });
+}
+
+/**
+ * 展平后的叶子菜单项:`parents` 为从根到直接父级的祖先链(不含自身)。
+ */
+export type MenuLeafFlat = Omit<MenuModel, 'children'> & {
+  parents: MenuModel[];
+};
+/**
+ * 从菜单树中取出所有叶子节点(无 `children` 或 `children` 为空),按深度优先顺序展平,并附带祖先链。
+ *
+ * @param menus - 根级菜单数组
+ * @returns 叶子项列表,每项带 `parents` 祖先路径
+ */
+export function flattenMenuLeaves(menus: MenuModel[]): MenuLeafFlat[] {
+  const walk = (nodes: MenuModel[], ancestors: MenuModel[]): MenuLeafFlat[] =>
+    nodes.flatMap((node) => {
+      // 非叶子:继续向下 DFS,当前节点入栈作为子树的祖先
+      if (node.children?.length) {
+        return walk(node.children, [...ancestors, node]);
+      }
+      const { children: _c, ...leaf } = node;
+      return [{ ...leaf, parents: [...ancestors] }];
+    });
+  return walk(menus, []);
+}
+
+/**
+ * 在菜单树中按 id 列表查找节点,返回**扁平数组**。
+ *
+ * - 结果顺序与入参 `ids` 一致。
+ * - 树中不存在的 id 会被跳过,不会占位。
+ * - 树内同一 `id` 出现多次时,取 DFS **先遇到**的节点引用。
+ *
+ * @param menus - 完整菜单树
+ * @param ids - 待查找的菜单 id 列表
+ * @returns 命中的 `MenuModel` 数组(保持 `ids` 顺序)
+ */
+export function findMenusByIds(menus: MenuModel[], ids: string[]): MenuModel[] {
+  if (ids.length === 0) return [];
+  // DFS 建 id → 节点表;同一 id 多次出现时保留先遇到的引用
+  const byId = new Map<string, MenuModel>();
+  const walk = (nodes: MenuModel[]) => {
+    for (const node of nodes) {
+      if (!byId.has(node.id)) byId.set(node.id, node);
+      if (node.children?.length) walk(node.children);
+    }
+  };
+  walk(menus);
+  return ids.map((id) => byId.get(id)).filter((n): n is MenuModel => n != null);
+}
+
+/**
+ * 按 id 集合过滤菜单树,**保持树形结构**(浅拷贝节点,不修改入参树)。
+ *
+ * 流程分三步:
+ * 1. **标记**:DFS 收集应保留的节点 id(可选把祖先 id 一并加入)。
+ * 2. **裁剪**:只克隆 `keep` 集合中的节点及其保留子分支。
+ * 3. **可选收缩**:`options.collapseSingleChild` 为 true 时去掉「仅有一个子节点」的中间层。
+ *
+ * @param menus - 完整菜单树
+ * @param ids - 权限或配置中给出的菜单 id(可仅为叶子 id)
+ * @param options - 可选行为配置
+ * @param options.includeAncestors - `true`(默认)时,任一 id 命中会把从根到该节点的父级链一并保留,得到完整子树;`false` 时只保留 id 直接命中的节点(父级不在 `ids` 中则不会出现,可能多根或零散)。
+ * @param options.collapseSingleChild - `true` 时,过滤后若节点只剩一个子节点则用子顶替父,可连续收缩至分叉或叶子;`false`(默认)保留原层级。
+ * @returns 新的菜单树根数组;`ids` 为空时返回 `[]`
+ */
+export function filterMenuTreeByIds(
+  menus: MenuModel[],
+  ids?: string[],
+  options?: { includeAncestors?: boolean; collapseSingleChild?: boolean },
+): MenuModel[] {
+  const idSet = new Set(ids?.filter(Boolean) ?? []);
+  if (idSet.size === 0) return [];
+
+  const includeAncestors = options?.includeAncestors ?? true;
+  const collapseSingleChild = options?.collapseSingleChild ?? false;
+  /** 裁剪阶段应保留的节点 id */
+  const keep = new Set<string>();
+
+  /** 阶段一:根据命中 id 填充 `keep` */
+  const mark = (nodes: MenuModel[], ancestors: MenuModel[]) => {
+    for (const node of nodes) {
+      if (idSet.has(node.id)) {
+        keep.add(node.id);
+        // 侧栏等场景:子节点被授权时,父级占位也要保留
+        if (includeAncestors) {
+          for (const a of ancestors) keep.add(a.id);
+        }
+      }
+      if (node.children?.length) mark(node.children, [...ancestors, node]);
+    }
+  };
+  mark(menus, []);
+
+  /** 阶段二:仅克隆 keep 内的子树 */
+  const prune = (nodes: MenuModel[]): MenuModel[] => {
+    const out: MenuModel[] = [];
+    for (const node of nodes) {
+      if (!keep.has(node.id)) continue;
+      const nextChildren = node.children?.length ? prune(node.children) : undefined;
+      out.push({
+        ...node,
+        children: nextChildren?.length ? nextChildren : undefined,
+      });
+    }
+    return out;
+  };
+
+  const pruned = prune(menus);
+  if (!collapseSingleChild) return pruned;
+
+  /**
+   * 阶段三:子树内若存在「单子父链」,自下而上递归折叠后,再用 while 吃掉残余单层包裹。
+   */
+  const collapse = (node: MenuModel): MenuModel => {
+    const nextChildren = node.children?.length ? node.children.map(collapse) : undefined;
+    let n: MenuModel = {
+      ...node,
+      children: nextChildren?.length ? nextChildren : undefined,
+    };
+    while (n.children?.length === 1) {
+      n = n.children[0];
+    }
+    return n;
+  };
+
+  return pruned.map(collapse);
+}

+ 12 - 0
src/router/guard/account.guard.ts

@@ -0,0 +1,12 @@
+import type { NavigationGuard } from 'vue-router';
+import { getPinia, useAccountStore } from '@/stores';
+
+export const accountGuard: NavigationGuard = (to) => {
+  if (!to.meta.account) return;
+  if (to.name === 'forbidden') return;
+
+  const { token } = useAccountStore(getPinia());
+  if (token) return;
+
+  return { name: 'forbidden', replace: true, query: { redirect: to.fullPath } };
+};

+ 5 - 0
src/router/index.ts

@@ -1,8 +1,10 @@
 import { createRouter, createWebHistory } from 'vue-router';
+import { accountGuard } from '@/router/guard/account.guard';
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
+    { path: '/login', name: 'login', component: () => import('@/pages/login.vue') },
     { path: '/screen', name: 'screen', component: () => import('@/pages/screen.page.vue'), meta: { scan: true } },
     { path: '/register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档', scan: true } },
     { path: '/pulse', component: () => import('@/modules/pulse/pulse.page.vue'), meta: { title: '脉诊' } },
@@ -16,10 +18,13 @@ const router = createRouter({
     { path: '/report/:id(\\w+)?', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
     { path: '/scheme/:id(\\w+)?', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: false } },
     { path: '/crossing/:flow(\\w+)?', component: () => import('@/pages/crossing.page.vue') },
+    { path: '/forbidden', name: 'forbidden', component: () => import('@/router/pages/forbidden.page.vue') },
     { path: '/', redirect: '/screen' },
   ],
 });
 
+router.beforeEach(accountGuard);
+
 export default router;
 export const flow_start = 'start';
 export const flow_router = {

+ 39 - 0
src/router/pages/forbidden.page.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import { useAccountStore } from '@/stores';
+
+const router = useRouter();
+const Account = useAccountStore();
+
+const logout = () => {
+  Account.logout();
+  router.replace('/login');
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <div class="page-header flex py-4 px-4 overflow-hidden">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden"></div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页">
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col justify-center items-center px-6" style="min-height: 60vh">
+      <van-empty image="error" description="您暂无权限访问该页面,请先登录" />
+      <div class="flex flex-col gap-4 w-full max-w-xs mt-4">
+        <van-button round block type="default" @click="logout"> 退出 </van-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  --van-empty-description-font-size: 24px;
+  --van-empty-description-color: hsl(var(--primary-hover) / 0.5);
+}
+</style>

+ 25 - 1
src/stores/account.store.ts

@@ -1,12 +1,36 @@
+import type { MenuModel, UserModel } from '@/request/model/manage.model';
 import { defineStore } from 'pinia';
+import { useWatcher } from 'alova/client';
+import { getMenus } from '@/request/api/manage';
+import { filterMenuTreeByIds } from '@/request/model/manage.model';
 
 
 export const useAccountStore = defineStore('account', () => {
   const token = ref<string>();
+  const user = shallowRef<UserModel>();
+  const menus = shallowRef<MenuModel[]>([]);
+  const { loading } = useWatcher(getMenus, [token, user], {
+    middleware(_, next) {
+      if (token.value && user.value?.roles?.length) return next();
+    },
+    immediate: true,
+  }).onSuccess(({ data }) => {
+    menus.value = filterMenuTreeByIds(
+      data,
+      user.value?.roles?.flatMap((role) => role.menus),
+      { collapseSingleChild: true }
+    );
+  });
 
   const $reset = () => {
     token.value = '';
+    user.value = void 0;
   };
 
-  return { token, $reset };
+  const login = (value: string, data: UserModel) => {
+    token.value = value;
+    user.value = data;
+  };
+
+  return { token, user, loading, menus, $reset, login, logout: $reset };
 });