feat(tenant): 登录后应用租户品牌(主色/标题/Logo/版权)+ 租户表单品牌字段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 22:32:25 +08:00
parent 64bbb0a987
commit 3746198f19
5 changed files with 75 additions and 11 deletions

View File

@ -45,3 +45,16 @@ export function register(data: {
export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> { export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> {
return http.patch('/auth/me/password', { oldPassword, newPassword }) return http.patch('/auth/me/password', { oldPassword, newPassword })
} }
export interface TenantBrand {
name: string
brandName: string | null
logoUrl: string | null
themeColor: string | null
copyright: string | null
}
/** 当前租户品牌。 */
export function fetchBrand(signal?: AbortSignal): Promise<TenantBrand> {
return http.get<TenantBrand>('/auth/brand', { signal })
}

View File

@ -6,6 +6,10 @@ export interface Tenant {
name: string name: string
status: 'enabled' | 'disabled' status: 'enabled' | 'disabled'
remark: string | null remark: string | null
brandName: string | null
logoUrl: string | null
themeColor: string | null
copyright: string | null
createdAt: number createdAt: number
} }
@ -32,11 +36,23 @@ export function listTenants(query: TenantListQuery, signal?: AbortSignal): Promi
return http.get<TenantListResult>('/tenants', { params, signal }) return http.get<TenantListResult>('/tenants', { params, signal })
} }
export function createTenant(data: { code: string; name: string; status?: string; remark?: string }): Promise<Tenant> { export function createTenant(data: {
code: string
name: string
status?: string
remark?: string
brandName?: string
logoUrl?: string
themeColor?: string
copyright?: string
}): Promise<Tenant> {
return http.post<Tenant>('/tenants', data) return http.post<Tenant>('/tenants', data)
} }
export function updateTenant(id: string, data: { name?: string; status?: string; remark?: string }): Promise<Tenant> { export function updateTenant(
id: string,
data: { name?: string; status?: string; remark?: string; brandName?: string; logoUrl?: string; themeColor?: string; copyright?: string },
): Promise<Tenant> {
return http.patch<Tenant>(`/tenants/${id}`, data) return http.patch<Tenant>(`/tenants/${id}`, data)
} }

View File

@ -63,7 +63,7 @@
</keep-alive> </keep-alive>
</router-view> </router-view>
</div> </div>
<ty-footer :fixed="app.footerFixed" /> <ty-footer :fixed="app.footerFixed" :copyright="copyright" />
</main> </main>
</div> </div>
@ -81,7 +81,7 @@ import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu' import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict' import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission' import { usePermissionStore } from '@/stores/modules/permission'
import logo from '@/assets/images/logo.png' import defaultLogo from '@/assets/images/logo.png'
import type { LayoutSettings } from '@/config/layout' import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource' import type { ResourceNode } from '@/types/resource'
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue' import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
@ -100,16 +100,24 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen() const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const title = '投云科技' const title = computed(() => auth.brand?.brandName || auth.brand?.name || '投云科技')
const logo = computed(() => auth.brand?.logoUrl || defaultLogo)
const copyright = computed(() => auth.brand?.copyright || '云南投云科技有限公司')
const HEADER_HEIGHT = 56 const HEADER_HEIGHT = 56
const menus = computed(() => menuStore.menus) const menus = computed(() => menuStore.menus)
const settingsOpen = ref(false) const settingsOpen = ref(false)
// 登录后拉取菜单、字典与权限码 // 登录后拉取菜单、字典、权限码与租户品牌
onMounted(() => { onMounted(async () => {
menuStore.fetch() menuStore.fetch()
dictStore.fetch() dictStore.fetch()
permissionStore.fetch() permissionStore.fetch()
try {
await auth.fetchBrandState()
if (auth.brand?.themeColor) app.themeColor = auth.brand.themeColor
} catch {
// 品牌拉取失败则用默认
}
}) })
const activePath = computed(() => route.path) const activePath = computed(() => route.path)

View File

