weibo.xia 1 mese fa
parent
commit
2c34f112a4
38 ha cambiato i file con 2222 aggiunte e 13 eliminazioni
  1. 5 0
      apps/webB-velofex/.env
  2. 7 0
      apps/webB-velofex/.env.analyze
  3. 16 0
      apps/webB-velofex/.env.development
  4. 19 0
      apps/webB-velofex/.env.production
  5. 22 0
      apps/webB-velofex/index.html
  6. 40 0
      apps/webB-velofex/package.json
  7. 1 0
      apps/webB-velofex/postcss.config.mjs
  8. BIN
      apps/webB-velofex/public/favicon.ico
  9. 168 0
      apps/webB-velofex/src/adapter/component/index.ts
  10. 47 0
      apps/webB-velofex/src/adapter/form.ts
  11. 67 0
      apps/webB-velofex/src/adapter/vxe-table.ts
  12. 150 0
      apps/webB-velofex/src/api/account.ts
  13. 64 0
      apps/webB-velofex/src/api/request.ts
  14. 39 0
      apps/webB-velofex/src/app.vue
  15. BIN
      apps/webB-velofex/src/assets/image/Polygon 2.png
  16. BIN
      apps/webB-velofex/src/assets/image/earth_18301626@2x.png
  17. BIN
      apps/webB-velofex/src/assets/image/user.png
  18. 190 0
      apps/webB-velofex/src/bootstrap.ts
  19. 71 0
      apps/webB-velofex/src/components/select-lang.vue
  20. 3 0
      apps/webB-velofex/src/locales/README.md
  21. 112 0
      apps/webB-velofex/src/locales/index.ts
  22. 8 0
      apps/webB-velofex/src/locales/langs/en-US/page.json
  23. 8 0
      apps/webB-velofex/src/locales/langs/zh-CN/page.json
  24. 55 0
      apps/webB-velofex/src/main.ts
  25. 18 0
      apps/webB-velofex/src/preferences.ts
  26. 53 0
      apps/webB-velofex/src/router/guard.ts
  27. 20 0
      apps/webB-velofex/src/router/index.ts
  28. 24 0
      apps/webB-velofex/src/router/routes.ts
  29. 62 0
      apps/webB-velofex/src/styles/global.scss
  30. 9 0
      apps/webB-velofex/src/utils/auth.ts
  31. 71 0
      apps/webB-velofex/src/views/error.vue
  32. 740 0
      apps/webB-velofex/src/views/home.vue
  33. 1 0
      apps/webB-velofex/tailwind.config.mjs
  34. 13 0
      apps/webB-velofex/tsconfig.json
  35. 10 0
      apps/webB-velofex/tsconfig.node.json
  36. 30 0
      apps/webB-velofex/vite.config.mts
  37. 75 13
      pnpm-lock.yaml
  38. 4 0
      vben-admin.code-workspace

+ 5 - 0
apps/webB-velofex/.env

@@ -0,0 +1,5 @@
+# 应用标题
+VITE_APP_TITLE=VELOFEX
+
+# 应用命名空间,用于缓存、store等功能的前缀,确保隔离 AB端相同缓存
+VITE_APP_NAMESPACE=velofex-web

+ 7 - 0
apps/webB-velofex/.env.analyze

@@ -0,0 +1,7 @@
+# public path
+VITE_BASE=/
+
+# Basic interface address SPA
+VITE_GLOB_API_URL=/api
+
+VITE_VISUALIZER=true

+ 16 - 0
apps/webB-velofex/.env.development

@@ -0,0 +1,16 @@
+# 端口号
+VITE_PORT=5666
+
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=/api
+
+# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
+VITE_NITRO_MOCK=true
+
+# 是否打开 devtools,true 为打开,false 为关闭
+VITE_DEVTOOLS=false
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true

+ 19 - 0
apps/webB-velofex/.env.production

@@ -0,0 +1,19 @@
+VITE_BASE=./
+
+# 接口地址
+VITE_GLOB_API_URL=
+
+# 是否开启压缩,可以设置为 none, brotli, gzip
+VITE_COMPRESS=none
+
+# 是否开启 PWA
+VITE_PWA=false
+
+# vue-router 的模式
+VITE_ROUTER_HISTORY=hash
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true
+
+# 打包后是否生成dist.zip
+VITE_ARCHIVER=true

+ 22 - 0
apps/webB-velofex/index.html

@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="zh">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <meta name="renderer" content="webkit" />
+    <meta name="description" content="A Modern Back-end Management System" />
+    <meta name="keywords" content="Vben Admin Vue3 Vite" />
+    <meta name="author" content="Vben" />
+    <meta
+      name="viewport"
+      content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
+    />
+    <!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
+    <title><%= VITE_APP_TITLE %></title>
+    <link rel="icon" href="/favicon.ico" />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 40 - 0
apps/webB-velofex/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "@vben/web-b-velofex",
+  "version": "5.5.2",
+  "homepage": "https://vben.pro",
+  "license": "MIT",
+  "type": "module",
+  "scripts": {
+    "build": "pnpm vite build --mode production",
+    "build:analyze": "pnpm vite build --mode analyze",
+    "dev": "pnpm vite --mode development",
+    "preview": "vite preview",
+    "typecheck": "vue-tsc --noEmit --skipLibCheck"
+  },
+  "imports": {
+    "#/*": "./src/*"
+  },
+  "dependencies": {
+    "@vben/access": "workspace:*",
+    "@vben/common-ui": "workspace:*",
+    "@vben/constants": "workspace:*",
+    "@vben/hooks": "workspace:*",
+    "@vben/icons": "workspace:*",
+    "@vben/layouts": "workspace:*",
+    "@vben/locales": "workspace:*",
+    "@vben/plugins": "workspace:*",
+    "@vben/preferences": "workspace:*",
+    "@vben/request": "workspace:*",
+    "@vben/stores": "workspace:*",
+    "@vben/styles": "workspace:*",
+    "@vben/types": "workspace:*",
+    "@vben/utils": "workspace:*",
+    "@vueuse/core": "catalog:",
+    "antdv-next": "catalog:",
+    "crypto-js": "^4.2.0",
+    "dayjs": "catalog:",
+    "pinia": "catalog:",
+    "vue": "catalog:",
+    "vue-router": "catalog:"
+  }
+}

+ 1 - 0
apps/webB-velofex/postcss.config.mjs

@@ -0,0 +1 @@
+export { default } from '@vben/tailwind-config/postcss';

BIN
apps/webB-velofex/public/favicon.ico


+ 168 - 0
apps/webB-velofex/src/adapter/component/index.ts

