feat(preferences): 公司品牌信息化编辑与全局联动(登录页/菜单栏/网页标题),偏好设置 tab
已添加4个文件
已修改9个文件
403 ■■■■■ 文件已修改
src/api/system/company-config/index.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/storage/index.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/bootstrap.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 21 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/blocks/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/en-US/preferences.json 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/zh-CN/preferences.json 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/company-config.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/company-config.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/company-config/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
import { requestClient } from '#/api/request';
export namespace SystemCompanyConfigApi {
  /** å…¬å¸å“ç‰Œä¿¡æ¯ */
  export interface CompanyConfig {
    id?: number;
    companyName?: string;
    logoUrl?: string;
    logoBlobId?: number;
    loginTitle?: string;
    loginDescription?: string;
  }
}
/** èŽ·å¾—å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆå…ç™»å½•å…¬å¼€ï¼Œç™»å½•é¡µ / åº”用启动时使用) */
export async function getCompanyConfig() {
  return requestClient.get<SystemCompanyConfigApi.CompanyConfig | null>(
    '/system/company-config/get',
  );
}
/** ä¿å­˜å…¬å¸å“ç‰Œä¿¡æ¯ */
export async function saveCompanyConfig(
  data: SystemCompanyConfigApi.CompanyConfig,
) {
  return requestClient.post<number>('/system/company-config/save', data);
}
src/api/system/storage/index.ts
@@ -45,6 +45,17 @@
  );
}
/** å…¬å…±æ–‡ä»¶ä¸Šä¼ ï¼ˆæ°¸ä¹…有效,返回永久公开 previewURL,用于登录页等免登录展示的场景) */
export function publicUploadFile(files: globalThis.File[]) {
  const formData = new FormData();
  files.forEach((file) => formData.append('files', file));
  return requestClient.post<SystemStorageApi.StorageBlob[]>(
    '/system/storage-blob/public-upload',
    formData,
    { headers: { 'Content-Type': 'multipart/form-data' } },
  );
}
/** æŸ¥è¯¢é™„件列表 */
export function listAttachments(params: SystemStorageApi.AttachmentListParams) {
  return requestClient.get<SystemStorageApi.StorageBlob[]>(
src/bootstrap.ts
@@ -18,6 +18,9 @@
import App from './app.vue';
import { router } from './router';
import { getCompanyConfig } from '#/api/system/company-config';
import { applyCompanyConfig } from '#/utils/company-config';
async function bootstrap(namespace: string) {
  // åˆå§‹åŒ–组件适配器
  await initComponentAdapter();
@@ -49,6 +52,17 @@
  // é…ç½® pinia-store
  await initStores(app, { namespace });
  // åŠ è½½å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆä»ŽåŽç«¯ï¼Œå…ç™»å½•æŽ¥å£ï¼‰ï¼Œè”åŠ¨ç™»å½•é¡µ/菜单栏/网页标题。
  // æ³¨ï¼šæ­¤æ—¶ pinia å°šæœªåœ¨ç»„件外激活,这里用纯函数,不依赖 store。
  try {
    const companyConfig = await getCompanyConfig();
    if (companyConfig) {
      applyCompanyConfig(companyConfig);
    }
  } catch {
    // åŽç«¯ä¸å¯ç”¨æˆ–未配置,保持编译期默认
  }
  // å®‰è£…权限指令
  registerAccessDirective(app);
src/layouts/auth.vue
@@ -1,16 +1,31 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
import { useCompanyConfigStore } from '#/store/company-config';
declare const __APP_LOGO__: string;
const companyConfigStore = useCompanyConfigStore();
onMounted(() => {
  // ç™»å½•页展示 loginTitle:启动加载走的是纯函数(不填 store),进入登录页时拉取一次
  companyConfigStore.loadFromServer();
});
const appName = computed(() => preferences.copyright.companyName);
const logo = computed(() => __APP_LOGO__ || preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
// ç™»å½•页标题/描述优先取后端配置,未配置时回退到 i18n é»˜è®¤
const pageTitle = computed(
  () => companyConfigStore.loginTitle || $t('authentication.pageTitle'),
);
const pageDescription = computed(
  () => companyConfigStore.loginDescription || $t('authentication.pageDesc'),
);
</script>
<template>
@@ -18,8 +33,8 @@
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
    :page-description="$t('authentication.pageDesc')"
    :page-title="$t('authentication.pageTitle')"
    :page-description="pageDescription"
    :page-title="pageTitle"
  >
    <!-- è‡ªå®šä¹‰å·¥å…·æ  -->
    <!-- <template #toolbar></template> -->
src/packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue
@@ -5,17 +5,22 @@
import { TabsTrigger } from 'reka-ui';
import { cn } from '../../../../../base/shared/src/utils';
import { Tabs, TabsContent, TabsList } from '../../ui';
import TabsIndicator from './tabs-indicator.vue';
interface Props {
  defaultValue?: string;
  tabs?: SegmentedItem[];
  /** æ˜¯å¦å…è®¸æ¨ªå‘滚动:tab è¾ƒå¤šæ—¶å¯ç”¨ï¼Œå„ tab æŒ‰å†…容宽度排列,超出部分可滑动 */
  scrollable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  defaultValue: '',
  tabs: () => [],
  scrollable: false,
});
const activeTab = defineModel<string>();
@@ -25,12 +30,18 @@
});
const tabsStyle = computed(() => {
  if (props.scrollable) {
    return undefined;
  }
  return {
    'grid-template-columns': `repeat(${props.tabs.length}, minmax(0, 1fr))`,
  };
});
const tabsIndicatorStyle = computed(() => {
  if (props.scrollable) {
    return undefined;
  }
  return {
    width: `${(100 / props.tabs.length).toFixed(0)}%`,
  };
@@ -45,14 +56,22 @@
  <Tabs v-model="activeTab" :default-value="getDefaultValue">
    <TabsList
      :style="tabsStyle"
      class="bg-accent outline-heavy! relative grid w-full outline-2!"
      :class="
        cn(
          'bg-accent outline-heavy! relative outline-2!',
          // å¯æ»šåŠ¨æ¨¡å¼ï¼šå·¦å¯¹é½ï¼ˆè¦†ç›– reka-ui çš„ justify-center,否则内容超宽时左侧被挤出且无法滚动)、全宽、横向滚动
          scrollable
            ? 'flex! w-full! overflow-x-auto justify-start!'
            : 'grid w-full',
        )
      "
    >
      <TabsIndicator :style="tabsIndicatorStyle" />
      <TabsIndicator v-if="!scrollable" :style="tabsIndicatorStyle" />
      <template v-for="tab in tabs" :key="tab.value">
        <TabsTrigger
          :value="tab.value"
          :class="activeClass(tab.value)"
          class="hover:text-primary z-20 inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
          class="hover:text-primary z-20 inline-flex shrink-0 items-center justify-center rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
        >
          {{ tab.label }}
        </TabsTrigger>
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { Button, Input, Upload } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
// æ³¨æ„ï¼šæœ¬æ–‡ä»¶ä½äºŽ @vben çš„ packages å†…部,无法使用应用层别名 #/,需用相对路径(与同目录 custom.vue ä¿æŒä¸€è‡´ï¼‰
import { publicUploadFile } from '../../../../../../../../api/system/storage';
import { $t } from '../../../../../../../locales/src';
import { useCompanyConfigStore } from '../../../../../../../../store/company-config';
defineOptions({
  name: 'PreferenceCompanyConfig',
});
const companyConfigStore = useCompanyConfigStore();
const companyName = ref(companyConfigStore.companyName);
const loginTitle = ref(companyConfigStore.loginTitle);
const loginDescription = ref(companyConfigStore.loginDescription);
const logoUrl = ref(companyConfigStore.logoUrl);
const uploading = ref(false);
// æ‰“开区块时从后端拉取最新公司配置并回填表单(store åˆå€¼å¯èƒ½ä¸ºç©ºï¼Œå› å¯åŠ¨åŠ è½½èµ°çš„æ˜¯çº¯å‡½æ•°ï¼‰
onMounted(async () => {
  await companyConfigStore.loadFromServer();
  companyName.value = companyConfigStore.companyName;
  loginTitle.value = companyConfigStore.loginTitle;
  loginDescription.value = companyConfigStore.loginDescription;
  logoUrl.value = companyConfigStore.logoUrl;
});
/** ä¸Šä¼  logo(永久公开,供登录页免登录展示) */
async function handleBeforeUpload(file: File) {
  uploading.value = true;
  try {
    const blobs = await publicUploadFile([file]);
    const blob = blobs?.[0];
    if (blob?.previewURL || blob?.url) {
      logoUrl.value = blob.previewURL || blob.url || '';
      message.success($t('preferences.company.uploadSuccess'));
    } else {
      message.error($t('preferences.company.uploadFailed'));
    }
  } catch {
    message.error($t('preferences.company.uploadFailed'));
  } finally {
    uploading.value = false;
  }
  // è¿”回 false,阻止组件默认上传(已通过接口上传)
  return false;
}
async function handleSave() {
  if (!companyName.value.trim()) {
    message.warning($t('preferences.company.companyNameRequired'));
    return;
  }
  try {
    await companyConfigStore.save({
      companyName: companyName.value.trim(),
      logoUrl: logoUrl.value || undefined,
      loginTitle: loginTitle.value,
      loginDescription: loginDescription.value,
    });
    message.success($t('preferences.company.saveSuccess'));
  } catch {
    message.error($t('preferences.company.saveFailed'));
  }
}
</script>
<template>
  <div class="flex flex-col">
    <!-- å…¬å¸åç§° -->
    <div class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1">
      <span class="flex items-center text-sm">
        {{ $t('preferences.company.companyName') }}
      </span>
      <Input
        v-model:value="companyName"
        :placeholder="$t('preferences.company.companyNamePlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- ç™»å½•页标题 -->
    <div class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1">
      <span class="flex items-center text-sm">
        {{ $t('preferences.company.loginTitle') }}
      </span>
      <Input
        v-model:value="loginTitle"
        :placeholder="$t('preferences.company.loginTitlePlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- ç™»å½•页描述 -->
    <div class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1">
      <span class="flex items-center text-sm">
        {{ $t('preferences.company.loginDescription') }}
      </span>
      <Input
        v-model:value="loginDescription"
        :placeholder="$t('preferences.company.loginDescriptionPlaceholder')"
        class="w-41.25"
        allow-clear
      />
    </div>
    <!-- å…¬å¸ Logo -->
    <div class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1">
      <span class="flex items-center text-sm">
        {{ $t('preferences.company.logo') }}
      </span>
      <div class="flex items-center gap-2">
        <Upload
          :show-upload-list="false"
          accept="image/*"
          :before-upload="handleBeforeUpload"
        >
          <Button size="small" :loading="uploading">
            {{ $t('preferences.company.uploadLogo') }}
          </Button>
        </Upload>
        <img
          v-if="logoUrl"
          :src="logoUrl"
          alt="company-logo"
          class="h-9 w-9 rounded border border-border object-contain"
        />
        <span v-else class="text-xs text-muted-foreground">
          {{ $t('preferences.company.logoEmpty') }}
        </span>
      </div>
    </div>
    <div v-if="uploading" class="px-2 pb-1 text-xs text-muted-foreground">
      {{ $t('preferences.company.uploading') }}
    </div>
    <!-- ä¿å­˜ -->
    <div class="mt-3 px-2">
      <Button type="primary" class="w-full" @click="handleSave">
        {{ $t('preferences.company.save') }}
      </Button>
    </div>
  </div>
</template>
src/packages/effects/layouts/src/widgets/preferences/blocks/index.ts
@@ -1,4 +1,5 @@
export { default as Block } from './block.vue';
export { default as Company } from './company/company.vue';
export { default as Custom } from './custom/custom.vue';
export { default as Animation } from './general/animation.vue';
export { default as General } from './general/general.vue';
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
@@ -43,6 +43,7 @@
  Breadcrumb,
  BuiltinTheme,
  ColorMode,
  Company,
  Content,
  Copyright,
  Custom,
@@ -256,6 +257,10 @@
      label: $t('preferences.general'),
      value: 'general',
    },
    {
      label: $t('preferences.company.title'),
      value: 'company',
    },
  ];
  if (showCustomTab.value) {
@@ -352,6 +357,7 @@
        <VbenSegmented
          v-model="activeTab"
          :tabs="tabs"
          scrollable
          :class="{
            'sticky-tabs-header': appEnableStickyPreferencesNavigationBar,
          }"
@@ -526,6 +532,11 @@
              />
            </Block>
          </template>
          <template #company>
            <Block :title="$t('preferences.company.title')">
              <Company />
            </Block>
          </template>
          <template #custom>
            <Block :title="customTabTitle">
              <Custom
src/packages/locales/src/langs/en-US/preferences.json
@@ -229,5 +229,24 @@
        "placeholder": "Please enter the default report title"
      }
    }
  },
  "company": {
    "title": "Company Info",
    "companyName": "Company Name",
    "companyNamePlaceholder": "Please enter company name",
    "companyNameRequired": "Company name is required",
    "loginTitle": "Login Page Title",
    "loginTitlePlaceholder": "Enter login page title (optional)",
    "loginDescription": "Login Page Description",
    "loginDescriptionPlaceholder": "Enter login page description (optional)",
    "logo": "Company Logo",
    "uploadLogo": "Upload Logo",
    "uploadSuccess": "Logo uploaded successfully",
    "uploadFailed": "Logo upload failed",
    "uploading": "Uploading...",
    "logoEmpty": "No logo set",
    "save": "Save",
    "saveSuccess": "Company info saved successfully",
    "saveFailed": "Failed to save company info"
  }
}
src/packages/locales/src/langs/zh-CN/preferences.json
@@ -229,5 +229,24 @@
        "placeholder": "请输入报表默认标题"
      }
    }
  },
  "company": {
    "title": "公司信息",
    "companyName": "公司名称",
    "companyNamePlaceholder": "请输入公司名称",
    "companyNameRequired": "公司名称不能为空",
    "loginTitle": "登录页标题",
    "loginTitlePlaceholder": "请输入登录页标题(可选)",
    "loginDescription": "登录页描述",
    "loginDescriptionPlaceholder": "请输入登录页描述(可选)",
    "logo": "公司 Logo",
    "uploadLogo": "上传 Logo",
    "uploadSuccess": "Logo ä¸Šä¼ æˆåŠŸ",
    "uploadFailed": "Logo ä¸Šä¼ å¤±è´¥",
    "uploading": "上传中...",
    "logoEmpty": "未设置 Logo",
    "save": "保存",
    "saveSuccess": "公司信息保存成功",
    "saveFailed": "公司信息保存失败"
  }
}
src/store/company-config.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import {
  getCompanyConfig,
  saveCompanyConfig as saveCompanyConfigApi,
} from '#/api/system/company-config';
import { applyCompanyConfig } from '#/utils/company-config';
/**
 * å…¬å¸å“ç‰Œä¿¡æ¯å…¨å±€é…ç½®
 *
 * å¯åŠ¨æ—¶ä»ŽåŽç«¯åŠ è½½ï¼Œä¿å­˜æ—¶å†™å›žåŽç«¯ï¼›å¹¶å°†å…¬å¸å/logo åŒæ­¥åˆ° preferences,
 * ä»Žè€Œè”动登录页、菜单栏、网页标题等所有消费点。
 */
