feat: add sidebar brand customization
The primary sidebar brand was fixed to the built-in name, subtitle, and logo. Appearance settings now let users preview and save a custom brand name, localized Chinese and English subtitles, and a validated local PNG, JPEG, or WebP logo, with default-logo and full-reset actions. Customization remains device-local and limited to the top-left brand area; installer, update, Agent, and protocol identities remain GoodBuddy. The desktop package version is now 0.11.6. Release note: 新增“外观 → 品牌定制”,可修改左上角品牌名称、中英文副标题和本地 Logo,并支持实时预览、恢复默认及本机持久化。
This commit is contained in:
@@ -298,6 +298,13 @@
|
|||||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||||
颜色或动效表达状态。
|
颜色或动效表达状态。
|
||||||
|
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||||
|
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||||
|
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||||
|
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||||
|
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||||
|
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||||
|
单行截断,不能撑宽或挤压侧栏。
|
||||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||||
|
|
||||||
### 6.9 助手工作栏
|
### 6.9 助手工作栏
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.25.1",
|
"@agentclientprotocol/sdk": "0.25.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||||
"desktopName": "GoodBuddy",
|
"desktopName": "GoodBuddy",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { extname } from 'node:path'
|
import { extname } from 'node:path'
|
||||||
|
export { detectSupportedImage } from '../shared/image-media-type'
|
||||||
|
|
||||||
const mimeTypes: Readonly<Record<string, string>> = {
|
const mimeTypes: Readonly<Record<string, string>> = {
|
||||||
'.c': 'text/x-c',
|
'.c': 'text/x-c',
|
||||||
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
|
|||||||
): string {
|
): string {
|
||||||
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
|
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectSupportedImage(data: Buffer): {
|
|
||||||
extension: 'jpg' | 'png' | 'webp'
|
|
||||||
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
|
|
||||||
} {
|
|
||||||
if (
|
|
||||||
data.byteLength >= 3 &&
|
|
||||||
data[0] === 0xff &&
|
|
||||||
data[1] === 0xd8 &&
|
|
||||||
data[2] === 0xff
|
|
||||||
) {
|
|
||||||
return { extension: 'jpg', mimeType: 'image/jpeg' }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.byteLength >= 8 &&
|
|
||||||
data.subarray(0, 8).equals(
|
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return { extension: 'png', mimeType: 'image/png' }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.byteLength >= 12 &&
|
|
||||||
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
||||||
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
||||||
) {
|
|
||||||
return { extension: 'webp', mimeType: 'image/webp' }
|
|
||||||
}
|
|
||||||
throw new Error('图片格式不受支持')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import type {
|
|||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
|
import {
|
||||||
|
loadBrandingPreferences,
|
||||||
|
saveBrandingPreferences
|
||||||
|
} from './branding'
|
||||||
import type {
|
import type {
|
||||||
AssistantProject,
|
AssistantProject,
|
||||||
AssistantSchedule,
|
AssistantSchedule,
|
||||||
@@ -1343,6 +1347,33 @@ describe('App', () => {
|
|||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads customized branding into the primary sidebar', async () => {
|
||||||
|
expect(
|
||||||
|
saveBrandingPreferences({
|
||||||
|
name: '研发伙伴',
|
||||||
|
subtitleZhCN: '团队工作台',
|
||||||
|
subtitleEnUS: 'Team Workspace',
|
||||||
|
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: '今天想一起完成什么?'
|
||||||
|
})
|
||||||
|
const brand = document.querySelector('.brand')
|
||||||
|
expect(brand).not.toBeNull()
|
||||||
|
expect(within(brand as HTMLElement).getByText('研发伙伴'))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(within(brand as HTMLElement).getByText('团队工作台'))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(brand?.querySelector('img')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
|
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
|
||||||
const conversationId =
|
const conversationId =
|
||||||
'00000000-0000-4000-8000-000000000951'
|
'00000000-0000-4000-8000-000000000951'
|
||||||
@@ -1751,6 +1782,41 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saves branding from Appearance and updates the sidebar immediately', async () => {
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /本地工作区/u
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('tab', { name: '外观' })
|
||||||
|
)
|
||||||
|
fireEvent.change(screen.getByLabelText('品牌名称'), {
|
||||||
|
target: { value: '研发伙伴' }
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('中文副标题'), {
|
||||||
|
target: { value: '团队工作台' }
|
||||||
|
})
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '保存品牌设置' })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('品牌设置已保存'))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
const brand = document.querySelector('.brand')
|
||||||
|
expect(brand).not.toBeNull()
|
||||||
|
expect(within(brand as HTMLElement).getByText('研发伙伴'))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(within(brand as HTMLElement).getByText('团队工作台'))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(loadBrandingPreferences()).toMatchObject({
|
||||||
|
name: '研发伙伴',
|
||||||
|
subtitleZhCN: '团队工作台'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
|
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
const activity = await screen.findByRole('button', {
|
const activity = await screen.findByRole('button', {
|
||||||
@@ -8710,7 +8776,7 @@ describe('App', () => {
|
|||||||
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
|
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
|
||||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
||||||
'placeholder',
|
'placeholder',
|
||||||
'描述你想生成的图片…'
|
'描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
|
||||||
)
|
)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.artifacts.list).toHaveBeenCalled()
|
expect(api.artifacts.list).toHaveBeenCalled()
|
||||||
|
|||||||
+40
-14
@@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel'
|
|||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
||||||
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
||||||
|
import {
|
||||||
|
loadBrandingPreferences,
|
||||||
|
saveBrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
import { BrandLockup } from './BrandLockup'
|
||||||
import {
|
import {
|
||||||
applyAppearanceTheme,
|
applyAppearanceTheme,
|
||||||
loadAppearanceTheme,
|
loadAppearanceTheme,
|
||||||
@@ -2084,6 +2089,9 @@ function App(): React.JSX.Element {
|
|||||||
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
||||||
const [appearanceTheme, setAppearanceTheme] =
|
const [appearanceTheme, setAppearanceTheme] =
|
||||||
useState<AppearanceTheme>(loadAppearanceTheme)
|
useState<AppearanceTheme>(loadAppearanceTheme)
|
||||||
|
const [brandingPreferences, setBrandingPreferences] = useState(
|
||||||
|
loadBrandingPreferences
|
||||||
|
)
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
||||||
() =>
|
() =>
|
||||||
typeof window.matchMedia === 'function' &&
|
typeof window.matchMedia === 'function' &&
|
||||||
@@ -2093,6 +2101,10 @@ function App(): React.JSX.Element {
|
|||||||
appearanceTheme,
|
appearanceTheme,
|
||||||
systemPrefersDark
|
systemPrefersDark
|
||||||
)
|
)
|
||||||
|
const brandingSubtitle =
|
||||||
|
locale === 'en-US'
|
||||||
|
? brandingPreferences.subtitleEnUS
|
||||||
|
: brandingPreferences.subtitleZhCN
|
||||||
const toggleAppearanceTheme = useCallback((): void => {
|
const toggleAppearanceTheme = useCallback((): void => {
|
||||||
setAppearanceTheme(
|
setAppearanceTheme(
|
||||||
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
|
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
|
||||||
@@ -7902,23 +7914,19 @@ function App(): React.JSX.Element {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
||||||
>
|
>
|
||||||
<div className="brand">
|
<BrandLockup
|
||||||
<div className="brand__mark">
|
className="brand"
|
||||||
<img
|
copyClassName="brand__copy"
|
||||||
alt=""
|
logo={
|
||||||
aria-hidden="true"
|
brandingPreferences.logoDataUrl ??
|
||||||
src={
|
(resolvedAppearanceTheme === 'dark'
|
||||||
resolvedAppearanceTheme === 'dark'
|
|
||||||
? goodbuddyDarkIcon
|
? goodbuddyDarkIcon
|
||||||
: goodbuddyLightIcon
|
: goodbuddyLightIcon)
|
||||||
}
|
}
|
||||||
|
markClassName="brand__mark"
|
||||||
|
name={brandingPreferences.name}
|
||||||
|
subtitle={brandingSubtitle || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="brand__copy">
|
|
||||||
<strong>GoodBuddy</strong>
|
|
||||||
<span>{t('brand.subtitle')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectSwitcher
|
<ProjectSwitcher
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
@@ -10126,11 +10134,29 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
appearanceTheme={appearanceTheme}
|
appearanceTheme={appearanceTheme}
|
||||||
|
brandingFallbackLogo={
|
||||||
|
resolvedAppearanceTheme === 'dark'
|
||||||
|
? goodbuddyDarkIcon
|
||||||
|
: goodbuddyLightIcon
|
||||||
|
}
|
||||||
|
brandingPreferences={brandingPreferences}
|
||||||
initialCategory={settingsInitialCategory}
|
initialCategory={settingsInitialCategory}
|
||||||
initialChannel={settingsInitialChannel}
|
initialChannel={settingsInitialChannel}
|
||||||
magicNotesEnabled={magicNotesEnabled}
|
magicNotesEnabled={magicNotesEnabled}
|
||||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||||
onAppearanceThemeChange={setAppearanceTheme}
|
onAppearanceThemeChange={setAppearanceTheme}
|
||||||
|
onBrandingPreferencesChange={(preferences) => {
|
||||||
|
if (!saveBrandingPreferences(preferences)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setBrandingPreferences(preferences)
|
||||||
|
notify({
|
||||||
|
tone: 'success',
|
||||||
|
message: t('notices.brandingSaved'),
|
||||||
|
dedupeKey: 'branding-saved'
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}}
|
||||||
onClearLocalData={clearLocalData}
|
onClearLocalData={clearLocalData}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSettingsInitialCategory(undefined)
|
setSettingsInitialCategory(undefined)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
type BrandLockupProps = {
|
||||||
|
ariaLabel?: string
|
||||||
|
className: string
|
||||||
|
copyClassName: string
|
||||||
|
logo?: string
|
||||||
|
markClassName: string
|
||||||
|
name: string
|
||||||
|
role?: 'img'
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandLockup({
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
copyClassName,
|
||||||
|
logo,
|
||||||
|
markClassName,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
subtitle
|
||||||
|
}: BrandLockupProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} className={className} role={role}>
|
||||||
|
<div className={markClassName}>
|
||||||
|
{logo && <img alt="" aria-hidden="true" src={logo} />}
|
||||||
|
</div>
|
||||||
|
<div className={copyClassName}>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
{subtitle && <span>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
brandingLogoMaximumBytes,
|
||||||
|
brandingNameMaximumLength,
|
||||||
|
brandingPreferencesEqual,
|
||||||
|
brandingSubtitleMaximumLength,
|
||||||
|
defaultBrandingPreferences,
|
||||||
|
isValidBrandingLogoDataUrl,
|
||||||
|
normalizeBrandingPreferences,
|
||||||
|
type BrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
import { BrandLockup } from './BrandLockup'
|
||||||
|
import {
|
||||||
|
loadImageDimensions,
|
||||||
|
readFileAsDataUrl
|
||||||
|
} from './file-data-url'
|
||||||
|
|
||||||
|
type BrandingSettingsSectionProps = {
|
||||||
|
preferences: BrandingPreferences
|
||||||
|
fallbackLogo?: string
|
||||||
|
onChange: (preferences: BrandingPreferences) => boolean
|
||||||
|
onDirtyChange?: (dirty: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandingSettingsSection({
|
||||||
|
preferences,
|
||||||
|
fallbackLogo,
|
||||||
|
onChange,
|
||||||
|
onDirtyChange
|
||||||
|
}: BrandingSettingsSectionProps): React.JSX.Element {
|
||||||
|
const { i18n, t } = useTranslation('settings')
|
||||||
|
const [draft, setDraft] = useState(preferences)
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const logoReadRef = useRef<AbortController>(null)
|
||||||
|
const dirty = !brandingPreferencesEqual(draft, preferences)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty)
|
||||||
|
}, [dirty, onDirtyChange])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
logoReadRef.current?.abort()
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const save = (): void => {
|
||||||
|
const normalized = normalizeBrandingPreferences(draft)
|
||||||
|
if (!onChange(normalized)) {
|
||||||
|
setError(t('appearance.branding.errors.saveFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDraft(normalized)
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectLogo = async (file: File | undefined): Promise<void> => {
|
||||||
|
logoReadRef.current?.abort()
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)
|
||||||
|
) {
|
||||||
|
setError(t('appearance.branding.errors.unsupportedLogo'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size === 0 || file.size > brandingLogoMaximumBytes) {
|
||||||
|
setError(t('appearance.branding.errors.logoTooLarge'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
logoReadRef.current = controller
|
||||||
|
try {
|
||||||
|
const dataUrl = await readFileAsDataUrl(
|
||||||
|
file,
|
||||||
|
file.type,
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
if (!isValidBrandingLogoDataUrl(dataUrl)) {
|
||||||
|
setError(t('appearance.branding.errors.invalidLogo'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { width, height } = await loadImageDimensions(
|
||||||
|
dataUrl,
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0 ||
|
||||||
|
width > 4_096 ||
|
||||||
|
height > 4_096 ||
|
||||||
|
width * height > 16_000_000
|
||||||
|
) {
|
||||||
|
setError(t('appearance.branding.errors.invalidLogo'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (logoReadRef.current !== controller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
logoDataUrl: dataUrl
|
||||||
|
}))
|
||||||
|
setError(undefined)
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(t('appearance.branding.errors.readLogoFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (logoReadRef.current === controller) {
|
||||||
|
logoReadRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewSubtitle =
|
||||||
|
(i18n.resolvedLanguage === 'en-US'
|
||||||
|
? draft.subtitleEnUS
|
||||||
|
: draft.subtitleZhCN) || undefined
|
||||||
|
const previewLogo = draft.logoDataUrl ?? fallbackLogo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section appearance-settings branding-settings">
|
||||||
|
<div className="settings-section__title">
|
||||||
|
<ImagePlus aria-hidden="true" size={17} />
|
||||||
|
<div>
|
||||||
|
<strong>{t('appearance.branding.title')}</strong>
|
||||||
|
<small>{t('appearance.branding.description')}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrandLockup
|
||||||
|
ariaLabel={t('appearance.branding.previewLabel')}
|
||||||
|
className="branding-settings__preview"
|
||||||
|
copyClassName="branding-settings__preview-copy"
|
||||||
|
logo={previewLogo}
|
||||||
|
markClassName="branding-settings__preview-mark"
|
||||||
|
name={draft.name || defaultBrandingPreferences.name}
|
||||||
|
role="img"
|
||||||
|
subtitle={previewSubtitle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="branding-settings__fields">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('appearance.branding.fields.name')}</span>
|
||||||
|
<input
|
||||||
|
maxLength={brandingNameMaximumLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={draft.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('appearance.branding.fields.subtitleZhCN')}</span>
|
||||||
|
<input
|
||||||
|
lang="zh-CN"
|
||||||
|
maxLength={brandingSubtitleMaximumLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
subtitleZhCN: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={draft.subtitleZhCN}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('appearance.branding.fields.subtitleEnUS')}</span>
|
||||||
|
<input
|
||||||
|
lang="en-US"
|
||||||
|
maxLength={brandingSubtitleMaximumLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
subtitleEnUS: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={draft.subtitleEnUS}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="branding-settings__logo">
|
||||||
|
<input
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
aria-label={t('appearance.branding.logo.select')}
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
void selectLogo(event.target.files?.[0])
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ImagePlus aria-hidden="true" size={13} />
|
||||||
|
{t('appearance.branding.logo.select')}
|
||||||
|
</button>
|
||||||
|
{draft.logoDataUrl && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
logoReadRef.current?.abort()
|
||||||
|
setDraft((current) => ({
|
||||||
|
name: current.name,
|
||||||
|
subtitleZhCN: current.subtitleZhCN,
|
||||||
|
subtitleEnUS: current.subtitleEnUS
|
||||||
|
}))
|
||||||
|
setError(undefined)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={13} />
|
||||||
|
{t('appearance.branding.logo.useDefault')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<small>{t('appearance.branding.logo.help')}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="settings-warning" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="update-settings__actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={brandingPreferencesEqual(
|
||||||
|
draft,
|
||||||
|
defaultBrandingPreferences
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
logoReadRef.current?.abort()
|
||||||
|
setDraft({ ...defaultBrandingPreferences })
|
||||||
|
setError(undefined)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" size={13} />
|
||||||
|
{t('appearance.branding.actions.restore')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!dirty}
|
||||||
|
onClick={save}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Save aria-hidden="true" size={13} />
|
||||||
|
{t('appearance.branding.actions.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type FeedbackSubmissionErrorCode
|
type FeedbackSubmissionErrorCode
|
||||||
} from '../../shared/feedback-contracts'
|
} from '../../shared/feedback-contracts'
|
||||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||||
|
import { loadImageDimensions } from './file-data-url'
|
||||||
|
|
||||||
type FeedbackDialogProps = {
|
type FeedbackDialogProps = {
|
||||||
appInfo: AppInfo
|
appInfo: AppInfo
|
||||||
@@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string {
|
|||||||
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
|
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageDimensions(
|
|
||||||
objectUrl: string
|
|
||||||
): Promise<{ width: number; height: number }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = new Image()
|
|
||||||
image.onload = () =>
|
|
||||||
resolve({
|
|
||||||
width: image.naturalWidth,
|
|
||||||
height: image.naturalHeight
|
|
||||||
})
|
|
||||||
image.onerror = () => reject(new Error('Image could not be decoded'))
|
|
||||||
image.src = objectUrl
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedbackDialog({
|
export function FeedbackDialog({
|
||||||
appInfo,
|
appInfo,
|
||||||
onClose
|
onClose
|
||||||
@@ -174,7 +160,7 @@ export function FeedbackDialog({
|
|||||||
}
|
}
|
||||||
objectUrl = URL.createObjectURL(file)
|
objectUrl = URL.createObjectURL(file)
|
||||||
pendingScreenshotUrlsRef.current.add(objectUrl)
|
pendingScreenshotUrlsRef.current.add(objectUrl)
|
||||||
const { width, height } = await imageDimensions(objectUrl)
|
const { width, height } = await loadImageDimensions(objectUrl)
|
||||||
if (selection !== screenshotSelectionRef.current) {
|
if (selection !== screenshotSelectionRef.current) {
|
||||||
pendingScreenshotUrlsRef.current.delete(objectUrl)
|
pendingScreenshotUrlsRef.current.delete(objectUrl)
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
magicNoteDataBytes,
|
magicNoteDataBytes,
|
||||||
type MagicNoteRichContent
|
type MagicNoteRichContent
|
||||||
} from '../../shared/magic-notes-contracts'
|
} from '../../shared/magic-notes-contracts'
|
||||||
|
import { readFileAsDataUrl } from './file-data-url'
|
||||||
|
|
||||||
const supportedImageTypes = new Set([
|
const supportedImageTypes = new Set([
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
@@ -41,32 +42,6 @@ export type MagicNoteEditorProps = {
|
|||||||
onParagraphCommit?: (content: MagicNoteRichContent) => void
|
onParagraphCommit?: (content: MagicNoteRichContent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(
|
|
||||||
file: File,
|
|
||||||
mimeType: string,
|
|
||||||
readFailedMessage: string
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result !== 'string') {
|
|
||||||
reject(new Error(readFailedMessage))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const separatorIndex = reader.result.indexOf(',')
|
|
||||||
if (separatorIndex < 0) {
|
|
||||||
reject(new Error(readFailedMessage))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve(
|
|
||||||
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
reader.onerror = () => reject(new Error(readFailedMessage))
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeEmbeddedFileName(file: File): string {
|
function safeEmbeddedFileName(file: File): string {
|
||||||
const fallback = file.type.startsWith('video/')
|
const fallback = file.type.startsWith('video/')
|
||||||
? 'video'
|
? 'video'
|
||||||
@@ -273,9 +248,6 @@ export function MagicNoteEditor({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const readFailedMessage = translateRef.current(
|
|
||||||
'editor.fileReadFailed'
|
|
||||||
)
|
|
||||||
const embeds = await Promise.all(
|
const embeds = await Promise.all(
|
||||||
classified.map(async ({ file, kind, mimeType }) => ({
|
classified.map(async ({ file, kind, mimeType }) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -283,11 +255,7 @@ export function MagicNoteEditor({
|
|||||||
name: safeEmbeddedFileName(file),
|
name: safeEmbeddedFileName(file),
|
||||||
mimeType,
|
mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: await readFileAsDataUrl(
|
dataUrl: await readFileAsDataUrl(file, mimeType)
|
||||||
file,
|
|
||||||
mimeType,
|
|
||||||
readFailedMessage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
@@ -302,12 +270,8 @@ export function MagicNoteEditor({
|
|||||||
index += 2
|
index += 2
|
||||||
}
|
}
|
||||||
quill.setSelection(index, 0, 'silent')
|
quill.setSelection(index, 0, 'silent')
|
||||||
} catch (error) {
|
} catch {
|
||||||
onErrorRef.current(
|
onErrorRef.current(translateRef.current('editor.fileReadFailed'))
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: translateRef.current('editor.fileReadFailed')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,21 @@ import {
|
|||||||
} from './RuntimeCustomizationSection'
|
} from './RuntimeCustomizationSection'
|
||||||
import { changeUiLocale } from './i18n'
|
import { changeUiLocale } from './i18n'
|
||||||
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
||||||
|
import type { BrandingPreferences } from './branding'
|
||||||
|
|
||||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||||
|
const nativeImage = globalThis.Image
|
||||||
|
|
||||||
|
class DecodableBrandImage {
|
||||||
|
naturalWidth = 64
|
||||||
|
naturalHeight = 64
|
||||||
|
onload: (() => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
|
||||||
|
set src(_value: string) {
|
||||||
|
queueMicrotask(() => this.onload?.())
|
||||||
|
}
|
||||||
|
}
|
||||||
const browserProfileId = '00000000-0000-4000-8000-000000000201'
|
const browserProfileId = '00000000-0000-4000-8000-000000000201'
|
||||||
const runtimeSettings: RuntimeSettings = {
|
const runtimeSettings: RuntimeSettings = {
|
||||||
provider: 'auto',
|
provider: 'auto',
|
||||||
@@ -934,6 +947,10 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
Object.defineProperty(globalThis, 'Image', {
|
||||||
|
configurable: true,
|
||||||
|
value: nativeImage
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers system, light, and dark appearance modes', async () => {
|
it('offers system, light, and dark appearance modes', async () => {
|
||||||
@@ -963,6 +980,99 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('customizes and restores the sidebar brand from Appearance', async () => {
|
||||||
|
const onBrandingPreferencesChange = vi.fn<
|
||||||
|
(preferences: BrandingPreferences) => boolean
|
||||||
|
>(() => true)
|
||||||
|
|
||||||
|
function BrandingHarness(): React.JSX.Element {
|
||||||
|
const [preferences, setPreferences] = useState({
|
||||||
|
name: 'GoodBuddy',
|
||||||
|
subtitleZhCN: '智能工作台',
|
||||||
|
subtitleEnUS: 'Intelligent Workspace'
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<SettingsPanel
|
||||||
|
{...heartbeatSettingsProps}
|
||||||
|
brandingFallbackLogo="default-logo.png"
|
||||||
|
brandingPreferences={preferences}
|
||||||
|
onBrandingPreferencesChange={(next) => {
|
||||||
|
onBrandingPreferencesChange(next)
|
||||||
|
setPreferences(next)
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
onClearLocalData={vi.fn(async () => {})}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<BrandingHarness />)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: '品牌效果预览' }))
|
||||||
|
.toHaveTextContent('GoodBuddy')
|
||||||
|
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('品牌名称'), {
|
||||||
|
target: { value: '研发伙伴' }
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('中文副标题'), {
|
||||||
|
target: { value: '团队工作台' }
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('英文副标题'), {
|
||||||
|
target: { value: 'Team Workspace' }
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('img', { name: '品牌效果预览' }))
|
||||||
|
.toHaveTextContent('研发伙伴')
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'Image', {
|
||||||
|
configurable: true,
|
||||||
|
value: DecodableBrandImage
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('选择 Logo'), {
|
||||||
|
target: {
|
||||||
|
files: [
|
||||||
|
new File(
|
||||||
|
[
|
||||||
|
Uint8Array.from([
|
||||||
|
0x89,
|
||||||
|
0x50,
|
||||||
|
0x4e,
|
||||||
|
0x47,
|
||||||
|
0x0d,
|
||||||
|
0x0a,
|
||||||
|
0x1a,
|
||||||
|
0x0a
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'brand.png',
|
||||||
|
{ type: 'image/png' }
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await screen.findByRole('button', { name: '使用默认 Logo' })
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '保存品牌设置' })
|
||||||
|
)
|
||||||
|
expect(onBrandingPreferencesChange).toHaveBeenCalledWith({
|
||||||
|
name: '研发伙伴',
|
||||||
|
subtitleZhCN: '团队工作台',
|
||||||
|
subtitleEnUS: 'Team Workspace',
|
||||||
|
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }))
|
||||||
|
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
|
||||||
|
expect(screen.getByLabelText('中文副标题')).toHaveValue(
|
||||||
|
'智能工作台'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('configures direct model context compression with explicit token budgets', async () => {
|
it('configures direct model context compression with explicit token budgets', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
|||||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
|
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
|
||||||
|
import { BrandingSettingsSection } from './BrandingSettingsSection'
|
||||||
|
import {
|
||||||
|
defaultBrandingPreferences,
|
||||||
|
type BrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
|
||||||
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
||||||
type AgentRuntimeType =
|
type AgentRuntimeType =
|
||||||
@@ -218,6 +223,11 @@ type SettingsPanelProps = {
|
|||||||
onClearLocalData: () => Promise<void>
|
onClearLocalData: () => Promise<void>
|
||||||
appearanceTheme?: AppearanceTheme
|
appearanceTheme?: AppearanceTheme
|
||||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
|
brandingPreferences?: BrandingPreferences
|
||||||
|
brandingFallbackLogo?: string
|
||||||
|
onBrandingPreferencesChange?: (
|
||||||
|
preferences: BrandingPreferences
|
||||||
|
) => boolean
|
||||||
magicNotesEnabled?: boolean
|
magicNotesEnabled?: boolean
|
||||||
remoteProjectsEnabled?: boolean
|
remoteProjectsEnabled?: boolean
|
||||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||||
@@ -584,6 +594,9 @@ export function SettingsPanel({
|
|||||||
onExpertsChanged = () => {},
|
onExpertsChanged = () => {},
|
||||||
appearanceTheme = 'system',
|
appearanceTheme = 'system',
|
||||||
onAppearanceThemeChange = () => {},
|
onAppearanceThemeChange = () => {},
|
||||||
|
brandingPreferences = defaultBrandingPreferences,
|
||||||
|
brandingFallbackLogo,
|
||||||
|
onBrandingPreferencesChange = () => false,
|
||||||
magicNotesEnabled = false,
|
magicNotesEnabled = false,
|
||||||
remoteProjectsEnabled = false,
|
remoteProjectsEnabled = false,
|
||||||
onMagicNotesEnabledChange = () => {},
|
onMagicNotesEnabledChange = () => {},
|
||||||
@@ -598,6 +611,7 @@ export function SettingsPanel({
|
|||||||
setPreference: setUiLocalePreference
|
setPreference: setUiLocalePreference
|
||||||
} = useUiLocale()
|
} = useUiLocale()
|
||||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||||
|
const [brandingDirty, setBrandingDirty] = useState(false)
|
||||||
const [provider, setProvider] =
|
const [provider, setProvider] =
|
||||||
useState<RuntimeSettingsInput['provider']>(
|
useState<RuntimeSettingsInput['provider']>(
|
||||||
defaultRuntimeSettings.provider
|
defaultRuntimeSettings.provider
|
||||||
@@ -927,11 +941,13 @@ export function SettingsPanel({
|
|||||||
runtimeDraftDirty ||
|
runtimeDraftDirty ||
|
||||||
speechModelSelectionDirty ||
|
speechModelSelectionDirty ||
|
||||||
runtimeCustomizationDirty ||
|
runtimeCustomizationDirty ||
|
||||||
|
brandingDirty ||
|
||||||
platformFeaturesDirty ||
|
platformFeaturesDirty ||
|
||||||
documentParsingDirty ||
|
documentParsingDirty ||
|
||||||
sshHostsDirty
|
sshHostsDirty
|
||||||
const navigationWouldLoseDraft =
|
const navigationWouldLoseDraft =
|
||||||
runtimeCustomizationDirty ||
|
runtimeCustomizationDirty ||
|
||||||
|
brandingDirty ||
|
||||||
platformFeaturesDirty ||
|
platformFeaturesDirty ||
|
||||||
documentParsingDirty ||
|
documentParsingDirty ||
|
||||||
sshHostsDirty
|
sshHostsDirty
|
||||||
@@ -939,6 +955,7 @@ export function SettingsPanel({
|
|||||||
runtimeCustomizationDirty &&
|
runtimeCustomizationDirty &&
|
||||||
!runtimeDraftDirty &&
|
!runtimeDraftDirty &&
|
||||||
!speechModelSelectionDirty &&
|
!speechModelSelectionDirty &&
|
||||||
|
!brandingDirty &&
|
||||||
!platformFeaturesDirty &&
|
!platformFeaturesDirty &&
|
||||||
!documentParsingDirty &&
|
!documentParsingDirty &&
|
||||||
!sshHostsDirty
|
!sshHostsDirty
|
||||||
@@ -1188,6 +1205,7 @@ export function SettingsPanel({
|
|||||||
setSpeechModelDraftId(undefined)
|
setSpeechModelDraftId(undefined)
|
||||||
setPersistedSpeechModelId(undefined)
|
setPersistedSpeechModelId(undefined)
|
||||||
setSpeechModelSelectionDirty(false)
|
setSpeechModelSelectionDirty(false)
|
||||||
|
setBrandingDirty(false)
|
||||||
setPlatformFeaturesDirty(false)
|
setPlatformFeaturesDirty(false)
|
||||||
setDocumentParsingDirty(false)
|
setDocumentParsingDirty(false)
|
||||||
setSshHostsDirty(false)
|
setSshHostsDirty(false)
|
||||||
@@ -1213,6 +1231,7 @@ export function SettingsPanel({
|
|||||||
setSpeechModelSelectionDirty(false)
|
setSpeechModelSelectionDirty(false)
|
||||||
runtimeCustomizationRef.current?.discard()
|
runtimeCustomizationRef.current?.discard()
|
||||||
setRuntimeCustomizationDirty(false)
|
setRuntimeCustomizationDirty(false)
|
||||||
|
setBrandingDirty(false)
|
||||||
setPlatformFeaturesDirty(false)
|
setPlatformFeaturesDirty(false)
|
||||||
setDocumentParsingDirty(false)
|
setDocumentParsingDirty(false)
|
||||||
setSshHostsDirty(false)
|
setSshHostsDirty(false)
|
||||||
@@ -2110,6 +2129,12 @@ export function SettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BrandingSettingsSection
|
||||||
|
fallbackLogo={brandingFallbackLogo}
|
||||||
|
onChange={onBrandingPreferencesChange}
|
||||||
|
onDirtyChange={setBrandingDirty}
|
||||||
|
preferences={brandingPreferences}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'platform-features' && (
|
{activeTab === 'platform-features' && (
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
brandingLogoMaximumBytes,
|
||||||
|
brandingNameMaximumLength,
|
||||||
|
defaultBrandingPreferences,
|
||||||
|
loadBrandingPreferences,
|
||||||
|
normalizeBrandingPreferences,
|
||||||
|
saveBrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('branding preferences', () => {
|
||||||
|
it('uses defaults when no valid preference is stored', () => {
|
||||||
|
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
|
||||||
|
|
||||||
|
localStorage.setItem('goodbuddy.branding.v1', '{"name":true}')
|
||||||
|
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes text and accepts only bounded inline image data', () => {
|
||||||
|
expect(
|
||||||
|
normalizeBrandingPreferences({
|
||||||
|
name: ` ${'B'.repeat(brandingNameMaximumLength + 4)} `,
|
||||||
|
subtitleZhCN: ' 中文品牌 ',
|
||||||
|
subtitleEnUS: ' English brand ',
|
||||||
|
logoDataUrl: 'https://example.com/logo.png'
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
name: 'B'.repeat(brandingNameMaximumLength),
|
||||||
|
subtitleZhCN: '中文品牌',
|
||||||
|
subtitleEnUS: 'English brand'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeBrandingPreferences({
|
||||||
|
name: ' ',
|
||||||
|
subtitleZhCN: '',
|
||||||
|
subtitleEnUS: '',
|
||||||
|
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
name: 'GoodBuddy',
|
||||||
|
subtitleZhCN: '',
|
||||||
|
subtitleEnUS: '',
|
||||||
|
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists and reloads valid preferences', () => {
|
||||||
|
const preferences = {
|
||||||
|
name: '团队助手',
|
||||||
|
subtitleZhCN: '研发工作台',
|
||||||
|
subtitleEnUS: 'Engineering Workspace',
|
||||||
|
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(saveBrandingPreferences(preferences)).toBe(true)
|
||||||
|
expect(loadBrandingPreferences()).toEqual(preferences)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a logo whose bytes do not match its declared type', () => {
|
||||||
|
expect(
|
||||||
|
normalizeBrandingPreferences({
|
||||||
|
...defaultBrandingPreferences,
|
||||||
|
logoDataUrl: 'data:image/jpeg;base64,iVBORw0KGgo='
|
||||||
|
})
|
||||||
|
).toEqual(defaultBrandingPreferences)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects oversized encoded logos before decoding them', () => {
|
||||||
|
const decode = vi.spyOn(globalThis, 'atob')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeBrandingPreferences({
|
||||||
|
...defaultBrandingPreferences,
|
||||||
|
logoDataUrl: `data:image/png;base64,${'A'.repeat(
|
||||||
|
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 8
|
||||||
|
)}`
|
||||||
|
})
|
||||||
|
).toEqual(defaultBrandingPreferences)
|
||||||
|
expect(decode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports unavailable local storage without throwing', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
saveBrandingPreferences(defaultBrandingPreferences)
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { detectSupportedImage } from '../../shared/image-media-type'
|
||||||
|
|
||||||
|
export type BrandingPreferences = {
|
||||||
|
name: string
|
||||||
|
subtitleZhCN: string
|
||||||
|
subtitleEnUS: string
|
||||||
|
logoDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBrandingPreferences: BrandingPreferences = {
|
||||||
|
name: 'GoodBuddy',
|
||||||
|
subtitleZhCN: '智能工作台',
|
||||||
|
subtitleEnUS: 'Intelligent Workspace'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const brandingNameMaximumLength = 32
|
||||||
|
export const brandingSubtitleMaximumLength = 48
|
||||||
|
export const brandingLogoMaximumBytes = 512 * 1024
|
||||||
|
|
||||||
|
const storageKey = 'goodbuddy.branding.v1'
|
||||||
|
const supportedLogoDataUrlPattern =
|
||||||
|
/^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/]+={0,2})$/iu
|
||||||
|
|
||||||
|
export function isValidBrandingLogoDataUrl(value: string): boolean {
|
||||||
|
const match = supportedLogoDataUrlPattern.exec(value)
|
||||||
|
if (!match) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const encoded = match[2]!
|
||||||
|
if (
|
||||||
|
encoded.length >
|
||||||
|
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 4
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const decoded = atob(encoded)
|
||||||
|
if (
|
||||||
|
decoded.length === 0 ||
|
||||||
|
decoded.length > brandingLogoMaximumBytes
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const header = Uint8Array.from(
|
||||||
|
decoded
|
||||||
|
.slice(0, 12)
|
||||||
|
.split('')
|
||||||
|
.map((character) => character.charCodeAt(0))
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
detectSupportedImage(header).mimeType === match[1]!.toLowerCase()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrandingPreferences(
|
||||||
|
value: BrandingPreferences
|
||||||
|
): BrandingPreferences {
|
||||||
|
const name = value.name.trim()
|
||||||
|
const subtitleZhCN = value.subtitleZhCN.trim()
|
||||||
|
const subtitleEnUS = value.subtitleEnUS.trim()
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
name.length > 0
|
||||||
|
? name.slice(0, brandingNameMaximumLength)
|
||||||
|
: defaultBrandingPreferences.name,
|
||||||
|
subtitleZhCN: subtitleZhCN.slice(
|
||||||
|
0,
|
||||||
|
brandingSubtitleMaximumLength
|
||||||
|
),
|
||||||
|
subtitleEnUS: subtitleEnUS.slice(
|
||||||
|
0,
|
||||||
|
brandingSubtitleMaximumLength
|
||||||
|
),
|
||||||
|
...(value.logoDataUrl &&
|
||||||
|
isValidBrandingLogoDataUrl(value.logoDataUrl)
|
||||||
|
? { logoDataUrl: value.logoDataUrl }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBrandingPreferences(): BrandingPreferences {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(storageKey)
|
||||||
|
if (!value) {
|
||||||
|
return { ...defaultBrandingPreferences }
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
if (
|
||||||
|
typeof parsed !== 'object' ||
|
||||||
|
parsed === null ||
|
||||||
|
typeof (parsed as Record<string, unknown>).name !== 'string' ||
|
||||||
|
typeof (parsed as Record<string, unknown>).subtitleZhCN !==
|
||||||
|
'string' ||
|
||||||
|
typeof (parsed as Record<string, unknown>).subtitleEnUS !==
|
||||||
|
'string'
|
||||||
|
) {
|
||||||
|
return { ...defaultBrandingPreferences }
|
||||||
|
}
|
||||||
|
const candidate = parsed as BrandingPreferences
|
||||||
|
return normalizeBrandingPreferences({
|
||||||
|
name: candidate.name,
|
||||||
|
subtitleZhCN: candidate.subtitleZhCN,
|
||||||
|
subtitleEnUS: candidate.subtitleEnUS,
|
||||||
|
...(typeof candidate.logoDataUrl === 'string'
|
||||||
|
? { logoDataUrl: candidate.logoDataUrl }
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return { ...defaultBrandingPreferences }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBrandingPreferences(
|
||||||
|
value: BrandingPreferences
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify(normalizeBrandingPreferences(value))
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandingPreferencesEqual(
|
||||||
|
left: BrandingPreferences,
|
||||||
|
right: BrandingPreferences
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.name === right.name &&
|
||||||
|
left.subtitleZhCN === right.subtitleZhCN &&
|
||||||
|
left.subtitleEnUS === right.subtitleEnUS &&
|
||||||
|
left.logoDataUrl === right.logoDataUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export function readFileAsDataUrl(
|
||||||
|
file: Blob,
|
||||||
|
mimeType: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
const cleanup = (): void => {
|
||||||
|
signal?.removeEventListener('abort', abort)
|
||||||
|
reader.onload = null
|
||||||
|
reader.onerror = null
|
||||||
|
reader.onabort = null
|
||||||
|
}
|
||||||
|
const fail = (): void => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error('File could not be read'))
|
||||||
|
}
|
||||||
|
const abort = (): void => {
|
||||||
|
reader.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', abort, { once: true })
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result !== 'string') {
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const separatorIndex = reader.result.indexOf(',')
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dataUrl =
|
||||||
|
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
|
||||||
|
cleanup()
|
||||||
|
resolve(dataUrl)
|
||||||
|
}
|
||||||
|
reader.onerror = fail
|
||||||
|
reader.onabort = fail
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadImageDimensions(
|
||||||
|
source: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
const cleanup = (): void => {
|
||||||
|
signal?.removeEventListener('abort', abort)
|
||||||
|
image.onload = null
|
||||||
|
image.onerror = null
|
||||||
|
}
|
||||||
|
const abort = (): void => {
|
||||||
|
cleanup()
|
||||||
|
image.src = ''
|
||||||
|
reject(new Error('Image loading was cancelled'))
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', abort, { once: true })
|
||||||
|
image.onload = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve({
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
image.onerror = () => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error('Image could not be decoded'))
|
||||||
|
}
|
||||||
|
image.src = source
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,9 +2,6 @@ import type { TranslationShape } from '../../resource-types'
|
|||||||
import type { app as chineseApp } from '../zh-CN/app'
|
import type { app as chineseApp } from '../zh-CN/app'
|
||||||
|
|
||||||
export const app = {
|
export const app = {
|
||||||
brand: {
|
|
||||||
subtitle: 'Intelligent Workspace'
|
|
||||||
},
|
|
||||||
notifications: {
|
notifications: {
|
||||||
success: 'Success',
|
success: 'Success',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
@@ -567,6 +564,7 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
notices: {
|
notices: {
|
||||||
|
brandingSaved: 'Branding saved',
|
||||||
updateAvailable:
|
updateAvailable:
|
||||||
'GoodBuddy {{version}} is available. View it in About & Updates.',
|
'GoodBuddy {{version}} is available. View it in About & Updates.',
|
||||||
remoteProjectActivated:
|
remoteProjectActivated:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const settings = {
|
|||||||
categories: {
|
categories: {
|
||||||
appearance: {
|
appearance: {
|
||||||
label: 'Appearance',
|
label: 'Appearance',
|
||||||
navigationDescription: 'Theme and interface language',
|
navigationDescription: 'Theme, language, and branding',
|
||||||
description: 'Theme and interface language'
|
description: 'Theme, language, and brand customization'
|
||||||
},
|
},
|
||||||
platformFeatures: {
|
platformFeatures: {
|
||||||
label: 'Platform features',
|
label: 'Platform features',
|
||||||
@@ -879,6 +879,36 @@ export const settings = {
|
|||||||
description: 'Use the English interface'
|
description: 'Use the English interface'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
branding: {
|
||||||
|
title: 'Brand customization',
|
||||||
|
description:
|
||||||
|
'Customize the brand name, subtitle, and logo in the top-left corner',
|
||||||
|
previewLabel: 'Brand preview',
|
||||||
|
fields: {
|
||||||
|
name: 'Brand name',
|
||||||
|
subtitleZhCN: 'Chinese subtitle',
|
||||||
|
subtitleEnUS: 'English subtitle'
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
select: 'Choose logo',
|
||||||
|
useDefault: 'Use default logo',
|
||||||
|
help:
|
||||||
|
'Supports PNG, JPEG, or WebP up to 4096 × 4096 and 512 KB'
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
restore: 'Restore defaults',
|
||||||
|
save: 'Save branding'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
unsupportedLogo: 'Choose a PNG, JPEG, or WebP image',
|
||||||
|
logoTooLarge: 'The logo file cannot exceed 512 KB',
|
||||||
|
invalidLogo:
|
||||||
|
'The logo is invalid, has the wrong format, or is too large',
|
||||||
|
readLogoFailed: 'The logo could not be read. Choose it again.',
|
||||||
|
saveFailed:
|
||||||
|
'Branding could not be saved. Check local storage and try again.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} satisfies TranslationShape<typeof chineseSettings>
|
} satisfies TranslationShape<typeof chineseSettings>
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
export const app = {
|
export const app = {
|
||||||
brand: {
|
|
||||||
subtitle: '智能工作台'
|
|
||||||
},
|
|
||||||
notifications: {
|
notifications: {
|
||||||
success: '成功',
|
success: '成功',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
@@ -533,6 +530,7 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
notices: {
|
notices: {
|
||||||
|
brandingSaved: '品牌设置已保存',
|
||||||
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
|
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
|
||||||
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
|
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
|
||||||
remoteProjectActivationFailed:
|
remoteProjectActivationFailed:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const settings = {
|
|||||||
categories: {
|
categories: {
|
||||||
appearance: {
|
appearance: {
|
||||||
label: '外观',
|
label: '外观',
|
||||||
navigationDescription: '主题与界面语言',
|
navigationDescription: '主题、语言与品牌',
|
||||||
description: '主题与界面语言'
|
description: '主题、语言与品牌定制'
|
||||||
},
|
},
|
||||||
platformFeatures: {
|
platformFeatures: {
|
||||||
label: '平台功能',
|
label: '平台功能',
|
||||||
@@ -809,6 +809,33 @@ export const settings = {
|
|||||||
description: 'Use the English interface'
|
description: 'Use the English interface'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
branding: {
|
||||||
|
title: '品牌定制',
|
||||||
|
description: '定制左上角品牌名称、副标题与 Logo',
|
||||||
|
previewLabel: '品牌效果预览',
|
||||||
|
fields: {
|
||||||
|
name: '品牌名称',
|
||||||
|
subtitleZhCN: '中文副标题',
|
||||||
|
subtitleEnUS: '英文副标题'
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
select: '选择 Logo',
|
||||||
|
useDefault: '使用默认 Logo',
|
||||||
|
help:
|
||||||
|
'支持 PNG、JPEG 或 WebP,尺寸不超过 4096 × 4096,文件不能超过 512 KB'
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
restore: '恢复默认',
|
||||||
|
save: '保存品牌设置'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
unsupportedLogo: '请选择 PNG、JPEG 或 WebP 图片',
|
||||||
|
logoTooLarge: 'Logo 文件不能超过 512 KB',
|
||||||
|
invalidLogo: 'Logo 图片无效、格式不符或尺寸过大',
|
||||||
|
readLogoFailed: '读取 Logo 失败,请重新选择',
|
||||||
|
saveFailed: '保存品牌设置失败,请检查本地存储后重试'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -2359,10 +2359,18 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.brand__copy {
|
.brand__copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand__copy strong,
|
||||||
|
.brand__copy span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.brand__copy strong {
|
.brand__copy strong {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -13736,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--accent-selected);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-mark img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-copy strong,
|
||||||
|
.branding-settings__preview-copy span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-copy strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-section-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__preview-copy span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__logo button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-settings__logo small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-options {
|
.appearance-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -14495,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branding-settings__fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-picker {
|
.workspace-picker {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { detectSupportedImage } from './image-media-type'
|
||||||
|
|
||||||
|
describe('supported image detection', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
bytes: [0xff, 0xd8, 0xff],
|
||||||
|
extension: 'jpg',
|
||||||
|
mimeType: 'image/jpeg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||||
|
extension: 'png',
|
||||||
|
mimeType: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bytes: [
|
||||||
|
0x52,
|
||||||
|
0x49,
|
||||||
|
0x46,
|
||||||
|
0x46,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0x57,
|
||||||
|
0x45,
|
||||||
|
0x42,
|
||||||
|
0x50
|
||||||
|
],
|
||||||
|
extension: 'webp',
|
||||||
|
mimeType: 'image/webp'
|
||||||
|
}
|
||||||
|
])('detects $mimeType bytes', ({ bytes, extension, mimeType }) => {
|
||||||
|
expect(detectSupportedImage(Uint8Array.from(bytes))).toEqual({
|
||||||
|
extension,
|
||||||
|
mimeType
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unsupported bytes', () => {
|
||||||
|
expect(() =>
|
||||||
|
detectSupportedImage(Uint8Array.from([1, 2, 3]))
|
||||||
|
).toThrow('图片格式不受支持')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type SupportedImageFormat = {
|
||||||
|
extension: 'jpg' | 'png' | 'webp'
|
||||||
|
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAscii(
|
||||||
|
data: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
value: string
|
||||||
|
): boolean {
|
||||||
|
return [...value].every(
|
||||||
|
(character, index) =>
|
||||||
|
data[offset + index] === character.charCodeAt(0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectSupportedImage(
|
||||||
|
data: Uint8Array
|
||||||
|
): SupportedImageFormat {
|
||||||
|
if (
|
||||||
|
data.byteLength >= 3 &&
|
||||||
|
data[0] === 0xff &&
|
||||||
|
data[1] === 0xd8 &&
|
||||||
|
data[2] === 0xff
|
||||||
|
) {
|
||||||
|
return { extension: 'jpg', mimeType: 'image/jpeg' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.byteLength >= 8 &&
|
||||||
|
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
|
||||||
|
(value, index) => data[index] === value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { extension: 'png', mimeType: 'image/png' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.byteLength >= 12 &&
|
||||||
|
matchesAscii(data, 0, 'RIFF') &&
|
||||||
|
matchesAscii(data, 8, 'WEBP')
|
||||||
|
) {
|
||||||
|
return { extension: 'webp', mimeType: 'image/webp' }
|
||||||
|
}
|
||||||
|
throw new Error('图片格式不受支持')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user