@ -23,6 +23,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant, type TenantListQ
const columns: TableColumn[] = [ const columns: TableColumn[] = [
{ prop: 'name', label: '租户名称', minWidth: 140 }, { prop: 'name', label: '租户名称', minWidth: 140 },
{ prop: 'code', label: '租户编码', minWidth: 140 }, { prop: 'code', label: '租户编码', minWidth: 140 },
{ prop: 'brandName', label: '品牌名', minWidth: 120 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' }, { prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'remark', label: '备注', minWidth: 160 }, { prop: 'remark', label: '备注', minWidth: 160 },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' }, { prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
@ -54,6 +55,10 @@ const formSchema: JsonSchema = {
default: 'enabled', default: 'enabled',
}, },
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' }, remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
brandName: { type: 'string', title: '品牌名', placeholder: '如 投云科技' },
logoUrl: { type: 'string', title: 'Logo 地址', placeholder: '请输入 Logo URL' },
themeColor: { type: 'string', title: '主色', placeholder: '如 #2E7FA3' },
copyright: { type: 'string', title: '版权文案', placeholder: '如 云南投云科技有限公司' },
}, },
required: ['code', 'name'], required: ['code', 'name'],
} }
@ -71,8 +76,25 @@ function toListQuery(query: PageQuery): TenantListQuery {
const list = (query: PageQuery, signal?: AbortSignal) => listTenants(toListQuery(query), signal) const list = (query: PageQuery, signal?: AbortSignal) => listTenants(toListQuery(query), signal)
const create = (data: Record<string, any>) => const create = (data: Record<string, any>) =>
createTenant({ code: data.code, name: data.name, status: data.status, remark: data.remark }) createTenant({
code: data.code,
name: data.name,
status: data.status,
remark: data.remark,
brandName: data.brandName,
logoUrl: data.logoUrl,
themeColor: data.themeColor,
copyright: data.copyright,
})
const update = (id: string, data: Record<string, any>) => const update = (id: string, data: Record<string, any>) =>
updateTenant(id, { name: data.name, status: data.status, remark: data.remark }) updateTenant(id, {
name: data.name,
status: data.status,
remark: data.remark,
brandName: data.brandName,
logoUrl: data.logoUrl,
themeColor: data.themeColor,
copyright: data.copyright,
})
const remove = (id: string) => deleteTenant(id) const remove = (id: string) => deleteTenant(id)
</script> </script>

View File

@ -1,16 +1,21 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { http } from '@/api/http'; import { http } from '@/api/http';
import { login as loginApi, type LoginResult } from '@/api/auth'; import { fetchBrand, login as loginApi, type LoginResult, type TenantBrand } from '@/api/auth';
import { clearTokens, getAccessToken, getMustChangePassword, getRefreshToken, setMustChangePassword, setTokens } from '@/utils/token'; import { clearTokens, getAccessToken, getMustChangePassword, getRefreshToken, setMustChangePassword, setTokens } from '@/utils/token';
export const useAuthStore = defineStore('authStore', () => { export const useAuthStore = defineStore('authStore', () => {
const accessToken = ref<string | null>(getAccessToken()); const accessToken = ref<string | null>(getAccessToken());
const refreshToken = ref<string | null>(getRefreshToken()); const refreshToken = ref<string | null>(getRefreshToken());
const mustChangePassword = ref<boolean>(getMustChangePassword()); const mustChangePassword = ref<boolean>(getMustChangePassword());
const brand = ref<TenantBrand | null>(null);
const isAuthenticated = computed(() => !!accessToken.value); const isAuthenticated = computed(() => !!accessToken.value);
async function fetchBrandState(): Promise<void> {
brand.value = await fetchBrand();
}
async function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> { async function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> {
const data = await loginApi(username, password, tenantCode); const data = await loginApi(username, password, tenantCode);
if ('accessToken' in data) { if ('accessToken' in data) {
@ -44,5 +49,5 @@ export const useAuthStore = defineStore('authStore', () => {
setMustChangePassword(false); setMustChangePassword(false);
} }
return { accessToken, refreshToken, isAuthenticated, mustChangePassword, login, logout, reset }; return { accessToken, refreshToken, isAuthenticated, mustChangePassword, brand, login, logout, reset, fetchBrandState };
}); });