@@ -0,0 +1,168 @@
+/**
+ * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
+ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
+ */
+
+import type { BaseFormComponentType } from '@vben/common-ui';
+
+import type { Component, SetupContext } from 'vue';
+import { h } from 'vue';
+
+import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import {
+  AutoComplete,
+  Button,
+  Checkbox,
+  CheckboxGroup,
+  DatePicker,
+  DateRangePicker,
+  Divider,
+  Input,
+  InputNumber,
+  InputPassword,
+  Mentions,
+  notification,
+  Radio,
+  RadioGroup,
+  Rate,
+  Select,
+  Space,
+  Switch,
+  TextArea,
+  TimePicker,
+  TreeSelect,
+  Upload,
+} from 'antdv-next';
+
+const withDefaultPlaceholder = <T extends Component>(
+  component: T,
+  type: 'input' | 'select',
+) => {
+  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
+    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
+    return h(component, { ...props, ...attrs, placeholder }, slots);
+  };
+};
+
+// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
+export type ComponentType =
+  | 'ApiSelect'
+  | 'ApiTreeSelect'
+  | 'AutoComplete'
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'DateRangePicker'
+  | 'DefaultButton'
+  | 'Divider'
+  | 'IconPicker'
+  | 'Input'
+  | 'InputNumber'
+  | 'InputPassword'
+  | 'Mentions'
+  | 'PrimaryButton'
+  | 'Radio'
+  | 'RadioGroup'
+  | 'Rate'
+  | 'Select'
+  | 'Space'
+  | 'Switch'
+  | 'TextArea'
+  | 'TimePicker'
+  | 'TreeSelect'
+  | 'Upload'
+  | BaseFormComponentType;
+
+async function initComponentAdapter() {
+  const components: Partial<Record<ComponentType, Component>> = {
+    // 如果你的组件体积比较大,可以使用异步加载
+    // Button: () =>
+    // import('xxx').then((res) => res.Button),
+    ApiSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiComponent,
+        {
+          placeholder: $t('ui.placeholder.select'),
+          ...props,
+          ...attrs,
+          component: Select,
+          loadingSlot: 'suffixIcon',
+          visibleEvent: 'onDropdownVisibleChange',
+          modelPropName: 'value',
+        },
+        slots,
+      );
+    },
+    ApiTreeSelect: (props, { attrs, slots }) => {
+      return h(
+        ApiComponent,
+        {
+          placeholder: $t('ui.placeholder.select'),
+          ...props,
+          ...attrs,
+          component: TreeSelect,
+          fieldNames: { label: 'label', value: 'value', children: 'children' },
+          loadingSlot: 'suffixIcon',
+          modelPropName: 'value',
+          optionsPropName: 'treeData',
+          visibleEvent: 'onVisibleChange',
+        },
+        slots,
+      );
+    },
+    AutoComplete,
+    Checkbox,
+    CheckboxGroup,
+    DatePicker,
+    // 自定义默认按钮
+    DefaultButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'default' }, slots);
+    },
+    Divider,
+    IconPicker: (props, { attrs, slots }) => {
+      return h(
+        IconPicker,
+        { iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs },
+        slots,
+      );
+    },
+    Input: withDefaultPlaceholder(Input, 'input'),
+    InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
+    InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
+    Mentions: withDefaultPlaceholder(Mentions, 'input'),
+    // 自定义主要按钮
+    PrimaryButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'primary' }, slots);
+    },
+    Radio,
+    RadioGroup,
+    DateRangePicker,
+    Rate,
+    Select: withDefaultPlaceholder(Select, 'select'),
+    Space,
+    Switch,
+    Textarea: withDefaultPlaceholder(TextArea, 'input'),
+    TimePicker,
+    TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
+    Upload,
+  };
+
+  // 将组件注册到全局共享状态中
+  globalShareState.setComponents(components);
+
+  // 定义全局共享状态中的消息提示
+  globalShareState.defineMessage({
+    // 复制成功消息提示
+    copyPreferencesSuccess: (title, content) => {
+      notification.success({
+        description: content,
+        title,
+        placement: 'bottomRight',
+      });
+    },
+  });
+}
+
+export { initComponentAdapter };

+ 47 - 0
apps/webB-velofex/src/adapter/form.ts

@@ -0,0 +1,47 @@
+import type {
+  VbenFormSchema as FormSchema,
+  VbenFormProps,
+} from '@vben/common-ui';
+
+import type { ComponentType } from './component';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+setupVbenForm<ComponentType>({
+  config: {
+    // ant design vue组件库默认都是 v-model:value
+    baseModelPropName: 'value',
+
+    // 一些组件是 v-model:checked 或者 v-model:fileList
+    modelPropNameMap: {
+      Checkbox: 'checked',
+      Radio: 'checked',
+      Switch: 'checked',
+      Upload: 'fileList',
+    },
+  },
+  defineRules: {
+    // 输入项目必填国际化适配
+    required: (value, _params, ctx) => {
+      if (value === undefined || value === null || value.length === 0) {
+        return $t('ui.formRules.required', [ctx.label]);
+      }
+      return true;
+    },
+    // 选择项目必填国际化适配
+    selectRequired: (value, _params, ctx) => {
+      if (value === undefined || value === null) {
+        return $t('ui.formRules.selectRequired', [ctx.label]);
+      }
+      return true;
+    },
+  },
+});
+
+const useVbenForm = useForm<ComponentType>;
+
+export { useVbenForm, z };
+
+export type VbenFormSchema = FormSchema<ComponentType>;
+export type { VbenFormProps };

+ 67 - 0
apps/webB-velofex/src/adapter/vxe-table.ts

@@ -0,0 +1,67 @@
+import { h } from 'vue';
+
+import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
+
+import { Button, Image } from 'antdv-next';
+
+import { useVbenForm } from './form';
+
+setupVbenVxeTable({
+  configVxeTable: (vxeUI) => {
+    vxeUI.setConfig({
+      grid: {
+        align: 'center',
+        border: false,
+        columnConfig: {
+          resizable: true,
+        },
+        minHeight: 180,
+        formConfig: {
+          // 全局禁用vxe-table的表单配置,使用formOptions
+          enabled: false,
+        },
+        proxyConfig: {
+          autoLoad: true,
+          response: {
+            result: 'items',
+            total: 'total',
+            list: 'items',
+          },
+          showActiveMsg: true,
+          showResponseMsg: false,
+        },
+        round: true,
+        showOverflow: true,
+        size: 'small',
+      },
+    });
+
+    // 表格配置项可以用 cellRender: { name: 'CellImage' },
+    vxeUI.renderer.add('CellImage', {
+      renderTableDefault(_renderOpts, params) {
+        const { column, row } = params;
+        return h(Image, { src: row[column.field] });
+      },
+    });
+
+    // 表格配置项可以用 cellRender: { name: 'CellLink' },
+    vxeUI.renderer.add('CellLink', {
+      renderTableDefault(renderOpts) {
+        const { props } = renderOpts;
+        return h(
+          Button,
+          { size: 'small', type: 'link' },
+          { default: () => props?.text },
+        );
+      },
+    });
+
+    // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
+    // vxeUI.formats.add
+  },
+  useVbenForm,
+});
+
+export { useVbenVxeGrid };
+
+export type * from '@vben/plugins/vxe-table';

+ 150 - 0
apps/webB-velofex/src/api/account.ts

@@ -0,0 +1,150 @@
+import { requestClient } from './request';
+
+interface ChangeLanguagePayload {
+  langId: 0 | 1;
+}
+
+export interface curUserInfoPayload {
+  account: string;
+  avatarFileId: string;
+  cellPhone: string;
+  employeeNumber: string;
+  enterpriseName: string;
+  id: string;
+  isLoginToB: boolean;
+  isOut: number;
+  isSuperAdmin: boolean;
+  langName: string;
+  language: string;
+  name: string;
+  nickName: string;
+  telephone: string;
+  avatar?: string;
+}
+
+interface CurUserInfoResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: curUserInfoPayload;
+}
+interface Ts {
+  value: string;
+}
+interface SubMenuList {
+  bindLink: boolean;
+  code: string;
+  deleted: boolean;
+  enableApply: boolean;
+  enableMobile: boolean;
+  fullName: string;
+  fullPath: string;
+  icon: string;
+  iconClass: string;
+  iconColor?: string;
+  id: string;
+  isBindLink: boolean;
+  isDeleted: boolean;
+  isFavourite: boolean;
+  isManuallyCreate: boolean;
+  langName: string;
+  languageCulture: string;
+  link: string;
+  linkType: number;
+  manuallyCreate: boolean;
+  menuDepth: number;
+  menuIndex: number;
+  menuType: number;
+  name: string;
+  openType: number;
+  parentId: string;
+  subMenuList: any[];
+  target: string;
+  ts: Ts;
+  remark?: string;
+}
+interface SubMenuList2 {
+  bindLink: boolean;
+  code: string;
+  deleted: boolean;
+  enableApply: boolean;
+  enableMobile: boolean;
+  fullName: string;
+  fullPath: string;
+  icon: string;
+  iconClass: string;
+  iconColor: string;
+  id: string;
+  isBindLink: boolean;
+  isDeleted: boolean;
+  isFavourite: boolean;
+  isManuallyCreate: boolean;
+  langName: string;
+  languageCulture: string;
+  link: string;
+  linkType: number;
+  manuallyCreate: boolean;
+  menuDepth: number;
+  menuIndex: number;
+  menuType: number;
+  name: string;
+  openType: number;
+  parentId: string;
+  subMenuList: SubMenuList[];
+  target: string;
+  ts: Ts;
+}
+
+interface leftMenuResult {
+  bindLink: boolean;
+  code: string;
+  deleted: boolean;
+  enableApply: boolean;
+  enableMobile: boolean;
+  fullName: string;
+  fullPath: string;
+  icon: string;
+  iconClass: string;
+  iconColor: string;
+  id: string;
+  isBindLink: boolean;
+  isDeleted: boolean;
+  isFavourite: boolean;
+  isManuallyCreate: boolean;
+  langName: string;
+  languageCulture: string;
+  link: string;
+  linkType: number;
+  manuallyCreate: boolean;
+  menuDepth: number;
+  menuIndex: number;
+  menuType: number;
+  name: string;
+  openType: number;
+  parentId: string;
+  remark: string;
+  subMenuList: SubMenuList2[];
+  target: string;
+  ts: Ts;
+}
+interface leftMenuResponse {
+  isSuccess: boolean;
+  code: number;
+  result: leftMenuResult;
+  isAuthorized: boolean;
+}
+
+// 切换语言接口
+export async function changeLanguageApi(payload: ChangeLanguagePayload) {
+  return requestClient.post('/api/account/v2/doChangeLanguage', payload);
+}
+
+// 获取当前用户信息接口
+export async function curUserInfo() {
+  return requestClient.get<CurUserInfoResponse>('/api/account/curUserInfo');
+}
+
+// 获取左侧菜单列表接口
+export async function leftMenuListFromB() {
+  return requestClient.get<leftMenuResponse>('/api/menu/leftMenuListFromB');
+}

