瀏覽代碼

feat: 首页页面

weibo.xia 1 周之前
父節點
當前提交
f4ab6aeb71

+ 212 - 0
apps/web-velofex-b/src/api/home.ts

@@ -0,0 +1,212 @@
+import { requestClient } from './request';
+
+export interface BackendIndexCommonMenuItem {
+  clickCount: number;
+  code: string;
+  iconClass: string;
+  id: string;
+  lastVisitTime: string;
+  link: string;
+  menuName: string;
+}
+
+interface BackendIndexSystemStatisticsItem {
+  curMonthTaskCount: number;
+  curTodayTaskCount: number;
+  groupCount: number;
+  taskFinishRate: number;
+  totalFileCount: number;
+  totalFinishTaskCount: number;
+  totalInterfaceCount: number;
+  totalPageCount: number;
+  totalTableCount: number;
+  totalTaskCount: number;
+  totalViewCount: number;
+  userCount: number;
+  workflowCount: number;
+}
+
+interface BackendIndexResourceQuota {
+  id: string;
+  number0fBusinessScenarios: number;
+  number0fCSiteMaxUser: number;
+  number0fDesigners: number;
+  number0fPages: number;
+  number0fTables: number;
+  number0fWorkFlow: number;
+  usedBusinessScenarios: number;
+  usedCSiteMaxUser: number;
+  usedDesigners: number;
+  usedPages: number;
+  usedTables: number;
+  usedWorkFlow: number;
+}
+
+interface BackendIndexSystemInfoItem {
+  comment: string;
+  onlineUserCount: number;
+  publisher: string;
+  version: string;
+}
+
+interface BackendIndexWeekInstCountItem {
+  curDay: string;
+  curDayCount: number;
+}
+
+interface BackendIndexResult {
+  commonMenu: BackendIndexCommonMenuItem[];
+  commonWorkFolw: unknown[];
+  resourceQuota: BackendIndexResourceQuota;
+  systemInfo: BackendIndexSystemInfoItem[];
+  systemStatistics: BackendIndexSystemStatisticsItem[];
+  weekInstCount: BackendIndexWeekInstCountItem[];
+}
+
+interface BackendIndexResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: BackendIndexResult;
+}
+
+export interface LinkFavoriteItem {
+  desc: string;
+  favoritesIndex: number;
+  id: string;
+  linkAddress: string;
+  linkType: number;
+  menuIcon: string;
+  name: string;
+  type: number;
+}
+
+interface LinkFavoritesResult {
+  model: LinkFavoriteItem[];
+}
+
+interface LinkFavoritesResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: LinkFavoritesResult;
+}
+
+export interface EnterpriseUserItem {
+  account: string;
+  activeStatus: string;
+  cellPhone: string;
+  chineseName: string;
+  employeeNumber: string;
+  id: string;
+}
+
+interface EnterpriseUserListResult {
+  model: EnterpriseUserItem[];
+}
+
+interface EnterpriseUserListResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: EnterpriseUserListResult;
+}
+
+export type CodeStatsCodeType = 'create' | 'delete' | 'modify';
+export type CodeStatsDateType = '7天之前' | '今天';
+
+interface CodeTotalWithDatePayload {
+  code_type: CodeStatsCodeType;
+  date_type: CodeStatsDateType;
+}
+
+interface CodeTotalWithDateResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: number;
+}
+
+interface CodePageListWithDatePayload extends CodeTotalWithDatePayload {
+  page_index: number;
+}
+
+export interface CodePageListWithDateItem {
+  directory: string;
+  fileExtension: string;
+  fileName: string;
+  id: string;
+  is_directory: boolean;
+}
+
+interface CodePageListWithDateResponse {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: CodePageListWithDateItem[];
+}
+
+const linkFavoritesPayload = {
+  currentPage: 1,
+  pageSize: 10,
+  orderByProperty: 'favoritesIndex',
+  Ascending: true,
+  totalPage: 1,
+  totalCount: 1,
+  filters: [{ name: 'name' }, { name: 'type' }, { name: 'linkType' }],
+};
+
+const enterpriseUserListPayload = {
+  currentPage: 1,
+  pageSize: 10,
+  orderByProperty: 'Id',
+  Ascending: false,
+  totalPage: 1,
+  totalCount: 1,
+  filters: [
+    { name: 'account' },
+    { name: 'employeeNumber' },
+    { name: 'chineseName' },
+    { name: 'englishName' },
+    { name: 'cellPhone' },
+    { name: 'telephone' },
+    { name: 'emailAddress' },
+  ],
+};
+
+export async function getBackendIndexData() {
+  return requestClient.post<BackendIndexResponse>(
+    '/api/home/GetBackendIndexData',
+    {},
+  );
+}
+
+export async function getLinkFavorites() {
+  return requestClient.post<LinkFavoritesResponse>(
+    '/api/bpm/GetLinkFavorites',
+    linkFavoritesPayload,
+  );
+}
+
+export async function getAllEnterpriseUserList() {
+  return requestClient.post<EnterpriseUserListResponse>(
+    '/api/bpm/GetAllEnterpriseUserList',
+    enterpriseUserListPayload,
+  );
+}
+
+export async function getCodeTotalWithDate(payload: CodeTotalWithDatePayload) {
+  return requestClient.post<CodeTotalWithDateResponse>(
+    '/api/code/getCodeTotalWithDate',
+    payload,
+  );
+}
+
+export async function getCodePageListWithDate(
+  payload: CodePageListWithDatePayload,
+) {
+  return requestClient.post<CodePageListWithDateResponse>(
+    '/api/code/getCodePageListWithDate',
+    payload,
+  );
+}

