feat: enable project-scoped preferences extension tabs (#7803)

* feat: enable project-scoped preferences extension tabs

Add a typed extension schema so subprojects can define extra settings,
render them in the shared preferences drawer only when configured, and
consume them in playground as a real feature demo. Extension labels now
follow locale keys instead of hardcoded app-specific strings.

Constraint: Reuse the shared preferences drawer and field blocks
Rejected: Add app-specific fields to core preferences | too tightly coupled
Rejected: Inline localized label objects | breaks existing locale-key flow
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep extension labels as locale keys rendered via $t in UI
Tested: Vitest preferences tests
Tested: Turbo typecheck for preferences, layouts, web-antd, and playground
Tested: ESLint for touched preferences and playground files
Not-tested: Manual browser interaction in playground preferences drawer

* fix: satisfy lint formatting for preferences extension demo

Adjust the playground preferences extension demo template so formatter and
Vue template lint rules agree on the rendered markup. This keeps CI green
without changing runtime behavior.

Constraint: Must preserve the existing demo behavior while fixing CI only
Rejected: Disable the Vue newline rule | would weaken shared lint guarantees
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Prefer computed/template structures that avoid formatter-vs-lint conflicts
Tested: pnpm run lint
Not-tested: Manual browser interaction in playground preferences extension demo

* fix: harden custom preferences validation and i18n labels

Tighten custom preferences handling so numeric extension fields respect
min, max, and step constraints. Number inputs now ignore NaN values,
and web-antd extension metadata uses locale keys instead of raw strings.
Also align tip-based hover guards in shared preference inputs/selects.

Constraint: Keep fixes scoped to verified findings only
Rejected: Broader refactor of preferences field components | not needed for these fixes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Reuse the same validation path for updates and cache hydration
Tested: Vitest preferences tests
Tested: ESLint for touched preferences and widget files
Tested: Typecheck for web-antd, layouts, and core preferences
Not-tested: Manual browser interaction for all preference field variants

* fix: remove localized default from playground extension config

Drop the hardcoded Chinese default value from the playground extension
report title field and fall back to an empty string instead. This keeps
extension config locale-neutral while preserving localized labels and
placeholders through translation keys.

Constraint: Keep the fix limited to the verified localized default issue
Rejected: Compute the default from runtime locale in config | unnecessary for this finding
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Avoid embedding localized literals in extension default values
Tested: ESLint for playground/src/preferences.ts
Tested: Oxfmt check for playground/src/preferences.ts
Not-tested: Manual playground preferences interaction

* docs: document project-scoped preferences extension workflow

Add Chinese and English guide sections explaining how to define,
initialize, read, and update project-scoped preferences extensions.
Also document numeric field validation and point readers to the
playground demo for a complete example.

Constraint: Keep this docs-only and aligned with the shipped API
Rejected: Update only Chinese docs | would leave English docs inconsistent
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep zh/en examples and playground demo paths synchronized
Tested: git diff --check; pnpm build:docs
Not-tested: Manual browser review of the rendered docs site

* fix: harden custom preferences defaults and baselines

Use a locale-neutral default for the web-antd report title.
Also stop preference getters from exposing mutable baseline
or extension schema objects, and add a regression test for
external mutation attempts.

Constraint: Keep behavior compatible with the shipped preferences API
Rejected: Return raw refs with readonly typing only | callers could still mutate internals
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep defensive copies for baseline and schema getters unless storage semantics change
Tested: eslint, oxlint, targeted vitest, filtered typecheck, git diff --check
Not-tested: Full monorepo typecheck and test suite

* test: relax custom preference cache key matching

Avoid coupling the custom-number cache test to one exact
localStorage key string. Match the intended cache lookup
more loosely so the test still verifies filtering behavior
without depending on the full namespaced cache key.

Constraint: Focus the test on cache filtering behavior
Rejected: Assert one exact key | brittle with namespace changes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Prefer behavior tests over literal storage keys
Tested: targeted vitest, eslint, git diff --check
Not-tested: Full monorepo test suite

---------

Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
This commit is contained in:
Caisin
2026-04-13 15:11:57 +08:00
committed by GitHub
parent 5b84ac5b13
commit ccabbf0e97
24 changed files with 1720 additions and 39 deletions

View File

@@ -44,6 +44,7 @@
"loginExpired": "Login Expired",
"icons": "Icons",
"watermark": "Watermark",
"preferencesExtension": "Preferences Extension",
"tabs": "Tabs",
"tabDetail": "Tab Detail Page",
"fullScreen": "FullScreen",
@@ -52,6 +53,63 @@
"openInNewWindow": "Open in New Window",
"fileDownload": "File Download"
},
"preferencesExtensionDemo": {
"description": "This page directly uses extension preferences defined in the playground app. You can modify them in “Preferences → Playground Extension” at the top right, or click the preset buttons below to see the page update in real time.",
"currentConfig": "Current Extension Preferences",
"currentTitle": "Current Title: {title}",
"currentDescription": "Displaying {count} tasks by default; quick actions are {quickActionText}; the current highlight tone is “{tone}”.",
"showQuickActions": "shown",
"hideQuickActions": "hidden",
"boardDescription": "This is a real usage example: the page title, action area, visible row count, and highlight styles are all driven by extension preferences.",
"quickActionsEnabled": "Quick actions are currently disabled, so the page only keeps the read-only information view.",
"quickActions": {
"create": "Create Task",
"export": "Batch Export",
"refresh": "Refresh Data"
},
"presetTitle": "Quick Presets (demonstrates updateCustomPreferences)",
"presetButtons": {
"default": "Reset Default",
"compact": "Switch to Compact",
"review": "Switch to Review"
},
"presetTitles": {
"compact": "Compact Dashboard",
"default": "Weekly Operations Overview",
"review": "Review Dashboard"
},
"tones": {
"default": "Default",
"success": "Success",
"warning": "Warning"
},
"owner": "Owner"
},
"preferencesExtensionConfig": {
"tabLabel": "Playground Extension",
"title": "Playground Business Preferences",
"fields": {
"reportTitle": {
"label": "Report Title",
"placeholder": "Please enter the report title"
},
"defaultVisibleRows": {
"label": "Default Visible Rows",
"tip": "Controls how many task items are rendered by default on the demo page."
},
"enableQuickActions": {
"label": "Show Quick Actions"
},
"highlightTone": {
"label": "Highlight Tone",
"options": {
"default": "Default",
"success": "Success",
"warning": "Warning"
}
}
}
},
"breadcrumb": {
"navigation": "Breadcrumb Navigation",
"lateral": "Lateral Mode",

View File

@@ -44,6 +44,7 @@
"loginExpired": "登录过期",
"icons": "图标",
"watermark": "水印",
"preferencesExtension": "偏好扩展示例",
"tabs": "标签页",
"tabDetail": "标签详情页",
"fullScreen": "全屏",
@@ -53,6 +54,63 @@
"fileDownload": "文件下载",
"requestParamsSerializer": "参数序列化"
},
"preferencesExtensionDemo": {
"description": "这个页面直接读取 playground 子项目定义的拓展偏好。你可以在右上角的“偏好设置 → Playground 拓展”中修改字段,也可以点击下方预设按钮,页面会实时联动。",
"currentConfig": "当前拓展配置",
"currentTitle": "当前标题:{title}",
"currentDescription": "默认展示 {count} 条任务;{quickActionText} 快捷操作;当前高亮风格为“{tone}”。",
"showQuickActions": "显示",
"hideQuickActions": "隐藏",
"boardDescription": "这是一个“真实使用”的示例:页面标题、操作区、列表条数和高亮样式都由拓展偏好驱动。",
"quickActionsEnabled": "当前已关闭快捷操作,页面只保留只读信息展示。",
"quickActions": {
"create": "新建任务",
"export": "批量导出",
"refresh": "刷新数据"
},
"presetTitle": "快捷预设(演示 updateCustomPreferences 用法)",
"presetButtons": {
"default": "恢复默认",
"compact": "切换紧凑模式",
"review": "切换评审模式"
},
"presetTitles": {
"compact": "紧凑模式看板",
"default": "本周运营概览",
"review": "评审态工作看板"
},
"tones": {
"default": "默认",
"success": "成功",
"warning": "警告"
},
"owner": "负责人"
},
"preferencesExtensionConfig": {
"tabLabel": "Playground 拓展",
"title": "Playground 业务偏好",
"fields": {
"reportTitle": {
"label": "看板标题",
"placeholder": "请输入看板标题"
},
"defaultVisibleRows": {
"label": "默认展示条数",
"tip": "用于控制示例页中任务列表默认渲染多少条数据。"
},
"enableQuickActions": {
"label": "显示快捷操作"
},
"highlightTone": {
"label": "高亮风格",
"options": {
"default": "默认",
"success": "成功",
"warning": "警告"
}
}
}
},
"breadcrumb": {
"navigation": "面包屑导航",
"lateral": "平级模式",

View File

@@ -1,7 +1,7 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -15,6 +15,7 @@ async function initApplication() {
// app偏好设置初始化
await initPreferences({
extension: preferencesExtension,
namespace,
overrides: overridesPreferences,
});

View File

@@ -1,4 +1,14 @@
import { defineOverridesPreferences } from '@vben/preferences';
import {
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface PlaygroundPreferencesExtension {
defaultVisibleRows: number;
enableQuickActions: boolean;
highlightTone: 'default' | 'success' | 'warning';
reportTitle: string;
}
/**
* @description 项目配置文件
@@ -11,3 +21,64 @@ export const overridesPreferences = defineOverridesPreferences({
name: import.meta.env.VITE_APP_TITLE,
},
});
export type { PlaygroundPreferencesExtension };
export const preferencesExtension =
definePreferencesExtension<PlaygroundPreferencesExtension>({
tabLabel: 'demos.preferencesExtensionConfig.tabLabel',
title: 'demos.preferencesExtensionConfig.title',
fields: [
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'demos.preferencesExtensionConfig.fields.reportTitle.label',
placeholder:
'demos.preferencesExtensionConfig.fields.reportTitle.placeholder',
},
{
component: 'number',
componentProps: {
max: 8,
min: 1,
step: 1,
},
defaultValue: 4,
key: 'defaultVisibleRows',
label:
'demos.preferencesExtensionConfig.fields.defaultVisibleRows.label',
tip: 'demos.preferencesExtensionConfig.fields.defaultVisibleRows.tip',
},
{
component: 'switch',
defaultValue: true,
key: 'enableQuickActions',
label:
'demos.preferencesExtensionConfig.fields.enableQuickActions.label',
},
{
component: 'select',
defaultValue: 'default',
key: 'highlightTone',
label: 'demos.preferencesExtensionConfig.fields.highlightTone.label',
options: [
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.default',
value: 'default',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.success',
value: 'success',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.warning',
value: 'warning',
},
],
},
],
});

View File

@@ -124,6 +124,16 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.features.watermark'),
},
},
{
name: 'PreferencesExtensionDemo',
path: '/demos/features/preferences-extension',
component: () =>
import('#/views/demos/features/preferences-extension/index.vue'),
meta: {
icon: 'lucide:sliders-horizontal',
title: $t('demos.features.preferencesExtension'),
},
},
{
name: 'FeatureTabsDemo',
path: '/demos/features/tabs',

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { PlaygroundPreferencesExtension } from '#/preferences';
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import {
getCustomPreferences,
updateCustomPreferences,
} from '@vben/preferences';
import { Alert, Button, Card, Space, Tag } from 'ant-design-vue';
import { $t } from '#/locales';
interface DemoTaskItem {
id: number;
owner: string;
priority: 'P0' | 'P1' | 'P2';
title: string;
}
type HighlightTone = PlaygroundPreferencesExtension['highlightTone'];
const playgroundPreferences =
getCustomPreferences<PlaygroundPreferencesExtension>();
const demoTasks: DemoTaskItem[] = [
{ id: 1, owner: 'Luna', priority: 'P0', title: '同步租户配置到缓存' },
{ id: 2, owner: 'Aiden', priority: 'P1', title: '补充角色权限回归用例' },
{ id: 3, owner: 'Mia', priority: 'P0', title: '修复看板接口超时重试' },
{ id: 4, owner: 'Noah', priority: 'P2', title: '整理本周运营周报模板' },
{ id: 5, owner: 'Ethan', priority: 'P1', title: '验证暗黑主题下图表对比度' },
{ id: 6, owner: 'Sophia', priority: 'P1', title: '更新埋点字段映射文档' },
{ id: 7, owner: 'Lucas', priority: 'P2', title: '检查消息中心未读状态同步' },
{ id: 8, owner: 'Emma', priority: 'P0', title: '补齐导出任务失败告警' },
];
const toneMap = {
default: {
alertType: 'info',
cardClass: 'border-primary/20 bg-primary/5',
label: $t('demos.preferencesExtensionDemo.tones.default'),
tagColor: 'processing',
},
success: {
alertType: 'success',
cardClass: 'border-emerald-500/20 bg-emerald-500/5',
label: $t('demos.preferencesExtensionDemo.tones.success'),
tagColor: 'success',
},
warning: {
alertType: 'warning',
cardClass: 'border-amber-500/20 bg-amber-500/5',
label: $t('demos.preferencesExtensionDemo.tones.warning'),
tagColor: 'warning',
},
} as const satisfies Record<
HighlightTone,
{
alertType: 'info' | 'success' | 'warning';
cardClass: string;
label: string;
tagColor: string;
}
>;
const visibleTasks = computed(() => {
return demoTasks.slice(0, playgroundPreferences.defaultVisibleRows);
});
const toneConfig = computed(() => {
return toneMap[playgroundPreferences.highlightTone];
});
const formattedPlaygroundPreferences = computed(() => {
return JSON.stringify(playgroundPreferences, null, 2);
});
const preClasses =
'mt-4 overflow-auto rounded-lg border border-border bg-muted p-4 text-sm';
function applyPreset(type: 'compact' | 'focus' | 'review') {
const presetMap: Record<
typeof type,
Partial<PlaygroundPreferencesExtension>
> = {
compact: {
defaultVisibleRows: 3,
enableQuickActions: false,
highlightTone: 'warning',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.compact'),
},
focus: {
defaultVisibleRows: 4,
enableQuickActions: true,
highlightTone: 'default',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.default'),
},
review: {
defaultVisibleRows: 6,
enableQuickActions: true,
highlightTone: 'success',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.review'),
},
};
updateCustomPreferences<PlaygroundPreferencesExtension>(presetMap[type]);
}
function getPriorityColor(priority: DemoTaskItem['priority']) {
switch (priority) {
case 'P0': {
return 'error';
}
case 'P1': {
return 'warning';
}
default: {
return 'default';
}
}
}
</script>
<template>
<Page :title="$t('demos.features.preferencesExtension')">
<template #description>
<div class="mt-2 text-foreground/80">
{{ $t('demos.preferencesExtensionDemo.description') }}
</div>
</template>
<Card
class="mb-5"
:title="$t('demos.preferencesExtensionDemo.currentConfig')"
>
<Alert :type="toneConfig.alertType" show-icon>
<template #message>
{{
$t('demos.preferencesExtensionDemo.currentTitle', {
title: playgroundPreferences.reportTitle,
})
}}
</template>
<template #description>
{{
$t('demos.preferencesExtensionDemo.currentDescription', {
count: playgroundPreferences.defaultVisibleRows,
quickActionText: playgroundPreferences.enableQuickActions
? $t('demos.preferencesExtensionDemo.showQuickActions')
: $t('demos.preferencesExtensionDemo.hideQuickActions'),
tone: toneConfig.label,
})
}}
</template>
</Alert>
<div class="mt-4 rounded-xl border p-4" :class="toneConfig.cardClass">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">
{{ playgroundPreferences.reportTitle }}
</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.boardDescription') }}
</div>
</div>
<Tag :color="toneConfig.tagColor">
{{ toneConfig.label }}
</Tag>
</div>
<Space
v-if="playgroundPreferences.enableQuickActions"
wrap
class="mb-4"
>
<Button type="primary">
{{ $t('demos.preferencesExtensionDemo.quickActions.create') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.export') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.refresh') }}
</Button>
</Space>
<div v-else class="mb-4 text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.quickActionsEnabled') }}
</div>
<div class="space-y-2">
<div
v-for="task in visibleTasks"
:key="task.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-background px-4 py-3"
>
<div>
<div class="font-medium">{{ task.title }}</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.owner') }}{{
task.owner
}}
</div>
</div>
<Tag :color="getPriorityColor(task.priority)">
{{ task.priority }}
</Tag>
</div>
</div>
</div>
</Card>
<Card :title="$t('demos.preferencesExtensionDemo.presetTitle')">
<Space wrap>
<Button @click="applyPreset('focus')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.default') }}
</Button>
<Button @click="applyPreset('compact')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.compact') }}
</Button>
<Button type="primary" @click="applyPreset('review')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.review') }}
</Button>
</Space>
<pre :class="preClasses">{{ formattedPlaygroundPreferences }}</pre>
</Card>
</Page>
</template>