+ 64 - 0
apps/webB-velofex/src/api/request.ts

@@ -0,0 +1,64 @@
+import { useAppConfig } from '@vben/hooks';
+import { RequestClient } from '@vben/request';
+
+const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+
+function getEnterpriseCode() {
+  if (typeof window === 'undefined') {
+    return '';
+  }
+
+  const searchParams = new URLSearchParams(window.location.search);
+  const direct = (searchParams.get('enterpriseCode') ?? '').trim();
+  if (direct) {
+    return direct;
+  }
+
+  // Hash 路由场景: #/path?enterpriseCode=xxx
+  const hash = window.location.hash ?? '';
+  const idx = hash.indexOf('?');
+  if (idx !== -1) {
+    const hashParams = new URLSearchParams(hash.slice(idx + 1));
+    return (hashParams.get('enterpriseCode') ?? '').trim();
+  }
+  return '';
+}
+
+function getEnterpriseToken() {
+  const enterpriseCode = getEnterpriseCode();
+  if (!enterpriseCode) {
+    return '';
+  }
+  return localStorage.getItem(`token_${enterpriseCode}`) ?? '';
+}
+
+const requestClient = new RequestClient({
+  baseURL: apiURL,
+});
+
+requestClient.addRequestInterceptor({
+  fulfilled: async (config) => {
+    const url = config.url ?? '';
+    const baseURL = config.baseURL ?? '';
+    const isEapiRequest =
+      url.startsWith('/eapi') || baseURL.startsWith('/eapi');
+
+    const token = isEapiRequest
+      ? (localStorage.getItem('token_a') ?? '')
+      : getEnterpriseToken();
+
+    if (token) {
+      config.headers = config.headers ?? {};
+      config.headers.Authorization = token;
+    }
+    return config;
+  },
+});
+
+requestClient.addResponseInterceptor({
+  fulfilled: async (response) => {
+    return response?.data ?? response;
+  },
+});
+
+export { requestClient };

+ 39 - 0
apps/webB-velofex/src/app.vue

@@ -0,0 +1,39 @@
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import { useAntdDesignTokens } from '@vben/hooks';
+import { preferences, usePreferences } from '@vben/preferences';
+
+import { App, ConfigProvider, theme } from 'antdv-next';
+
+import { antdLocale } from '#/locales';
+
+defineOptions({ name: 'App' });
+
+const { isDark } = usePreferences();
+const { tokens } = useAntdDesignTokens();
+
+const tokenTheme = computed(() => {
+  const algorithm = isDark.value
+    ? [theme.darkAlgorithm]
+    : [theme.defaultAlgorithm];
+
+  // antd 紧凑模式算法
+  if (preferences.app.compact) {
+    algorithm.push(theme.compactAlgorithm);
+  }
+
+  return {
+    algorithm,
+    token: tokens,
+  };
+});
+</script>
+
+<template>
+  <ConfigProvider :locale="antdLocale" :theme="tokenTheme">
+    <App>
+      <RouterView />
+    </App>
+  </ConfigProvider>
+</template>

BIN
apps/webB-velofex/src/assets/image/Polygon 2.png


BIN
apps/webB-velofex/src/assets/image/earth_18301626@2x.png


BIN
apps/webB-velofex/src/assets/image/user.png


+ 190 - 0
apps/webB-velofex/src/bootstrap.ts