二進制
apps/web-velofex-b/src/assets/image/bg.png


二進制
apps/web-velofex-b/src/assets/image/icon_file.png


二進制
apps/web-velofex-b/src/assets/image/icon_flowchat.png


二進制
apps/web-velofex-b/src/assets/image/icon_interface.png


二進制
apps/web-velofex-b/src/assets/image/icon_page.png


二進制
apps/web-velofex-b/src/assets/image/icon_tableview.png


二進制
apps/web-velofex-b/src/assets/image/icon_user.png


File diff suppressed because it is too large
+ 58 - 0
apps/web-velofex-b/src/assets/image/no_permission.svg


File diff suppressed because it is too large
+ 1049 - 0
apps/web-velofex-b/src/components/home-dashboard-tab.vue


+ 382 - 0
apps/web-velofex-b/src/components/user-info.vue

@@ -0,0 +1,382 @@
+<script setup lang="ts">
+import type { curUserInfoPayload } from '@/api/account';
+
+import { computed, reactive, ref, watch } from 'vue';
+
+import { updateUserInfoApi } from '@/api/account';
+import {
+  Button,
+  DatePicker,
+  Input,
+  message,
+  Modal,
+  Radio,
+  RadioGroup,
+  Upload,
+} from 'antdv-next';
+import dayjs from 'dayjs';
+
+import { $t } from '#/locales';
+
+const props = withDefaults(
+  defineProps<{
+    open: boolean;
+    userInfo?: curUserInfoPayload;
+  }>(),
+  {
+    open: false,
+    userInfo: undefined,
+  },
+);
+
+const emit = defineEmits<{
+  saved: [];
+  'update:open': [value: boolean];
+}>();
+
+type DayjsValue = dayjs.Dayjs | null;
+
+const loading = ref(false);
+const formData = reactive({
+  avatarFileId: '',
+  birthday: null as DayjsValue,
+  fileList: [] as any[],
+  gender: '-1',
+  gogs_email: '',
+  id: '',
+  langNameList: [
+    {
+      name: 'zh-CN',
+      value: '',
+    },
+  ] as Array<{ name: string; value: string }>,
+});
+
+const genderOptions = computed(() => {
+  return [
+    { label: $t('userProfile.genderOptions.secret'), value: '-1' },
+    { label: $t('userProfile.genderOptions.male'), value: '1' },
+    { label: $t('userProfile.genderOptions.female'), value: '0' },
+  ];
+});
+
+const modalOpen = computed({
+  get() {
+    return props.open;
+  },
+  set(value: boolean) {
+    emit('update:open', value);
+  },
+});
+
+function handleCancel() {
+  emit('update:open', false);
+}
+
+function fillFormByUserInfo() {
+  const user = (props.userInfo ?? {}) as {
+    birthday?: string;
+    gender?: number | string;
+  } & curUserInfoPayload;
+
+  formData.id = user.id || '';
+  formData.gogs_email = user.gogs_email || user.email || '';
+  formData.gender = String(user.gender ?? '-1');
+  formData.birthday = user.birthday ? dayjs(user.birthday) : null;
+  formData.avatarFileId = user.avatarFileId || '';
+
+  if (formData.langNameList[0]) {
+    formData.langNameList[0].value = user.name || user.nickName || '';
+  }
+
+  if (formData.avatarFileId) {
+    formData.fileList = [
+      {
+        name: 'avatar.png',
+        response: {
+          result: [{ id: formData.avatarFileId }],
+        },
+        status: 'done',
+        uid: '-1',
+        url: `/File/Download?fileId=${formData.avatarFileId}`,
+      },
+    ];
+    return;
+  }
+
+  formData.fileList = [];
+}
+
+function handleAvatarUpload(info: any) {
+  const fileStatus = info?.file?.status;
+  if (fileStatus === 'done') {
+    const nextAvatarId = info?.file?.response?.result?.[0]?.id;
+    if (nextAvatarId) {
+      formData.avatarFileId = nextAvatarId;
+      message.success($t('userProfile.messages.avatarUploadSuccess'));
+      return;
+    }
+
+    message.error($t('userProfile.messages.avatarUploadFailed'));
+    return;
+  }
+
+  if (fileStatus === 'error') {
+    message.error($t('userProfile.messages.avatarUploadFailed'));
+  }
+}
+
+async function handleSave() {
+  if (!formData.id) {
+    message.error($t('userProfile.messages.userIdRequired'));
+    return;
+  }
+
+  loading.value = true;
+  try {
+    const result = await updateUserInfoApi({
+      avatarFileId: formData.avatarFileId,
+      birthday: formData.birthday ? formData.birthday.format('YYYY-MM-DD') : '',
+      gender: formData.gender,
+      gogs_email: formData.gogs_email,
+      id: formData.id,
+      langNameList: formData.langNameList,
+    });
+
+    if (result?.isSuccess) {
+      message.success($t('userProfile.messages.updateSuccess'));
+      emit('saved');
+      emit('update:open', false);
+      return;
+    }
+
+    message.error($t('userProfile.messages.updateFailed'));
+  } catch {
+    message.error($t('userProfile.messages.updateFailed'));
+  } finally {
+    loading.value = false;
+  }
+}
+
+watch(
+  () => [props.open, props.userInfo],
+  ([open]) => {
+    if (!open) {
+      return;
+    }
+    fillFormByUserInfo();
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <Modal
+    v-model:open="modalOpen"
+    :footer="null"
+    :mask-closable="false"
+    :title="$t('userProfile.title')"
+    :width="920"
+    @cancel="handleCancel"
+  >
+    <div class="profile-modal-body">
+      <div class="avatar-wrap">
+        <Upload
+          v-model:file-list="formData.fileList"
+          :max-count="1"
+          :show-upload-list="false"
+          action="/fileApi/File/UploadFiles"
+          list-type="picture"
+          @change="handleAvatarUpload"
+        >
+          <div class="avatar-uploader">
+            <img
+              v-if="formData.avatarFileId"
+              :alt="$t('userProfile.avatar')"
+              :src="`/File/Download?fileId=${formData.avatarFileId}`"
+              class="avatar-image"
+            />
+            <span v-else class="avatar-text">{{
+              $t('userProfile.uploadAvatar')
+            }}</span>
+          </div>
+        </Upload>
+        <p class="avatar-tip">{{ $t('userProfile.clickToChangeAvatar') }}</p>
+      </div>
+
+      <div class="form-grid">
+        <div class="form-item">
+          <label>{{ $t('userProfile.username') }}</label>
+          <Input v-model:value="formData.langNameList[0]!.value" />
+        </div>
+        <div class="form-item">
+          <label>{{ $t('userProfile.gitAccount') }}</label>
+          <Input v-model:value="formData.gogs_email" />
+        </div>
+        <div class="form-item">
+          <label>{{ $t('userProfile.gender') }}</label>
+          <RadioGroup v-model:value="formData.gender" class="gender-group">
+            <Radio
+              v-for="option in genderOptions"
+              :key="option.value"
+              :value="option.value"
+            >
+              {{ option.label }}
+            </Radio>
+          </RadioGroup>
+        </div>
+        <div class="form-item">
+          <label>{{ $t('userProfile.birthday') }}</label>
+          <DatePicker
+            v-model:value="formData.birthday"
+            :placeholder="$t('userProfile.chooseDate')"
+            format="YYYY-MM-DD"
+          />
+        </div>
+      </div>
+
+      <div class="modal-actions">
+        <Button class="action-btn cancel-btn" @click="handleCancel">
+          {{ $t('userProfile.cancel') }}
+        </Button>
+        <Button
+          :loading="loading"
+          class="action-btn save-btn"
+          type="primary"
+          @click="handleSave"
+        >
+          {{ $t('userProfile.save') }}
+        </Button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style scoped lang="scss">
+.profile-modal-body {
+  padding: 6px 4px 8px;
+}
+
+.avatar-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 4px 0 28px;
+}
+
+.avatar-uploader {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 118px;
+  height: 118px;
+  overflow: hidden;
+  color: #9ea6b2;
+  cursor: pointer;
+  background: #fff;
+  border: 2px dashed #cfd4dc;
+  border-radius: 999px;
+}
+
+.avatar-image {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-text {
+  font-size: 22px;
+  line-height: 1;
+}
+
+.avatar-tip {
+  margin: 14px 0 0;
+  font-size: 18px;
+  color: #6b7280;
+}
+
+.form-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 22px 24px;
+}
+
+.form-item {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+
+  label {
+    font-size: 24px;
+    font-weight: 600;
+    color: #3f4752;
+  }
+}
+
+.gender-group {
+  display: flex;
+  gap: 18px;
+  align-items: center;
+  min-height: 48px;
+}
+
+.modal-actions {
+  display: flex;
+  gap: 18px;
+  justify-content: center;
+  margin-top: 34px;
+}
+
+.action-btn {
+  min-width: 110px;
+  height: 42px;
+  font-size: 20px;
+  border-radius: 999px;
+}
+
+.cancel-btn {
+  color: #151515;
+  border-color: #151515;
+}
+
+.save-btn {
+  background: #7d003f;
+  border-color: #7d003f;
+}
+
+:deep(.ant-modal .ant-modal-title) {
+  font-size: 42px;
+  font-weight: 700;
+  color: #3a1f2b;
+}
+
+:deep(.ant-upload-wrapper) {
+  display: inline-flex;
+}
+
+:deep(.ant-input),
+:deep(.ant-picker) {
+  height: 48px;
+  border-color: #d9d9d9;
+  border-radius: 12px;
+}
+
+:deep(.ant-radio-wrapper) {
+  margin-inline-end: 0;
+  color: #3f4752;
+}
+
+@media (width <= 960px) {
+  .form-grid {
+    grid-template-columns: 1fr;
+  }
+
+  :deep(.ant-modal .ant-modal-title) {
+    font-size: 30px;
+  }
+
+  .form-item label {
+    font-size: 18px;
+  }
+}
+</style>

+ 114 - 1
apps/web-velofex-b/src/locales/langs/en-US/page.json

@@ -1,8 +1,121 @@
 {
   "home": {
+    "dashboard": {
+      "cards": {
+        "locale": {
+          "description": "The home tab title and placeholder content now switch immediately with locale changes, ready for future modules.",
+          "title": "Locale Ready"
+        },
+        "navigation": {
+          "description": "The left tree menu keeps its current behavior and opens business tabs next to the home tab when clicked.",
+          "title": "Navigation"
+        },
+        "workspace": {
+          "description": "This area now provides the shell for a homepage so statistics, announcements, and shortcuts can be added next.",
+          "title": "Homepage Shell"
+        }
+      },
+      "crumb": "Home",
+      "hero": {
+        "description": "The home tab and its content container are in place, so the next step is wiring real business widgets into this area.",
+        "eyebrow": "Enterprise Workspace",
+        "title": "Home Is Ready"
+      },
+      "tab": "Home"
+    },
+    "dashboardTab": {
+      "announcement": {
+        "content": "A new version has been released",
+        "more": "View more >",
+        "placeholder": "xxxxx",
+        "title": "Collaboration Notice"
+      },
+      "commonFeature": {
+        "title": "Common Features"
+      },
+      "emptyPermission": {
+        "alt": "No permission",
+        "title": "No permission"
+      },
+      "fileStats": {
+        "deleted": "Deleted",
+        "edited": "Edited",
+        "lastWeek": "Last 7 days",
+        "loading": "Loading...",
+        "loadMore": "Load more",
+        "more": "View more >",
+        "noData": "No data",
+        "noMore": "No more data",
+        "today": "Today",
+        "title": "File Statistics",
+        "added": "Added"
+      },
+      "links": {
+        "title": "Links"
+      },
+      "overview": {
+        "apiCount": "API Count",
+        "fileCount": "File Count",
+        "pageCount": "Page Count",
+        "processCount": "Process Count",
+        "tableViewCount": "Table View Count",
+        "userCount": "User Count"
+      },
+      "resourceUsage": {
+        "categories": {
+          "cUserCount": "C-End Users",
+          "pageRatio": "Page Ratio",
+          "plannedPeople": "Planned People",
+          "processRatio": "Process Ratio",
+          "scenarioCount": "Business Scenarios",
+          "tableRatio": "Data Table Ratio"
+        },
+        "planningCount": "Planned Count",
+        "title": "Resource Usage Ratio",
+        "usageCount": "Usage Count",
+        "usagePercent": "Usage Percent"
+      },
+      "teamMembers": {
+        "columns": {
+          "account": "Account",
+          "jobNo": "Employee No.",
+          "mobile": "Mobile",
+          "name": "Name",
+          "status": "Status"
+        },
+        "status": {
+          "abnormal": "Abnormal"
+        },
+        "title": "Project Team Members"
+      }
+    },
     "userMenu": {
-      "changeInformation": "Change Information",
       "logout": "Logout"
     }
+  },
+  "userProfile": {
+    "avatar": "Avatar",
+    "birthday": "Birthday",
+    "cancel": "Cancel",
+    "chooseDate": "Please select a date",
+    "clickToChangeAvatar": "Click to change avatar",
+    "gender": "Gender",
+    "genderOptions": {
+      "female": "Female",
+      "male": "Male",
+      "secret": "Secret"
+    },
+    "gitAccount": "Git account / email",
+    "messages": {
+      "avatarUploadFailed": "Avatar upload failed",
+      "avatarUploadSuccess": "Avatar uploaded successfully",
+      "updateFailed": "Save failed",
+      "updateSuccess": "Saved successfully",
+      "userIdRequired": "Missing user ID, unable to save"
+    },
+    "save": "Save",
+    "title": "Personal Information",
+    "uploadAvatar": "Upload avatar",
+    "username": "Username"
   }
 }

+ 93 - 1
apps/web-velofex-b/src/locales/langs/zh-CN/page.json

@@ -1,8 +1,100 @@
 {
   "home": {
+    "dashboard": {
+      "crumb": "首页",
+      "tab": "首页"
+    },
+    "dashboardTab": {
+      "announcement": {
+        "more": "查看更多 >",
+        "title": "协作公告"
+      },
+      "commonFeature": {
+        "title": "常用功能"
+      },
+      "emptyPermission": {
+        "alt": "没有权限",
+        "title": "没有权限"
+      },
+      "fileStats": {
+        "deleted": "删除",
+        "edited": "编辑",
+        "lastWeek": "近七日",
+        "loading": "加载中...",
+        "loadMore": "加载更多",
+        "more": "查看更多 >",
+        "noData": "暂无数据",
+        "noMore": "没有更多了",
+        "today": "今日",
+        "title": "文件统计",
+        "added": "新增"
+      },
+      "links": {
+        "title": "链接地址"
+      },
+      "overview": {
+        "apiCount": "接口数量",
+        "fileCount": "文件数量",
+        "pageCount": "页面数量",
+        "processCount": "流程数量",
+        "tableViewCount": "表视图数量",
+        "userCount": "用户数量"
+      },
+      "resourceUsage": {
+        "categories": {
+          "cUserCount": "C端用户数",
+          "pageRatio": "页面占比",
+          "plannedPeople": "计划人员数",
+          "processRatio": "流程占比",
+          "scenarioCount": "业务场景数",
+          "tableRatio": "数据表占比"
+        },
+        "planningCount": "规划数量",
+        "title": "资源使用占比",
+        "usageCount": "使用数量",
+        "usagePercent": "使用占比"
+      },
+      "teamMembers": {
+        "columns": {
+          "account": "账号",
+          "jobNo": "工号",
+          "mobile": "绑定手机",
+          "name": "中文名",
+          "status": "状态"
+        },
+        "status": {
+          "abnormal": "异常"
+        },
+        "title": "项目组成员"
+      }
+    },
     "userMenu": {
-      "changeInformation": "修改个人信息",
       "logout": "退出"
     }
+  },
+  "userProfile": {
+    "avatar": "头像",
+    "birthday": "生日",
+    "cancel": "取消",
+    "chooseDate": "请选择日期",
+    "clickToChangeAvatar": "点击更换头像",
+    "gender": "性别",
+    "genderOptions": {
+      "female": "女",
+      "male": "男",
+      "secret": "保密"
+    },
+    "gitAccount": "Git账户/邮箱",
+    "messages": {
+      "avatarUploadFailed": "头像上传失败",
+      "avatarUploadSuccess": "头像上传成功",
+      "updateFailed": "保存失败",
+      "updateSuccess": "保存成功",
+      "userIdRequired": "缺少用户ID,无法保存"
+    },
+    "save": "保存",
+    "title": "个人信息",
+    "uploadAvatar": "上传头像",
+    "username": "用户名"
   }
 }

+ 0 - 7
apps/web-velofex-b/src/views/error.vue

@@ -16,13 +16,6 @@
   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 {

+ 208 - 40
apps/web-velofex-b/src/views/home.vue

@@ -2,7 +2,15 @@
 import type { IContextMenuItem } from '@velofex-core/tabs-ui';
 import type { MenuProps } from 'antdv-next';
 
-import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import {
+  computed,
+  defineComponent,
+  h,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from 'vue';
 
 import {
   loadLocaleMessages,
@@ -21,13 +29,25 @@ import defaultAvatar from '@/assets/image/user.png';
 import { resolveEnterpriseCodeFromLocation } from '@/router/guard';
 import { Dropdown, Menu } from 'antdv-next';
 
+import HomeDashboardTab from '#/components/home-dashboard-tab.vue';
 import SelectLang from '#/components/select-lang.vue';
 import { $t } from '#/locales';
 
+const HOME_TAB_KEY = '__home__';
+const HomeTabIcon = defineComponent({
+  name: 'HomeTabIcon',
+  setup(_, { attrs }) {
+    return () =>
+      h('i', {
+        ...attrs,
+        class: ['home-tab-icon', 'icon-dashboard', attrs.class],
+      });
+  },
+});
+
 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[]>([]);
@@ -42,9 +62,10 @@ const iframeTabs = ref<
   Array<{ crumb: string; iframeSrc: string; key: string; title: string }>
 >([]);
 const activeTabKey = ref('');
+const homeRefreshStamp = ref(Date.now());
 const companyLogoSrc = ref('/Content/Images/company-logo.png');
 const avatarSrc = computed(() => {
-  const avatar = userInfo.value?.avatar;
+  const avatar = userInfo.value?.avatarFileId;
   return avatar ? `/File/Download?fileId=${avatar}` : defaultAvatar;
 });
 const contentTabs = computed(() => {
@@ -52,7 +73,8 @@ const contentTabs = computed(() => {
     return {
       fullPath: tab.key,
       meta: {
-        tabClosable: true,
+        icon: tab.key === HOME_TAB_KEY ? HomeTabIcon : undefined,
+        tabClosable: tab.key !== HOME_TAB_KEY,
         title: tab.title,
       },
       name: tab.title,
@@ -60,6 +82,13 @@ const contentTabs = computed(() => {
     } as any;
   });
 });
+const homeTabMeta = computed(() => ({
+  crumb: $t('home.dashboard.crumb'),
+  iframeSrc: '',
+  icon: HomeTabIcon,
+  key: HOME_TAB_KEY,
+  title: $t('home.dashboard.tab'),
+}));
 
 function handleCompanyLogoError() {
   companyLogoSrc.value = defaultCompanyLogo;
@@ -195,8 +224,27 @@ function clearActiveTab() {
   pageCrumb.value = '';
 }
 
+function ensureHomeTab() {
+  const nextHomeTab = homeTabMeta.value;
+  const index = iframeTabs.value.findIndex((tab) => tab.key === HOME_TAB_KEY);
+
+  if (index === -1) {
+    iframeTabs.value.unshift(nextHomeTab);
+    return;
+  }
+
+  iframeTabs.value.splice(index, 1, nextHomeTab);
+}
+
 function setActiveTab(key: string) {
   activeTabKey.value = key;
+  if (key === HOME_TAB_KEY) {
+    selectedKeys.value = [];
+    pageTitle.value = homeTabMeta.value.title;
+    pageCrumb.value = homeTabMeta.value.crumb;
+    return;
+  }
+
   selectedKeys.value = [key];
   applyMenuMeta(key);
 }
@@ -241,6 +289,10 @@ async function loadLeftMenuFromB() {
 
   menuMetaByKey.value = nextMetaMap;
   iframeTabs.value = iframeTabs.value.flatMap((tab) => {
+    if (tab.key === HOME_TAB_KEY) {
+      return [homeTabMeta.value];
+    }
+
     const meta = nextMetaMap[tab.key];
     if (!meta?.iframeSrc) {
       return [];
@@ -255,10 +307,10 @@ async function loadLeftMenuFromB() {
       },
     ];
   });
+  ensureHomeTab();
 
   leftMenuItems.value = items;
   if (firstLeafPath.length > 0) {
-    const defaultKey = firstLeafPath[firstLeafPath.length - 1]!;
     openMenuKeys.value = items
       .filter(
         (item) =>
@@ -273,13 +325,11 @@ async function loadLeftMenuFromB() {
       setActiveTab(activeTabKey.value);
     } else if (iframeTabs.value.length > 0) {
       setActiveTab(iframeTabs.value[0]!.key);
-    } else {
-      openIframeTab(defaultKey);
     }
   } else {
     openMenuKeys.value = [];
-    iframeTabs.value = [];
-    clearActiveTab();
+    ensureHomeTab();
+    setActiveTab(HOME_TAB_KEY);
   }
 }
 
@@ -381,9 +431,63 @@ function handleLeftMenuClick({ key }: { key: string }) {
   closeMobileSidebar();
 }
 
+function findMenuPathByKey(
+  items: MenuProps['items'],
+  targetKey: string,
+  parentPath: string[] = [],
+): null | string[] {
+  for (const item of items ?? []) {
+    if (!item) {
+      continue;
+    }
+
+    const currentKey = String((item as any).key ?? '');
+    if (!currentKey) {
+      continue;
+    }
+
+    const currentPath = [...parentPath, currentKey];
+    if (currentKey === targetKey) {
+      return currentPath;
+    }
+
+    const childItems = (item as any).children as MenuProps['items'];
+    if (!childItems || childItems.length === 0) {
+      continue;
+    }
+
+    const matchedPath = findMenuPathByKey(childItems, targetKey, currentPath);
+    if (matchedPath) {
+      return matchedPath;
+    }
+  }
+
+  return null;
+}
+
+function handleDashboardMenuOpen(key: string) {
+  const normalizedKey = String(key ?? '');
+  if (!normalizedKey) {
+    return;
+  }
+
+  if (!menuMetaByKey.value[normalizedKey]?.iframeSrc) {
+    return;
+  }
+
+  const menuPath = findMenuPathByKey(leftMenuItems.value, normalizedKey);
+  if (menuPath && menuPath.length > 1) {
+    const parentKeys = menuPath.slice(0, -1);
+    openMenuKeys.value = [...new Set([...openMenuKeys.value, ...parentKeys])];
+  }
+
+  openIframeTab(normalizedKey);
+  closeMobileSidebar();
+}
+
 function handleTabChange(key: string) {
   const normalizedKey = String(key);
-  if (!menuMetaByKey.value[normalizedKey]) {
+  if (normalizedKey !== HOME_TAB_KEY && !menuMetaByKey.value[normalizedKey]) {
     return;
   }
 
@@ -392,6 +496,10 @@ function handleTabChange(key: string) {
 
 function handleTabClose(key: string) {
   const normalizedKey = String(key);
+  if (normalizedKey === HOME_TAB_KEY) {
+    return;
+  }
+
   const currentIndex = iframeTabs.value.findIndex(
     (tab) => tab.key === normalizedKey,
   );
@@ -452,6 +560,12 @@ function addTabRefreshStamp(url: string) {
 
 function refreshTabByKey(key: string) {
   const normalizedKey = String(key);
+  if (normalizedKey === HOME_TAB_KEY) {
+    homeRefreshStamp.value = Date.now();
+    setActiveTab(HOME_TAB_KEY);
+    return;
+  }
+
   const index = iframeTabs.value.findIndex((tab) => tab.key === normalizedKey);
   if (index === -1) {
     return;
@@ -476,24 +590,33 @@ function closeOtherTabsByKey(key: string) {
     return;
   }
 
-  iframeTabs.value = [targetTab];
+  iframeTabs.value =
+    normalizedKey === HOME_TAB_KEY
+      ? [homeTabMeta.value]
+      : [homeTabMeta.value, targetTab];
   setActiveTab(normalizedKey);
 }
 
 function closeAllTabs() {
-  iframeTabs.value = [];
-  clearActiveTab();
+  iframeTabs.value = [homeTabMeta.value];
+  setActiveTab(HOME_TAB_KEY);
 }
 
 function createTabContextMenus(tab: { key?: string }) {
   const tabKey = String(tab?.key ?? '');
   const hasTabs = iframeTabs.value.length > 0;
-  const isCurrentTabClosable =
+  const isCurrentTabRefreshable =
     hasTabs && tabKey && iframeTabs.value.some((item) => item.key === tabKey);
+  const isHomeTab = tabKey === HOME_TAB_KEY;
+  const isCurrentTabClosable =
+    hasTabs &&
+    !isHomeTab &&
+    tabKey &&
+    iframeTabs.value.some((item) => item.key === tabKey);
 
   const menus: IContextMenuItem[] = [
     {
-      disabled: !isCurrentTabClosable,
+      disabled: !isCurrentTabRefreshable,
       handler: () => {
         refreshTabByKey(tabKey);
       },
@@ -509,7 +632,7 @@ function createTabContextMenus(tab: { key?: string }) {
       text: '关闭当前',
     },
     {
-      disabled: !isCurrentTabClosable || iframeTabs.value.length <= 1,
+      disabled: !tabKey || iframeTabs.value.length <= 1,
       handler: () => {
         closeOtherTabsByKey(tabKey);
       },
@@ -536,16 +659,8 @@ function closeMobileSidebar() {
 }
 
 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) {
@@ -557,6 +672,25 @@ function handleUserMenuClick({ key }: { key: string }) {
     window.location.href = logoutUrl;
   }
 }
+
+function handleIframeLoad(event: Event) {
+  const iframe = event.target as HTMLIFrameElement | null;
+  if (!iframe) {
+    return;
+  }
+
+  try {
+    const doc = iframe.contentDocument;
+    if (!doc) {
+      return;
+    }
+
+    doc.documentElement.style.background = 'transparent';
+    if (doc.body) {
+      doc.body.style.background = 'transparent';
+    }
+  } catch {}
+}
 </script>
 
 <template>
@@ -627,14 +761,17 @@ function handleUserMenuClick({ key }: { key: string }) {
           </div>
         </header>
 
-        <div class="content-placeholder">
+        <div
+          :class="{ 'home-active': activeTabKey === HOME_TAB_KEY }"
+          class="content-placeholder"
+        >
           <template v-if="iframeTabs.length > 0">
             <div class="content-tabs-wrap">
               <TabsView
                 :active="activeTabKey"
                 :context-menus="createTabContextMenus"
                 :draggable="preferences.tabbar.draggable"
-                :show-icon="false"
+                :show-icon="true"
                 :style-type="preferences.tabbar.styleType"
                 :tabs="contentTabs"
                 :wheelable="preferences.tabbar.wheelable"
@@ -644,14 +781,26 @@ function handleUserMenuClick({ key }: { key: string }) {
               />
             </div>
 
-            <div class="content-iframe-stack">
+            <div
+              :class="{ 'home-active': activeTabKey === HOME_TAB_KEY }"
+              class="content-iframe-stack"
+            >
+              <HomeDashboardTab
+                v-show="activeTabKey === HOME_TAB_KEY"
+                :key="homeRefreshStamp"
+                :is-super-admin="userInfo?.isSuperAdmin"
+                class="h-full"
+                @open-menu="handleDashboardMenuOpen"
+              />
+
               <iframe
                 v-for="tab in iframeTabs"
-                v-show="tab.key === activeTabKey"
+                v-show="tab.key === activeTabKey && tab.key !== HOME_TAB_KEY"
                 :key="tab.key"
                 :src="tab.iframeSrc"
                 border="0"
                 class="content-iframe"
+                @load="handleIframeLoad"
               ></iframe>
             </div>
           </template>
@@ -719,13 +868,6 @@ function handleUserMenuClick({ key }: { key: string }) {
   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 {
@@ -733,7 +875,7 @@ function handleUserMenuClick({ key }: { key: string }) {
   height: 100vh;
   min-height: 100vh;
   overflow: hidden;
-  background: #f8f6f8;
+  background: linear-gradient(180deg, #f8f6f8 0%, #fdf2f7 100%);
   box-shadow: 0 10px 30px rgb(60 34 51 / 8%);
 }
 
@@ -762,7 +904,7 @@ function handleUserMenuClick({ key }: { key: string }) {
   min-height: 0;
   padding: 24px 16px;
   overflow: hidden;
-  background: #f8f6f8;
+  background: linear-gradient(180deg, #f8f6f8 0%, #fdf2f7 100%);
   border-right: none;
 }
 
@@ -975,6 +1117,19 @@ function handleUserMenuClick({ key }: { key: string }) {
   fill: rgb(139 22 72 / 10%) !important;
 }
 
+:deep(.content-tabs-wrap .home-tab-icon) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+  color: #111;
+  vertical-align: middle;
+}
+
+:deep(.content-tabs-wrap .is-active .home-tab-icon) {
+  color: #111 !important;
+}
+
 :deep(.overflow-y-hidden) {
   overflow: hidden;
 }
@@ -987,7 +1142,6 @@ function handleUserMenuClick({ key }: { key: string }) {
   padding: 28px 32px;
   overflow: hidden;
   border-radius: 50px 0 0 50px;
-  box-shadow: 0 8px 20px rgb(95 67 84 / 10%);
 }
 
 .right-panel::before {
@@ -995,7 +1149,11 @@ function handleUserMenuClick({ key }: { key: string }) {
   inset: 0;
   z-index: 0;
   content: '';
-  background: #fff;
+  background-color: #fff;
+  background-image: url('@/assets/image/bg.png');
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: cover;
 }
 
 .right-panel > * {
@@ -1047,7 +1205,11 @@ function handleUserMenuClick({ key }: { key: string }) {
   min-height: 0;
   margin-top: 12px;
   overflow: hidden;
-  background: #fff;
+  background: transparent;
+}
+
+.content-placeholder.home-active {
+  overflow: visible;
 }
 
 .content-tabs-wrap {
@@ -1063,12 +1225,18 @@ function handleUserMenuClick({ key }: { key: string }) {
   position: relative;
   flex: 1;
   min-height: 0;
+  overflow: auto;
+}
+
+.content-iframe-stack.home-active {
+  overflow: visible;
 }
 
 .content-iframe {
   display: block;
   width: 100%;
   height: 100%;
+  background: transparent;
   border: 0;
 }