export const useCompanyConfigStore = defineStore('companyConfig', () => {
  const companyName = ref('');
  const logoUrl = ref('');
  const loginTitle = ref('');
  const loginDescription = ref('');
  /** å°†å…¬å¸ä¿¡æ¯åº”用到 preferences,联动登录页/菜单栏/标题 */
  function applyToPreferences() {
    applyCompanyConfig({
      companyName: companyName.value,
      logoUrl: logoUrl.value,
    });
  }
  /** ä»ŽåŽç«¯åŠ è½½å…¬å¸é…ç½®å¹¶åº”ç”¨ */
  async function loadFromServer() {
    try {
      const config = await getCompanyConfig();
      if (config) {
        companyName.value = config.companyName ?? '';
        logoUrl.value = config.logoUrl ?? '';
        loginTitle.value = config.loginTitle ?? '';
        loginDescription.value = config.loginDescription ?? '';
        applyToPreferences();
      }
    } catch {
      // åŽç«¯ä¸å¯ç”¨æˆ–未配置,保持编译期默认
    }
  }
  /** ä¿å­˜å…¬å¸é…ç½®åˆ°åŽç«¯å¹¶åº”用 */
  async function save(data: {
    companyName: string;
    logoUrl?: string;
    logoBlobId?: number;
    loginTitle?: string;
    loginDescription?: string;
  }) {
    await saveCompanyConfigApi(data);
    companyName.value = data.companyName;
    logoUrl.value = data.logoUrl ?? '';
    loginTitle.value = data.loginTitle ?? '';
    loginDescription.value = data.loginDescription ?? '';
    applyToPreferences();
  }
  return {
    companyName,
    logoUrl,
    loginTitle,
    loginDescription,
    applyToPreferences,
    loadFromServer,
    save,
  };
});
src/store/index.ts
@@ -1 +1,2 @@
export * from './auth';
export * from './company-config';
src/utils/company-config.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
import { preferencesManager } from '@vben/preferences';
/** å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆä¸ŽåŽç«¯ system_company_config è¡¨å­—段对应) */
export interface CompanyConfigData {
  companyName?: string;
  logoUrl?: string;
  loginTitle?: string;
  loginDescription?: string;
}
/**
 * å°†å…¬å¸å“ç‰Œä¿¡æ¯åº”用到全局:
 * - å…¬å¸å â†’ preferences.app.name / copyright.companyName(联动菜单栏、登录页、网页标题、版权)
 * - logo â†’ preferences.logo.source(联动登录页、侧边栏 logo)
 * - æœ‰å…¬å¸åæ—¶ï¼Œè®¾ç½® document.title(基础标题,dynamicTitle å¼€å¯æ—¶è·¯ç”±æ ‡é¢˜åœ¨æ­¤åŸºç¡€ä¸Šæ‹¼æŽ¥ï¼‰
 */
export function applyCompanyConfig(config: CompanyConfigData) {
  const updates: Record<string, any> = {};
  if (config.logoUrl) {
    updates.logo = { source: config.logoUrl };
  }
  if (config.companyName) {
    updates.app = { name: config.companyName };
    updates.copyright = { companyName: config.companyName };
  }
  if (Object.keys(updates).length > 0) {
    preferencesManager.updatePreferences(updates);
  }
  if (config.companyName) {
    document.title = config.companyName;
  }
}