@@ -0,0 +1,190 @@
+import { createApp, watch, watchEffect } from 'vue';
+
+import { registerAccessDirective } from '@vben/access';
+import { loadLocaleMessages, type SupportedLanguagesType } from '@vben/locales';
+import { preferences, updatePreferences } from '@vben/preferences';
+import { initStores } from '@vben/stores';
+import '@vben/styles';
+import '@vben/styles/antd';
+
+import { useTitle } from '@vueuse/core';
+
+import { $t, setupI18n } from '#/locales';
+
+import { initComponentAdapter } from './adapter/component';
+import { changeLanguageApi } from './api/account';
+import App from './app.vue';
+import { router } from './router';
+
+import './styles/global.scss';
+
+async function bootstrap(namespace: string) {
+  const localeCacheKey = `${namespace}-preferences-locale`;
+  const userCacheKey = `${namespace}-core-user`;
+
+  const isSupportedLocale = (
+    locale: string,
+  ): locale is SupportedLanguagesType =>
+    locale === 'en-US' || locale === 'zh-CN';
+
+  const parseLocaleValue = (
+    raw: null | string,
+  ): '' | SupportedLanguagesType => {
+    const unwrap = (value: unknown, depth = 0): '' | SupportedLanguagesType => {
+      if (depth > 6 || value === null) {
+        return '';
+      }
+
+      if (typeof value === 'string') {
+        const trimmed = value.trim();
+        if (isSupportedLocale(trimmed)) {
+          return trimmed;
+        }
+        try {
+          return unwrap(JSON.parse(trimmed), depth + 1);
+        } catch {
+          return '';
+        }
+      }
+
+      if (
+        typeof value === 'object' &&
+        'value' in (value as Record<string, unknown>)
+      ) {
+        return unwrap((value as Record<string, unknown>).value, depth + 1);
+      }
+
+      return '';
+    };
+
+    return unwrap(raw);
+  };
+
+  const parseLocaleFromCoreUser = (
+    raw: null | string,
+  ): '' | SupportedLanguagesType => {
+    if (!raw) {
+      return '';
+    }
+
+    try {
+      const parsed = JSON.parse(raw) as Record<string, any>;
+      const userInfo = parsed?.userInfo as Record<string, any> | undefined;
+      const language = String(userInfo?.language ?? '').trim();
+
+      if (isSupportedLocale(language)) {
+        return language;
+      }
+    } catch {
+      return '';
+    }
+
+    return '';
+  };
+
+  // 避免 Invalid value
+  const localeInCache = parseLocaleValue(localStorage.getItem(localeCacheKey));
+  if (localeInCache && localeInCache !== preferences.app.locale) {
+    updatePreferences({ app: { locale: localeInCache } });
+  }
+  if (!isSupportedLocale(preferences.app.locale)) {
+    updatePreferences({ app: { locale: 'en-US' } });
+  }
+
+  await initComponentAdapter();
+
+  const app = createApp(App);
+
+  await setupI18n(app);
+  await initStores(app, { namespace });
+
+  registerAccessDirective(app);
+  app.use(router);
+
+  let syncLock = false;
+  let lastSyncedLocale = '';
+  const syncLanguageToServer = async (locale: SupportedLanguagesType) => {
+    if (locale === lastSyncedLocale) {
+      return;
+    }
+    if (syncLock) {
+      return;
+    }
+    syncLock = true;
+    try {
+      await changeLanguageApi({ langId: locale === 'en-US' ? 1 : 0 });
+      lastSyncedLocale = locale;
+    } catch (error) {
+      console.error('web-b', error);
+    } finally {
+      syncLock = false;
+    }
+  };
+
+  watch(
+    () => preferences.app.locale,
+    (locale, previous) => {
+      if (!isSupportedLocale(locale) || locale === previous) {
+        return;
+      }
+      void syncLanguageToServer(locale);
+    },
+  );
+
+  const applyLocaleFromCache = async (raw: null | string) => {
+    const locale = parseLocaleValue(raw);
+    if (!locale) {
+      return;
+    }
+
+    if (locale !== preferences.app.locale) {
+      updatePreferences({ app: { locale } });
+      await loadLocaleMessages(locale);
+    }
+
+    await syncLanguageToServer(locale);
+  };
+
+  const localeFromCoreUser = parseLocaleFromCoreUser(
+    localStorage.getItem(userCacheKey),
+  );
+  if (
+    localeFromCoreUser &&
+    isSupportedLocale(preferences.app.locale) &&
+    localeFromCoreUser !== preferences.app.locale
+  ) {
+    updatePreferences({ app: { locale: localeFromCoreUser } });
+    await loadLocaleMessages(localeFromCoreUser);
+    await syncLanguageToServer(localeFromCoreUser);
+  }
+
+  window.addEventListener('storage', (event) => {
+    if (event.key !== localeCacheKey) {
+      return;
+    }
+    void applyLocaleFromCache(event.newValue);
+  });
+
+  let lastLocaleSnapshot = localStorage.getItem(localeCacheKey);
+  window.setInterval(() => {
+    const next = localStorage.getItem(localeCacheKey);
+    if (next === lastLocaleSnapshot) {
+      return;
+    }
+    lastLocaleSnapshot = next;
+    void applyLocaleFromCache(next);
+  }, 1000);
+
+  watchEffect(() => {
+    if (preferences.app.dynamicTitle) {
+      const routeTitle = router.currentRoute.value.meta?.title;
+      const pageTitle =
+        (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
+      useTitle(pageTitle);
+    }
+  });
+
+  app.mount('#app');
+}
+
+export { bootstrap };

+ 71 - 0
apps/webB-velofex/src/components/select-lang.vue

@@ -0,0 +1,71 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+
+import { loadLocaleMessages } from '@vben/locales';
+import { preferences, updatePreferences } from '@vben/preferences';
+
+import { Dropdown } from 'antdv-next';
+
+const items = [
+  {
+    key: 'en-US',
+    label: 'English',
+  },
+  {
+    key: 'zh-CN',
+    label: '中文',
+  },
+];
+
+const show = ref(false);
+
+const currentLabel = computed(() => {
+  const selected = items.find((item) => item.key === preferences.app.locale);
+  return selected?.label ?? 'Language';
+});
+
+const onOpenChange = (open: boolean) => {
+  show.value = open;
+};
+
+const onMenuClick = (info: any) => {
+  if (preferences.app.locale === info.key) {
+    return;
+  }
+  updatePreferences({
+    app: {
+      locale: info.key,
+    },
+  });
+
+  loadLocaleMessages(info.key);
+};
+</script>
+
+<template>
+  <Dropdown
+    :menu="{ items }"
+    placement="bottom"
+    @menu-click="onMenuClick"
+    @open-change="onOpenChange"
+  >
+    <div class="flex cursor-pointer items-center gap-2">
+      <img
+        alt="earth"
+        height="20px"
+        src="@/assets/image/earth_18301626@2x.png"
+        width="20px"
+      />
+      <span>{{ currentLabel }}</span>
+      <img
+        :class="show ? 'rotate-180' : ''"
+        alt="polygon"
+        height="6px"
+        src="@/assets/image/Polygon 2.png"
+        width="7px"
+      />
+    </div>
+  </Dropdown>
+</template>
+
+<style scoped></style>

+ 3 - 0
apps/webB-velofex/src/locales/README.md

@@ -0,0 +1,3 @@
+# locale
+
+每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。

+ 112 - 0
apps/webB-velofex/src/locales/index.ts

@@ -0,0 +1,112 @@
+import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
+import type { ConfigProviderProps } from 'antdv-next';
+
+import type { App } from 'vue';
+import { ref } from 'vue';
+
+import {
+  $t,
+  setupI18n as coreSetup,
+  loadLocalesMapFromDir,
+} from '@vben/locales';
+import { preferences } from '@vben/preferences';
+
+import antdEnLocale from 'antdv-next/locale/en_US';
+import antdDefaultLocale from 'antdv-next/locale/zh_CN';
+import dayjs from 'dayjs';
+
+type Locale = ConfigProviderProps['locale'];
+
+const antdLocale = ref<Locale>(antdDefaultLocale);
+
+const modules = import.meta.glob('./langs/**/*.json');
+
+const localesMap = loadLocalesMapFromDir(
+  /\.\/langs\/([^/]+)\/(.*)\.json$/,
+  modules,
+);
+/**
+ * 加载应用特有的语言包
+ * 这里也可以改造为从服务端获取翻译数据
+ * @param lang
+ */
+async function loadMessages(lang: SupportedLanguagesType) {
+  const [appLocaleMessages] = await Promise.all([
+    localesMap[lang]?.(),
+    loadThirdPartyMessage(lang),
+  ]);
+  const pageMessages = appLocaleMessages?.default?.page;
+  if (pageMessages && typeof pageMessages === 'object') {
+    return pageMessages as Record<string, string>;
+  }
+
+  const defaultMessages = appLocaleMessages?.default;
+  if (defaultMessages && typeof defaultMessages === 'object') {
+    return defaultMessages as Record<string, string>;
+  }
+
+  return undefined;
+}
+
+/**
+ * 加载第三方组件库的语言包
+ * @param lang
+ */
+async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
+  await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
+}
+
+/**
+ * 加载dayjs的语言包
+ * @param lang
+ */
+async function loadDayjsLocale(lang: SupportedLanguagesType) {
+  let locale;
+  switch (lang) {
+    case 'en-US': {
+      locale = await import('dayjs/locale/en');
+      break;
+    }
+    case 'zh-CN': {
+      locale = await import('dayjs/locale/zh-cn');
+      break;
+    }
+    // 默认使用英语
+    default: {
+      locale = await import('dayjs/locale/en');
+    }
+  }
+  if (locale) {
+    dayjs.locale(locale);
+  } else {
+    console.error(`Failed to load dayjs locale for ${lang}`);
+  }
+}
+
+/**
+ * 加载antd的语言包
+ * @param lang
+ */
+async function loadAntdLocale(lang: SupportedLanguagesType) {
+  switch (lang) {
+    case 'en-US': {
+      antdLocale.value = antdEnLocale;
+      break;
+    }
+    case 'zh-CN': {
+      antdLocale.value = antdDefaultLocale;
+      break;
+    }
+  }
+}
+
+async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
+  await coreSetup(app, {
+    defaultLocale: preferences.app.locale,
+    loadMessages,
+    missingWarn: !import.meta.env.PROD,
+    ...options,
+  });
+}
+
+export { $t, antdLocale, setupI18n };

+ 8 - 0
apps/webB-velofex/src/locales/langs/en-US/page.json

@@ -0,0 +1,8 @@
+{
+  "home": {
+    "userMenu": {
+      "changeInformation": "Change Information",
+      "logout": "Logout"
+    }
+  }
+}

+ 8 - 0
apps/webB-velofex/src/locales/langs/zh-CN/page.json

@@ -0,0 +1,8 @@
+{
+  "home": {
+    "userMenu": {
+      "changeInformation": "修改个人信息",
+      "logout": "退出"
+    }
+  }
+}

+ 55 - 0
apps/webB-velofex/src/main.ts

@@ -0,0 +1,55 @@
+import { initPreferences } from '@vben/preferences';
+import { unmountGlobalLoading } from '@vben/utils';
+
+import { overridesPreferences } from './preferences';
+
+function mountLegacyIconStyles() {
+  if (typeof document === 'undefined') {
+    return;
+  }
+
+  const styleId = 'webb-legacy-icon-style';
+  const iconBase = import.meta.env.PROD ? '' : 'https://edesign.shalu.com';
+  const href = `${iconBase}/Content/Lib/component/icons/icon.css?v=2.2.0`;
+
+  const existing = document.querySelector<HTMLLinkElement>(`#${styleId}`);
+  if (existing) {
+    existing.href = href;
+    return;
+  }
+
+  const link = document.createElement('link');
+  link.id = styleId;
+  link.rel = 'stylesheet';
+  link.href = href;
+  document.head.append(link);
+}
+
+/**
+ * 应用初始化完成之后再进行页面加载渲染
+ */
+async function initApplication() {
+  mountLegacyIconStyles();
+
+  // name用于指定项目唯一标识
+  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
+  const env = import.meta.env.PROD ? 'prod' : 'dev';
+  const appVersion = import.meta.env.VITE_APP_VERSION;
+  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
+
+  // app偏好设置初始化
+  await initPreferences({
+    namespace,
+    overrides: overridesPreferences,
+  });
+
+  // 启动应用并挂载
+  // vue应用主要逻辑及视图
+  const { bootstrap } = await import('./bootstrap');
+  await bootstrap(namespace);
+
+  // 移除并销毁loading
+  unmountGlobalLoading();
+}
+
+initApplication();

