Jelajahi Sumber

fix: B端log图标变小/tabs菜单/面包屑

weibo.xia 2 minggu lalu
induk
melakukan
623e287add

+ 1 - 0
apps/web-velofex-b/package.json

@@ -15,6 +15,7 @@
     "#/*": "./src/*"
   },
   "dependencies": {
+    "@velofex-core/tabs-ui": "workspace:*",
     "@velofex/access": "workspace:*",
     "@velofex/common-ui": "workspace:*",
     "@velofex/constants": "workspace:*",

+ 2 - 150
apps/web-velofex-b/src/bootstrap.ts

@@ -1,8 +1,7 @@
-import { createApp, watch, watchEffect } from 'vue';
+import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@velofex/access';
-import { loadLocaleMessages, type SupportedLanguagesType } from '@velofex/locales';
-import { preferences, updatePreferences } from '@velofex/preferences';
+import { preferences } from '@velofex/preferences';
 import { initStores } from '@velofex/stores';
 import '@velofex/styles';
 
@@ -11,85 +10,12 @@ 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);
@@ -100,80 +26,6 @@ async function bootstrap(namespace: string) {
   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;

+ 16 - 7
apps/web-velofex-b/src/components/select-lang.vue

@@ -4,6 +4,7 @@ import { computed, ref } from 'vue';
 import { loadLocaleMessages } from '@velofex/locales';
 import { preferences, updatePreferences } from '@velofex/preferences';
 
+import { changeLanguageApi } from '@/api/account';
 import { Dropdown } from 'antdv-next';
 
 const items = [
@@ -28,17 +29,25 @@ const onOpenChange = (open: boolean) => {
   show.value = open;
 };
 
-const onMenuClick = (info: any) => {
+const onMenuClick = async (info: any) => {
   if (preferences.app.locale === info.key) {
     return;
   }
-  updatePreferences({
-    app: {
-      locale: info.key,
-    },
-  });
 
-  loadLocaleMessages(info.key);
+  const nextLocale = info.key;
+  try {
+    await changeLanguageApi({ langId: nextLocale === 'en-US' ? 1 : 0 });
+
+    updatePreferences({
+      app: {
+        locale: nextLocale,
+      },
+    });
+
+    await loadLocaleMessages(nextLocale);
+  } catch (error) {
+    console.error('web-b', error);
+  }
 };
 </script>
 

+ 309 - 105
apps/web-velofex-b/src/views/home.vue

@@ -3,11 +3,14 @@ import type { MenuProps } from 'antdv-next';
 
 import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 
-import { loadLocaleMessages, type SupportedLanguagesType } from '@velofex/locales';
+import {
+  loadLocaleMessages,
+  type SupportedLanguagesType,
+} from '@velofex/locales';
 import { preferences, updatePreferences } from '@velofex/preferences';
+import { TabsView } from '@velofex-core/tabs-ui';
 
 import {
-  changeLanguageApi,
   curUserInfo,
   type curUserInfoPayload,
   leftMenuListFromB,
@@ -31,15 +34,31 @@ 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 iframeTabs = ref<
+  Array<{ crumb: string; iframeSrc: string; key: string; title: string }>
+>([]);
+const activeTabKey = ref('');
 const companyLogoSrc = ref('/Content/Images/company-logo.png');
 const avatarSrc = computed(() => {
   const avatar = userInfo.value?.avatar;
   return avatar ? `/File/Download?fileId=${avatar}` : defaultAvatar;
 });
+const contentTabs = computed(() => {
+  return iframeTabs.value.map((tab) => {
+    return {
+      fullPath: tab.key,
+      meta: {
+        tabClosable: true,
+        title: tab.title,
+      },
+      name: tab.title,
+      path: tab.key,
+    } as any;
+  });
+});
 
 function handleCompanyLogoError() {
   companyLogoSrc.value = defaultCompanyLogo;
@@ -132,11 +151,10 @@ function mapMenuItems(
       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,
+        title: labelText,
       };
       const children = mapMenuItems(
         node.subMenuList ?? [],
@@ -163,12 +181,46 @@ function applyMenuMeta(key: string) {
   if (!meta) {
     pageTitle.value = '';
     pageCrumb.value = '';
-    iframeSrc.value = '';
     return;
   }
   pageTitle.value = meta.title;
   pageCrumb.value = meta.crumb;
-  iframeSrc.value = meta.iframeSrc;
+}
+
+function clearActiveTab() {
+  activeTabKey.value = '';
+  selectedKeys.value = [];
+  pageTitle.value = '';
+  pageCrumb.value = '';
+}
+
+function setActiveTab(key: string) {
+  activeTabKey.value = key;
+  selectedKeys.value = [key];
+  applyMenuMeta(key);
+}
+
+function openIframeTab(key: string) {
+  const meta = menuMetaByKey.value[key];
+  if (!meta?.iframeSrc) {
+    return;
+  }
+
+  const nextTab = {
+    crumb: meta.crumb,
+    iframeSrc: meta.iframeSrc,
+    key,
+    title: meta.title,
+  };
+  const existingIndex = iframeTabs.value.findIndex((tab) => tab.key === key);
+
+  if (existingIndex === -1) {
+    iframeTabs.value.push(nextTab);
+  } else {
+    iframeTabs.value.splice(existingIndex, 1, nextTab);
+  }
+
+  setActiveTab(key);
 }
 
 async function loadLeftMenuFromB() {
@@ -187,11 +239,25 @@ async function loadLeftMenuFromB() {
   );
 
   menuMetaByKey.value = nextMetaMap;
+  iframeTabs.value = iframeTabs.value.flatMap((tab) => {
+    const meta = nextMetaMap[tab.key];
+    if (!meta?.iframeSrc) {
+      return [];
+    }
+
+    return [
+      {
+        crumb: meta.crumb,
+        iframeSrc: meta.iframeSrc,
+        key: tab.key,
+        title: meta.title,
+      },
+    ];
+  });
 
   leftMenuItems.value = items;
   if (firstLeafPath.length > 0) {
     const defaultKey = firstLeafPath[firstLeafPath.length - 1]!;
-    selectedKeys.value = [defaultKey];
     openMenuKeys.value = items
       .filter(
         (item) =>
@@ -201,13 +267,18 @@ async function loadLeftMenuFromB() {
           (item as any).children.length > 0,
       )
       .map((item) => String(item!.key));
-    applyMenuMeta(defaultKey);
+
+    if (activeTabKey.value && nextMetaMap[activeTabKey.value]?.iframeSrc) {
+      setActiveTab(activeTabKey.value);
+    } else if (iframeTabs.value.length > 0) {
+      setActiveTab(iframeTabs.value[0]!.key);
+    } else {
+      openIframeTab(defaultKey);
+    }
   } else {
-    selectedKeys.value = [];
     openMenuKeys.value = [];
-    pageTitle.value = '';
-    pageCrumb.value = '';
-    iframeSrc.value = '';
+    iframeTabs.value = [];
+    clearActiveTab();
   }
 }
 
@@ -249,22 +320,18 @@ async function refreshHomeData() {
       const localeFromUserInfo = resolveLocaleByUserLanguage(
         userInfo.value?.language,
       );
-      if (localeFromUserInfo) {
-        const langId = localeFromUserInfo === 'en-US' ? 1 : 0;
-        if (localeFromUserInfo !== preferences.app.locale) {
-          isApplyingLocaleFromUserInfo = true;
-          try {
-            updatePreferences({
-              app: {
-                locale: localeFromUserInfo,
-              },
-            });
-            await loadLocaleMessages(localeFromUserInfo);
-          } finally {
-            isApplyingLocaleFromUserInfo = false;
-          }
+      if (localeFromUserInfo && localeFromUserInfo !== preferences.app.locale) {
+        isApplyingLocaleFromUserInfo = true;
+        try {
+          updatePreferences({
+            app: {
+              locale: localeFromUserInfo,
+            },
+          });
+          await loadLocaleMessages(localeFromUserInfo);
+        } finally {
+          isApplyingLocaleFromUserInfo = false;
         }
-        await changeLanguageApi({ langId });
       }
       hasInitializedLanguageFromUserInfo = true;
     }
@@ -309,8 +376,59 @@ function handleMenuOpenChange(keys: string[]) {
 
 function handleLeftMenuClick({ key }: { key: string }) {
   const normalizedKey = String(key);
-  selectedKeys.value = [normalizedKey];
-  applyMenuMeta(normalizedKey);
+  openIframeTab(normalizedKey);
+  closeMobileSidebar();
+}
+
+function handleTabChange(key: string) {
+  const normalizedKey = String(key);
+  if (!menuMetaByKey.value[normalizedKey]) {
+    return;
+  }
+
+  setActiveTab(normalizedKey);
+}
+
+function handleTabClose(key: string) {
+  const normalizedKey = String(key);
+  const currentIndex = iframeTabs.value.findIndex(
+    (tab) => tab.key === normalizedKey,
+  );
+  if (currentIndex === -1) {
+    return;
+  }
+
+  iframeTabs.value.splice(currentIndex, 1);
+  if (activeTabKey.value !== normalizedKey) {
+    return;
+  }
+
+  const nextActiveTab =
+    iframeTabs.value[currentIndex] ??
+    iframeTabs.value[currentIndex - 1] ??
+    null;
+  if (nextActiveTab) {
+    setActiveTab(nextActiveTab.key);
+    return;
+  }
+
+  clearActiveTab();
+}
+
+function handleSortTabs(oldIndex: number, newIndex: number) {
+  if (oldIndex === newIndex) {
+    return;
+  }
+
+  const nextTabs = [...iframeTabs.value];
+  const movedTabs = nextTabs.splice(oldIndex, 1);
+  const movedTab = movedTabs[0];
+  if (!movedTab) {
+    return;
+  }
+
+  nextTabs.splice(newIndex, 0, movedTab);
+  iframeTabs.value = nextTabs;
 }
 
 function openMobileSidebar() {
@@ -387,7 +505,6 @@ function handleUserMenuClick({ key }: { key: string }) {
       <section class="right-panel">
         <header class="top-bar">
           <div class="title-wrap">
-            <h1>{{ pageTitle }}</h1>
             <div class="crumb">{{ pageCrumb }}</div>
           </div>
 
@@ -415,12 +532,34 @@ function handleUserMenuClick({ key }: { key: string }) {
         </header>
 
         <div class="content-placeholder">
-          <iframe
-            v-if="iframeSrc"
-            :src="iframeSrc"
-            border="0"
-            class="content-iframe"
-          ></iframe>
+          <template v-if="iframeTabs.length > 0">
+            <div class="content-tabs-wrap">
+              <TabsView
+                :active="activeTabKey"
+                :draggable="preferences.tabbar.draggable"
+                :show-icon="false"
+                :style-type="preferences.tabbar.styleType"
+                :tabs="contentTabs"
+                :wheelable="preferences.tabbar.wheelable"
+                @close="handleTabClose"
+                @sort-tabs="handleSortTabs"
+                @update:active="handleTabChange"
+              />
+            </div>
+
+            <div class="content-iframe-stack">
+              <iframe
+                v-for="tab in iframeTabs"
+                v-show="tab.key === activeTabKey"
+                :key="tab.key"
+                :src="tab.iframeSrc"
+                border="0"
+                class="content-iframe"
+              ></iframe>
+            </div>
+          </template>
+
+          <div v-else class="content-empty">暂无可显示内容</div>
         </div>
       </section>
     </div>
@@ -428,6 +567,57 @@ function handleUserMenuClick({ key }: { key: string }) {
 </template>
 
 <style scoped lang="scss">
+@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;
+  }
+}
+
 .enterprise-page {
   height: 100vh;
   min-height: 100vh;
@@ -527,8 +717,8 @@ function handleUserMenuClick({ key }: { key: string }) {
 .logo-dot {
   display: grid;
   place-items: center;
-  width: 80px;
-  height: 80px;
+  width: 30px;
+  height: 30px;
   border-radius: 50%;
 
   img {
@@ -632,6 +822,66 @@ function handleUserMenuClick({ key }: { key: string }) {
   color: #5a4f5a;
 }
 
+:deep(.content-tabs-wrap .vben-tabs-content) {
+  height: 100%;
+}
+
+:deep(.content-tabs-wrap .tabs-chrome) {
+  height: 100%;
+  padding-right: 4px;
+}
+
+:deep(.content-tabs-wrap .tabs-chrome__item-main) {
+  min-width: 72px;
+}
+
+:deep(.content-tabs-wrap [data-radix-scroll-area-viewport]) {
+  overflow-y: hidden !important;
+  scrollbar-width: none;
+}
+
+:deep(.content-tabs-wrap [data-radix-scroll-area-viewport]::-webkit-scrollbar) {
+  height: 0;
+}
+
+:deep(.content-tabs-wrap [data-orientation='horizontal'].hidden) {
+  display: flex !important;
+}
+
+:deep(.content-tabs-wrap [data-orientation='horizontal']) {
+  height: 6px;
+  padding: 0 2px;
+  opacity: 0;
+  transition: opacity 0.2s ease;
+}
+
+:deep(
+  .content-tabs-wrap [data-orientation='horizontal'][data-state='visible']
+) {
+  opacity: 1;
+}
+
+:deep(.content-tabs-wrap [data-orientation='horizontal'] .bg-border) {
+  background: rgb(139 22 72 / 50%) !important;
+}
+
+:deep(.content-tabs-wrap .is-active .tabs-chrome__background-content) {
+  background: rgb(139 22 72 / 10%) !important;
+}
+
+:deep(.content-tabs-wrap .is-active .text-sm) {
+  color: rgb(139 22 72) !important;
+}
+
+:deep(.content-tabs-wrap .is-active .tabs-chrome__background-before),
+:deep(.content-tabs-wrap .is-active .tabs-chrome__background-after) {
+  fill: rgb(139 22 72 / 10%) !important;
+}
+
+:deep(.overflow-y-hidden) {
+  overflow: hidden;
+}
+
 .right-panel {
   position: relative;
   display: flex;
@@ -670,18 +920,10 @@ function handleUserMenuClick({ key }: { key: string }) {
   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;
+  font-size: 20px;
+  color: #3e2b33;
 }
 
 .top-actions {
@@ -702,15 +944,32 @@ function handleUserMenuClick({ key }: { key: string }) {
 }
 
 .content-placeholder {
+  display: flex;
   flex: 1;
+  flex-direction: column;
   min-height: 0;
   margin-top: 12px;
   overflow: hidden;
   background: #fff;
-  border-radius: 14px;
+}
+
+.content-tabs-wrap {
+  flex-shrink: 0;
+  height: 42px;
+  padding: 0 10px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #fcfafc 0%, #f7f3f6 100%);
+  border-bottom: 1px solid #f0ebf1;
+}
+
+.content-iframe-stack {
+  position: relative;
+  flex: 1;
+  min-height: 0;
 }
 
 .content-iframe {
+  display: block;
   width: 100%;
   height: 100%;
   border: 0;
@@ -724,59 +983,4 @@ function handleUserMenuClick({ key }: { key: string }) {
   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>

+ 3 - 0
pnpm-lock.yaml

@@ -665,6 +665,9 @@ importers:
 
   apps/web-velofex-b:
     dependencies:
+      '@velofex-core/tabs-ui':
+        specifier: workspace:*
+        version: link:../../packages/@core/ui-kit/tabs-ui
       '@velofex/access':
         specifier: workspace:*
         version: link:../../packages/effects/access