+ 18 - 0
apps/webB-velofex/src/preferences.ts

@@ -0,0 +1,18 @@
+import { defineOverridesPreferences } from '@vben/preferences';
+
+/**
+ * @description 项目配置文件
+ * 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
+ * !!! 更改配置后请清空缓存,否则可能不生效
+ */
+export const overridesPreferences = defineOverridesPreferences({
+  // overrides
+  app: {
+    name: import.meta.env.VITE_APP_TITLE,
+    locale: 'en-US',
+  },
+  theme: {
+    mode: 'light',
+    radius: '0.75',
+  },
+});

+ 53 - 0
apps/webB-velofex/src/router/guard.ts

@@ -0,0 +1,53 @@
+import type { RouteLocationNormalized } from 'vue-router';
+
+import { getAccessToken } from '@/utils/auth';
+
+export function resolveEnterpriseCodeFromLocation() {
+  if (typeof window === 'undefined') {
+    return '';
+  }
+
+  const fromSearch = (
+    new URLSearchParams(window.location.search).get('enterpriseCode') ?? ''
+  ).trim();
+  if (fromSearch) {
+    return fromSearch;
+  }
+
+  const hash = window.location.hash ?? '';
+  const queryIndex = hash.indexOf('?');
+  if (queryIndex === -1) {
+    return '';
+  }
+
+  return (
+    new URLSearchParams(hash.slice(queryIndex + 1)).get('enterpriseCode') ?? ''
+  ).trim();
+}
+
+export function createRouterGuard(to: RouteLocationNormalized) {
+  if (to.path === '/error') {
+    return true;
+  }
+
+  const enterpriseCode = resolveEnterpriseCodeFromLocation();
+
+  if (!enterpriseCode) {
+    return {
+      path: '/error',
+      replace: true,
+    };
+  }
+
+  const token = getAccessToken(enterpriseCode);
+
+  if (!token) {
+    return {
+      path: '/error',
+      query: { enterpriseCode },
+      replace: true,
+    };
+  }
+
+  return true;
+}

+ 20 - 0
apps/webB-velofex/src/router/index.ts

@@ -0,0 +1,20 @@
+import {
+  createRouter,
+  createWebHashHistory,
+  createWebHistory,
+} from 'vue-router';
+
+import { createRouterGuard } from './guard';
+import { routes } from './routes';
+
+const router = createRouter({
+  history:
+    import.meta.env.VITE_ROUTER_HISTORY === 'hash'
+      ? createWebHashHistory(import.meta.env.VITE_BASE)
+      : createWebHistory(import.meta.env.VITE_BASE),
+  routes,
+});
+
+router.beforeEach((to) => createRouterGuard(to));
+
+export { router };

+ 24 - 0
apps/webB-velofex/src/router/routes.ts

@@ -0,0 +1,24 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+const routes: RouteRecordRaw[] = [
+  {
+    path: '/',
+    redirect: '/homePage',
+  },
+  {
+    path: '/homePage',
+    name: 'homePage',
+    component: () => import('@/views/home.vue'),
+  },
+  {
+    path: '/error',
+    name: 'ErrorPage',
+    component: () => import('@/views/error.vue'),
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    redirect: '/error',
+  },
+];
+
+export { routes };

+ 62 - 0
apps/webB-velofex/src/styles/global.scss

@@ -0,0 +1,62 @@
+.ant-btn {
+  min-height: 33px;
+  padding: 0 20px;
+  font-weight: 500;
+  border-radius: 25px;
+}
+
+.ant-btn-primary {
+  position: relative;
+  z-index: 1;
+  overflow: hidden;
+  color: #fff;
+  background: linear-gradient(to right, #8b0046, #460023);
+  border: none !important;
+  outline: none !important;
+  box-shadow: none !important;
+}
+
+.ant-btn-primary::after {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: -1;
+  width: 100%;
+  height: 100%;
+  content: '';
+  background: #7a003d;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+}
+
+.ant-btn-primary:hover {
+  color: #fff;
+  border: none !important;
+  outline: none !important;
+  box-shadow: none !important;
+}
+
+.ant-btn-primary:hover::after {
+  opacity: 1;
+}
+
+.ant-btn-default {
+  color: #000;
+  background: transparent;
+  border: 1px solid #000;
+}
+
+.ant-btn-default:hover {
+  color: #fff !important;
+  background: linear-gradient(to right, #8b0046, #460023);
+  border: 1px solid #8b0046 !important;
+  border-color: #8b0046;
+}
+
+.global-color {
+  background-color: #5d1818;
+}
+
+.global-color:hover {
+  background-color: #7a003d;
+}

+ 9 - 0
apps/webB-velofex/src/utils/auth.ts

@@ -0,0 +1,9 @@
+export function getAccessToken(enterpriseCode: string) {
+  const scopedToken = localStorage.getItem(`token_${enterpriseCode}`);
+
+  if (!scopedToken) {
+    return false;
+  }
+
+  return true;
+}

+ 71 - 0
apps/webB-velofex/src/views/error.vue

@@ -0,0 +1,71 @@
+<template>
+  <main class="error-page">
+    <section class="error-card">
+      <div class="error-badge">ERROR</div>
+      <h1 class="error-title">会话超时!请重新登录!(ERROR)</h1>
+      <p class="error-subtitle">
+        当前登录态已失效,请重新进入系统完成身份验证。
+      </p>
+    </section>
+  </main>
+</template>
+
+<style scoped lang="scss">
+.error-page {
+  display: grid;
+  place-items: center;
+  min-height: 100vh;
+  padding: 24px;
+  background: radial-gradient(
+      circle at 78% 88%,
+      rgb(193 233 255 / 35%),
+      transparent 36%
+    ),
+    radial-gradient(circle at 70% 92%, rgb(255 216 199 / 30%), transparent 30%),
+    #f2f0f3;
+}
+
+.error-card {
+  width: min(760px, 100%);
+  padding: 32px 28px;
+  background: #fff;
+  border: 1px solid #efe8ee;
+  border-radius: 24px;
+  box-shadow: 0 14px 36px rgb(60 34 51 / 10%);
+}
+
+.error-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 4px 10px;
+  margin-bottom: 14px;
+  font-size: 12px;
+  font-weight: 700;
+  color: #fff;
+  letter-spacing: 0.4px;
+  background: linear-gradient(135deg, #8b1648, #460023);
+  border-radius: 999px;
+}
+
+.error-title {
+  margin: 0;
+  font-size: clamp(24px, 4vw, 34px);
+  font-weight: 800;
+  line-height: 1.2;
+  color: #3e2b33;
+}
+
+.error-subtitle {
+  margin: 14px 0 0;
+  font-size: 15px;
+  color: #776a74;
+}
+
+@media (width <= 640px) {
+  .error-card {
+    padding: 22px 18px;
+    border-radius: 18px;
+  }
+}
+</style>

+ 740 - 0
apps/webB-velofex/src/views/home.vue

@@ -0,0 +1,740 @@
+<script setup lang="ts">
+import type { MenuProps } from 'antdv-next';
+
+import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+
+import { preferences } from '@vben/preferences';
+
+import {
+  curUserInfo,
+  type curUserInfoPayload,
+  leftMenuListFromB,
+} from '@/api/account';
+import defaultCompanyLogo from '@/assets/image/earth_18301626@2x.png';
+import defaultAvatar from '@/assets/image/user.png';
+import { resolveEnterpriseCodeFromLocation } from '@/router/guard';
+import { Dropdown, Menu } from 'antdv-next';
+
+import SelectLang from '#/components/select-lang.vue';
+import { $t } from '#/locales';
+
+const leftMenuItems = ref<MenuProps['items']>([]);
+const selectedKeys = ref<string[]>([]);
+const userMenuItems = computed<MenuProps['items']>(() => [
+  { key: 'ChangeInformation', label: $t('home.userMenu.changeInformation') },
+  { key: 'Logout', label: $t('home.userMenu.logout') },
+]);
+const openMenuKeys = ref<string[]>([]);
+const isMobileSidebarOpen = ref(false);
+const userInfo = ref<curUserInfoPayload>();
+const pageTitle = ref('');
+const pageCrumb = ref('');
+const iframeSrc = ref('');
+const menuMetaByKey = ref<
+  Record<string, { crumb: string; iframeSrc: string; title: string }>
+>({});
+const companyLogoSrc = ref('/Content/Images/company-logo.png');
+const avatarSrc = computed(() => {
+  const avatar = userInfo.value?.avatar;
+  return avatar ? `/File/Download?fileId=${avatar}` : defaultAvatar;
+});
+
+function handleCompanyLogoError() {
+  companyLogoSrc.value = defaultCompanyLogo;
+}
+
+type RawMenuNode = {
+  code?: string;
+  deleted?: boolean;
+  fullName?: string;
+  icon?: string;
+  iconClass?: string;
+  iconColor?: string;
+  id?: string;
+  isDeleted?: boolean;
+  link?: string;
+  name?: string;
+  subMenuList?: RawMenuNode[];
+};
+
+function buildMenuIframeSrc(link?: string, id?: string) {
+  const linkPart = String(link ?? '').trim();
+  if (!linkPart) {
+    return '';
+  }
+
+  const idPart = String(id ?? '').trim();
+  const addMenuIdQuery = (url: string) => {
+    if (!idPart) {
+      return url;
+    }
+
+    const hashIndex = url.indexOf('#');
+    const pathAndQuery = hashIndex === -1 ? url : url.slice(0, hashIndex);
+    const hashPart = hashIndex === -1 ? '' : url.slice(hashIndex);
+    const joiner = pathAndQuery.includes('?') ? '&' : '?';
+    return `${pathAndQuery}${joiner}menuId=${idPart}${hashPart}`;
+  };
+
+  if (/^https?:\/\//i.test(linkPart)) {
+    return addMenuIdQuery(linkPart);
+  }
+
+  const normalizedPath = linkPart.startsWith('/') ? linkPart : `/${linkPart}`;
+  return addMenuIdQuery(normalizedPath);
+}
+
+function normalizeMenuIconClass(iconClass?: string) {
+  if (!iconClass) {
+    return [];
+  }
+
+  return iconClass
+    .replaceAll(',', ' ')
+    .split(' ')
+    .map((token) => token.trim())
+    .filter(Boolean);
+}
+
+function renderMenuLabel(node: RawMenuNode, fallbackText: string) {
+  const iconClass = node.iconClass || node.icon;
+  const text = node.name || node.fullName || fallbackText;
+  const normalizedIconClass = normalizeMenuIconClass(iconClass);
+
+  if (normalizedIconClass.length === 0) {
+    return text;
+  }
+
+  return h('span', { class: 'menu-node-label' }, [
+    h('i', {
+      class: ['menu-node-icon', ...normalizedIconClass],
+    }),
+    h('span', { class: 'menu-node-text' }, text),
+  ]);
+}
+
+function mapMenuItems(
+  nodes: RawMenuNode[],
+  firstLeafPathRef: string[],
+  parentPath: string[] = [],
+  parentLabelPath: string[] = [],
+  metaMap: Record<
+    string,
+    { crumb: string; iframeSrc: string; title: string }
+  > = {},
+): NonNullable<MenuProps['items']> {
+  return nodes
+    .filter((node) => !(node.deleted || node.isDeleted))
+    .map((node, index) => {
+      const key = node.id || node.code || `${node.name ?? 'menu'}-${index}`;
+      const labelText = node.name || node.fullName || key;
+      const currentPath = [...parentPath, key];
+      const currentLabelPath = [...parentLabelPath, labelText];
+      const topLevelTitle = currentLabelPath[0] ?? labelText;
+      metaMap[key] = {
+        crumb: currentLabelPath.join(' / '),
+        iframeSrc: buildMenuIframeSrc(node.link, node.id),
+        title: topLevelTitle,
+      };
+      const children = mapMenuItems(
+        node.subMenuList ?? [],
+        firstLeafPathRef,
+        currentPath,
+        currentLabelPath,
+        metaMap,
+      );
+
+      if (children.length === 0 && firstLeafPathRef.length === 0) {
+        firstLeafPathRef.push(...currentPath);
+      }
+
+      return {
+        key,
+        label: renderMenuLabel(node, key),
+        children: children.length > 0 ? children : undefined,
+      };
+    });
+}
+
+function applyMenuMeta(key: string) {
+  const meta = menuMetaByKey.value[key];
+  if (!meta) {
+    pageTitle.value = '';
+    pageCrumb.value = '';
+    iframeSrc.value = '';
+    return;
+  }
+  pageTitle.value = meta.title;
+  pageCrumb.value = meta.crumb;
+  iframeSrc.value = meta.iframeSrc;
+}
+
+async function loadLeftMenuFromB() {
+  const { result } = await leftMenuListFromB();
+  const firstLeafPath: string[] = [];
+  const nextMetaMap: Record<
+    string,
+    { crumb: string; iframeSrc: string; title: string }
+  > = {};
+  const items = mapMenuItems(
+    result?.subMenuList ?? [],
+    firstLeafPath,
+    [],
+    [],
+    nextMetaMap,
+  );
+
+  menuMetaByKey.value = nextMetaMap;
+
+  leftMenuItems.value = items;
+  if (firstLeafPath.length > 0) {
+    const defaultKey = firstLeafPath[firstLeafPath.length - 1]!;
+    selectedKeys.value = [defaultKey];
+    openMenuKeys.value = items
+      .filter(
+        (item) =>
+          item &&
+          (item as any).children &&
+          Array.isArray((item as any).children) &&
+          (item as any).children.length > 0,
+      )
+      .map((item) => String(item!.key));
+    applyMenuMeta(defaultKey);
+  } else {
+    selectedKeys.value = [];
+    openMenuKeys.value = [];
+    pageTitle.value = '';
+    pageCrumb.value = '';
+    iframeSrc.value = '';
+  }
+}
+
+async function getCurUserInfo() {
+  const { result } = await curUserInfo();
+  userInfo.value = result;
+}
+
+let isRefreshingData = false;
+let refreshQueued = false;
+
+async function refreshHomeData() {
+  if (isRefreshingData) {
+    refreshQueued = true;
+    return;
+  }
+
+  isRefreshingData = true;
+  do {
+    refreshQueued = false;
+    await getCurUserInfo();
+    await loadLeftMenuFromB();
+  } while (refreshQueued);
+  isRefreshingData = false;
+}
+
+function handleLocaleStorageChange(event: StorageEvent) {
+  if (!event.key?.endsWith('-preferences-locale')) {
+    return;
+  }
+  if (event.newValue === event.oldValue) {
+    return;
+  }
+  void refreshHomeData();
+}
+
+onMounted(async () => {
+  await refreshHomeData();
+  window.addEventListener('storage', handleLocaleStorageChange);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('storage', handleLocaleStorageChange);
+});
+
+watch(
+  () => preferences.app.locale,
+  (locale, previousLocale) => {
+    if (locale === previousLocale) {
+      return;
+    }
+    void refreshHomeData();
+  },
+);
+
+function handleMenuOpenChange(keys: string[]) {
+  openMenuKeys.value = keys;
+}
+
+function handleLeftMenuClick({ key }: { key: string }) {
+  const normalizedKey = String(key);
+  selectedKeys.value = [normalizedKey];
+  applyMenuMeta(normalizedKey);
+}
+
+function openMobileSidebar() {
+  isMobileSidebarOpen.value = true;
+}
+
+function closeMobileSidebar() {
+  isMobileSidebarOpen.value = false;
+}
+
+function handleUserMenuClick({ key }: { key: string }) {
+  if (key === 'ChangeInformation' && window.top && window.top !== window) {
+    window.top.location.href = '/user-profile';
+  }
+
+  if (key === 'Logout') {
+    const enterpriseCode = resolveEnterpriseCodeFromLocation();
+    if (enterpriseCode) {
+      localStorage.removeItem(`token_${enterpriseCode}`);
+    }
+
+    const logoutUrl = `/Account/Logout?enterpriseCode=${enterpriseCode}`;
+    try {
+      if (window.top && window.top !== window) {
+        window.top.location.href = logoutUrl;
+        return;
+      }
+    } catch {}
+
+    window.location.href = logoutUrl;
+  }
+}
+</script>
+
+<template>
+  <div class="enterprise-page">
+    <div
+      :class="[{ show: isMobileSidebarOpen }]"
+      class="mobile-mask"
+      @click="closeMobileSidebar"
+    ></div>
+    <div class="enterprise-shell">
+      <aside
+        :class="[{ 'mobile-open': isMobileSidebarOpen }]"
+        class="left-panel"
+      >
+        <div class="brand-card">
+          <div class="logo-dot">
+            <img :src="companyLogoSrc" @error="handleCompanyLogoError" />
+          </div>
+          <div class="brand-text">{{ userInfo?.enterpriseName }}</div>
+          <button
+            class="mobile-close-btn"
+            type="button"
+            @click="closeMobileSidebar"
+          >
+            x
+          </button>
+        </div>
+
+        <div class="menu-scroll-wrap">
+          <Menu
+            :items="leftMenuItems"
+            :open-keys="openMenuKeys"
+            :selected-keys="selectedKeys"
+            class="enterprise-menu"
+            mode="inline"
+            @click="(info: any) => handleLeftMenuClick(info)"
+            @open-change="handleMenuOpenChange"
+          />
+        </div>
+      </aside>
+
+      <section class="right-panel">
+        <header class="top-bar">
+          <div class="title-wrap">
+            <h1>{{ pageTitle }}</h1>
+            <div class="crumb">{{ pageCrumb }}</div>
+          </div>
+
+          <div class="top-actions">
+            <button
+              class="mobile-toggle-btn"
+              type="button"
+              @click="openMobileSidebar"
+            >
+              Menu
+            </button>
+            <SelectLang />
+            <Dropdown
+              :menu="{
+                items: userMenuItems,
+              }"
+              placement="bottom"
+              @menu-click="(info: any) => handleUserMenuClick(info)"
+            >
+              <div class="user-avatar cursor-pointer">
+                <img :src="avatarSrc" alt="avatar" />
+              </div>
+            </Dropdown>
+          </div>
+        </header>
+
+        <div class="content-placeholder">
+          <iframe
+            v-if="iframeSrc"
+            :src="iframeSrc"
+            border="0"
+            class="content-iframe"
+          ></iframe>
+        </div>
+      </section>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.enterprise-page {
+  height: 100vh;
+  min-height: 100vh;
+  overflow: hidden;
+  background: radial-gradient(
+      circle at 78% 88%,
+      rgb(193 233 255 / 35%),
+      transparent 36%
+    ),
+    radial-gradient(circle at 70% 92%, rgb(255 216 199 / 30%), transparent 30%),
+    #f2f0f3;
+}
+
+.enterprise-shell {
+  display: flex;
+  height: 100vh;
+  min-height: 100vh;
+  overflow: hidden;
+  background: #f8f6f8;
+  box-shadow: 0 10px 30px rgb(60 34 51 / 8%);
+}
+
+.mobile-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 20;
+  pointer-events: none;
+  background: rgb(26 15 23 / 42%);
+  opacity: 0;
+  transition: opacity 0.2s ease;
+}
+
+.mobile-mask.show {
+  pointer-events: auto;
+  opacity: 1;
+}
+
+.left-panel {
+  position: relative;
+  z-index: 30;
+  display: flex;
+  flex-direction: column;
+  width: 256px;
+  height: 100%;
+  min-height: 0;
+  padding: 24px 16px;
+  overflow: hidden;
+  background: #f8f6f8;
+  border-right: none;
+}
+
+.menu-scroll-wrap {
+  flex: 1;
+  min-height: 0;
+  padding-right: 4px;
+  overflow-y: auto;
+  scrollbar-color: #cbb8c3 transparent;
+  scrollbar-width: thin;
+}
+
+.menu-scroll-wrap::-webkit-scrollbar {
+  width: 3px;
+}
+
+.menu-scroll-wrap::-webkit-scrollbar-thumb {
+  background-color: #cbb8c3;
+  border-radius: 999px;
+}
+
+.menu-scroll-wrap::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.brand-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  align-items: center;
+  padding-bottom: 20px;
+  margin-bottom: 18px;
+  border-bottom: 1px solid #ece6ed;
+}
+
+.mobile-close-btn {
+  display: none;
+  width: 28px;
+  height: 28px;
+  margin-left: auto;
+  font-size: 12px;
+  line-height: 1;
+  color: #7a4860;
+  background: #fff;
+  border: 1px solid #d8cbd5;
+  border-radius: 6px;
+}
+
+.logo-dot {
+  display: grid;
+  place-items: center;
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+  }
+}
+
+.brand-text {
+  font-size: 14px;
+  font-weight: 600;
+  color: #4f4250;
+}
+
+.user-avatar {
+  display: grid;
+  place-items: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+}
+
+:deep(.enterprise-menu) {
+  background: transparent;
+  border-inline-end: none;
+}
+
+:deep(.enterprise-menu .ant-menu-item) {
+  height: auto;
+  padding: 9px 10px;
+  margin: 0 0 6px;
+  font-size: 13px;
+  line-height: 1.2;
+  color: #6f6771;
+  border-radius: 8px;
+}
+
+:deep(.menu-node-label) {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  font-size: 16px;
+}
+
+:deep(.menu-node-icon) {
+  font-size: 14px;
+  line-height: 1;
+  color: currentcolor;
+}
+
+:deep(.enterprise-menu .ant-menu-item-selected) {
+  font-weight: 600;
+  color: #fff;
+  background: #8b1648;
+}
+
+:deep(.enterprise-menu .ant-menu-item-selected .menu-node-icon),
+:deep(.enterprise-menu .ant-menu-item-selected .menu-node-text) {
+  color: #fff !important;
+}
+
+:deep(.enterprise-menu .ant-menu-submenu-selected > .ant-menu-submenu-title) {
+  color: #5a4f5a;
+  background: transparent;
+}
+
+:deep(.enterprise-menu .ant-menu-item-selected::after) {
+  display: none;
+}
+
+:deep(.enterprise-menu .ant-menu-sub .ant-menu-item) {
+  font-size: 12px;
+}
+
+:deep(.ant-menu-light.ant-menu-root.ant-menu-inline) {
+  border-inline-end: none;
+}
+
+:deep(.ant-menu-light.ant-menu-inline .ant-menu-sub.ant-menu-inline) {
+  background: none;
+}
+
+:deep(.enterprise-menu .ant-menu-submenu-title) {
+  height: auto;
+  padding: 8px 10px;
+  margin: 0 0 6px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #5a4f5a;
+  border-radius: 8px;
+}
+
+:deep(.enterprise-menu .ant-menu-submenu-title:hover) {
+  color: #5a4f5a;
+}
+
+.right-panel {
+  position: relative;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  padding: 28px 32px;
+  overflow: hidden;
+  border-radius: 50px 0 0 50px;
+  box-shadow: 0 8px 20px rgb(95 67 84 / 10%);
+}
+
+.right-panel::before {
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  content: '';
+  background: #fff;
+}
+
+.right-panel > * {
+  position: relative;
+  z-index: 1;
+}
+
+.top-bar {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #f0ebf1;
+}
+
+.title-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: baseline;
+}
+
+.title-wrap h1 {
+  margin: 0;
+  font-size: 34px;
+  font-weight: 700;
+  color: #3e2b33;
+  letter-spacing: 0.2px;
+}
+
+.crumb {
+  margin-left: 8px;
+  font-size: 14px;
+  color: #8b808a;
+}
+
+.top-actions {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+}
+
+.mobile-toggle-btn {
+  display: none;
+  height: 32px;
+  padding: 0 12px;
+  font-size: 12px;
+  color: #6f4e60;
+  background: #fff;
+  border: 1px solid #dcced7;
+  border-radius: 16px;
+}
+
+.content-placeholder {
+  flex: 1;
+  min-height: 0;
+  margin-top: 12px;
+  overflow: hidden;
+  background: #fff;
+  border-radius: 14px;
+}
+
+.content-iframe {
+  width: 100%;
+  height: 100%;
+  border: 0;
+}
+
+.content-empty {
+  display: grid;
+  place-items: center;
+  width: 100%;
+  height: 100%;
+  font-size: 14px;
+  color: #8b808a;
+}
+
+@media (width <= 960px) {
+  .enterprise-page {
+    padding: 10px;
+  }
+
+  .enterprise-shell {
+    flex-direction: column;
+    min-height: calc(100vh - 20px);
+  }
+
+  .left-panel {
+    position: fixed;
+    inset: 0 auto 0 0;
+    width: 280px;
+    border-right: 1px solid #efebf1;
+    transition: transform 0.24s ease;
+    transform: translateX(-100%);
+  }
+
+  .left-panel.mobile-open {
+    transform: translateX(0);
+  }
+
+  .mobile-close-btn {
+    display: block;
+  }
+
+  .right-panel {
+    padding: 20px 16px;
+    margin: 0;
+    border-radius: 0;
+    box-shadow: none;
+  }
+
+  .right-panel::before {
+    border-radius: 0;
+  }
+
+  .top-bar {
+    flex-direction: column;
+    gap: 12px;
+    align-items: flex-start;
+  }
+
+  .mobile-toggle-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .title-wrap h1 {
+    font-size: 26px;
+  }
+}
+</style>

+ 1 - 0
apps/webB-velofex/tailwind.config.mjs

@@ -0,0 +1 @@
+export { default } from '@vben/tailwind-config';

+ 13 - 0
apps/webB-velofex/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/web-app.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "#/*": ["./src/*"],
+      "@/*": ["./src/*"]
+    }
+  },
+  "references": [{ "path": "./tsconfig.node.json" }],
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 10 - 0
apps/webB-velofex/tsconfig.node.json

@@ -0,0 +1,10 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/node.json",
+  "compilerOptions": {
+    "composite": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "noEmit": false
+  },
+  "include": ["vite.config.mts"]
+}

+ 30 - 0
apps/webB-velofex/vite.config.mts

@@ -0,0 +1,30 @@
+import { defineConfig } from '@vben/vite-config';
+
+export default defineConfig(async () => {
+  return {
+    application: {},
+    vite: {
+      resolve: {
+        alias: {
+          '@': '/src',
+        },
+      },
+      server: {
+        proxy: {
+          '/api': {
+            changeOrigin: true,
+            rewrite: (path) => path.replace(/^\/api/, ''),
+            target: 'http://ab.dev.jbpm.shalu.com/',
+            ws: true,
+          },
+          '/eapi': {
+            changeOrigin: true,
+            rewrite: (path) => path.replace(/^\/eapi/, '/api'),
+            target: 'http://a.dev.jbpm.shalu.com/',
+            ws: true,
+          },
+        },
+      },
+    },
+  };
+});

+ 75 - 13
pnpm-lock.yaml

@@ -663,6 +663,72 @@ importers:
         specifier: 'catalog:'
         version: 4.5.0(vue@3.5.13(typescript@5.7.2))
 
+  apps/webB-velofex:
+    dependencies:
+      '@vben/access':
+        specifier: workspace:*
+        version: link:../../packages/effects/access
+      '@vben/common-ui':
+        specifier: workspace:*
+        version: link:../../packages/effects/common-ui
+      '@vben/constants':
+        specifier: workspace:*
+        version: link:../../packages/constants
+      '@vben/hooks':
+        specifier: workspace:*
+        version: link:../../packages/effects/hooks
+      '@vben/icons':
+        specifier: workspace:*
+        version: link:../../packages/icons
+      '@vben/layouts':
+        specifier: workspace:*
+        version: link:../../packages/effects/layouts
+      '@vben/locales':
+        specifier: workspace:*
+        version: link:../../packages/locales
+      '@vben/plugins':
+        specifier: workspace:*
+        version: link:../../packages/effects/plugins
+      '@vben/preferences':
+        specifier: workspace:*
+        version: link:../../packages/preferences
+      '@vben/request':
+        specifier: workspace:*
+        version: link:../../packages/effects/request
+      '@vben/stores':
+        specifier: workspace:*
+        version: link:../../packages/stores
+      '@vben/styles':
+        specifier: workspace:*
+        version: link:../../packages/styles
+      '@vben/types':
+        specifier: workspace:*
+        version: link:../../packages/types
+      '@vben/utils':
+        specifier: workspace:*
+        version: link:../../packages/utils
+      '@vueuse/core':
+        specifier: 'catalog:'
+        version: 12.2.0(typescript@5.7.2)
+      antdv-next:
+        specifier: 'catalog:'
+        version: 1.0.5(vue@3.5.13(typescript@5.7.2))
+      crypto-js:
+        specifier: ^4.2.0
+        version: 4.2.0
+      dayjs:
+        specifier: 'catalog:'
+        version: 1.11.13
+      pinia:
+        specifier: 2.2.2
+        version: 2.2.2(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))
+      vue:
+        specifier: ^3.5.13
+        version: 3.5.13(typescript@5.7.2)
+      vue-router:
+        specifier: 'catalog:'
+        version: 4.5.0(vue@3.5.13(typescript@5.7.2))
+
   internal/lint-configs/commitlint-config:
     dependencies:
       '@commitlint/cli':
@@ -3124,8 +3190,8 @@ packages:
     resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==}
     engines: {node: '>= 16'}
 
-  '@intlify/shared@11.3.0':
-    resolution: {integrity: sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==}
+  '@intlify/shared@11.3.2':
+    resolution: {integrity: sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==}
     engines: {node: '>= 16'}
 
   '@intlify/shared@12.0.0-alpha.3':
@@ -5251,9 +5317,6 @@ packages:
     resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
 
-  csstype@3.1.3:
-    resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
-
   csstype@3.2.3:
     resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
 
@@ -9612,6 +9675,7 @@ packages:
   whatwg-encoding@3.1.1:
     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
     engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
 
   whatwg-mimetype@3.0.0:
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -11580,7 +11644,7 @@ snapshots:
 
   '@intlify/shared@10.0.5': {}
 
-  '@intlify/shared@11.3.0': {}
+  '@intlify/shared@11.3.2': {}
 
   '@intlify/shared@12.0.0-alpha.3': {}
 
@@ -11588,8 +11652,8 @@ snapshots:
     dependencies:
       '@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
       '@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))
-      '@intlify/shared': 11.3.0
-      '@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.3.0)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
+      '@intlify/shared': 11.3.2
+      '@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
       '@rollup/pluginutils': 5.1.4(rollup@4.29.1)
       '@typescript-eslint/scope-manager': 8.18.1
       '@typescript-eslint/typescript-estree': 8.18.1(typescript@5.7.2)
@@ -11611,11 +11675,11 @@ snapshots:
       - supports-color
       - typescript
 
-  '@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.3.0)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
+  '@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
     dependencies:
       '@babel/parser': 7.26.3
     optionalDependencies:
-      '@intlify/shared': 11.3.0
+      '@intlify/shared': 11.3.2
       '@vue/compiler-dom': 3.5.13
       vue: 3.5.13(typescript@5.7.2)
       vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.2))
@@ -13054,7 +13118,7 @@ snapshots:
       '@vue/reactivity': 3.5.13
       '@vue/runtime-core': 3.5.13
       '@vue/shared': 3.5.13
-      csstype: 3.1.3
+      csstype: 3.2.3
 
   '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.6.3))':
     dependencies:
@@ -14121,8 +14185,6 @@ snapshots:
     dependencies:
       css-tree: 2.2.1
 
-  csstype@3.1.3: {}
-
   csstype@3.2.3: {}
 
   cz-git@1.11.0: {}

+ 4 - 0
vben-admin.code-workspace

@@ -8,6 +8,10 @@
       "name": "@vben/web",
       "path": "apps/web-velofex",
     },
+    {
+      "name": "@vben/web-b-velofex",
+      "path": "apps/webB-velofex",
+    },
     {
       "name": "@vben/commitlint-config",
       "path": "internal/lint-configs/commitlint-config",