diff --git a/assets/screenshots/user-id-display.png b/assets/screenshots/user-id-display.png new file mode 100644 index 0000000000..eed9b8a472 Binary files /dev/null and b/assets/screenshots/user-id-display.png differ diff --git a/backend/internal/app/settings_composite_runtime_test.go b/backend/internal/app/settings_composite_runtime_test.go index 4f0940ee77..4e00f9e4c3 100644 --- a/backend/internal/app/settings_composite_runtime_test.go +++ b/backend/internal/app/settings_composite_runtime_test.go @@ -444,7 +444,7 @@ func newCompositeSettingsHTTPFixture(repo settingscore.Repository, cfg *config.C } // TestSettingsFieldOwnership enumerates static participants and the flattened -// update request type. It checks unique ownership, all 287 request fields are +// update request type. It checks unique ownership, all request fields are // represented, and no participant declares an extra field outside that request. func TestSettingsFieldOwnership(t *testing.T) { defaults := scheduler.DefaultAdminSettingsDefaults() @@ -477,7 +477,7 @@ func TestSettingsFieldOwnership(t *testing.T) { delete(owners, name) count++ } - require.Equal(t, 288, count, "HTTP 字段变化需要同步字段登记") + require.Equal(t, 289, count, "HTTP 字段变化需要同步字段登记") require.Empty(t, owners, "参与者不应声明不存在的输入字段") } @@ -2271,3 +2271,28 @@ func TestSettingsCoverAtomicSave(t *testing.T) { require.Empty(t, repo.values[site.SettingKeySiteShareImage]) require.Empty(t, site.ReadShareImageMetadata(repo.values[site.SettingKeySiteShareImageMeta]).Hash) } + +// TestSettingsShowUserIDDefaultsAndPartialUpdates exercises the display switch through +// the combined settings handler. It protects the disabled default, explicit updates, +// and retention when an unrelated save omits the field. +func TestSettingsShowUserIDDefaultsAndPartialUpdates(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + response := doUpdateSettings(t, h, map[string]any{}, nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Contains(t, response.Body.String(), `"show_user_id":false`) + + response = doUpdateSettings(t, h, map[string]any{"show_user_id": true}, nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, "true", repo.values[site.SettingKeyShowUserID]) + require.Contains(t, response.Body.String(), `"show_user_id":true`) + + response = doUpdateSettings(t, h, map[string]any{"contact_info": "support@example.test"}, nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, "true", repo.values[site.SettingKeyShowUserID]) + require.Contains(t, response.Body.String(), `"show_user_id":true`) + + response = doUpdateSettings(t, h, map[string]any{"show_user_id": false}, nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, "false", repo.values[site.SettingKeyShowUserID]) + require.Contains(t, response.Body.String(), `"show_user_id":false`) +} diff --git a/backend/internal/settings/composite/site_projection.go b/backend/internal/settings/composite/site_projection.go index 32cf5cdb86..8d82e38766 100644 --- a/backend/internal/settings/composite/site_projection.go +++ b/backend/internal/settings/composite/site_projection.go @@ -25,6 +25,7 @@ func (s *Snapshot) SiteAdminSettings() site.AdminSettings { FooterText: s.FooterText, FrontendURL: s.FrontendURL, HideCcsImportButton: s.HideCcsImportButton, + ShowUserID: s.ShowUserID, HomeContent: s.HomeContent, HomeFeaturedModels: s.HomeFeaturedModels, LoginAgreementDocuments: s.LoginAgreementDocuments, @@ -58,6 +59,7 @@ func (s *Snapshot) ApplySiteAdminSettings(value site.AdminSettings) { s.FooterText = value.FooterText s.FrontendURL = value.FrontendURL s.HideCcsImportButton = value.HideCcsImportButton + s.ShowUserID = value.ShowUserID s.HomeContent = value.HomeContent s.HomeFeaturedModels = value.HomeFeaturedModels s.LoginAgreementDocuments = value.LoginAgreementDocuments @@ -90,6 +92,7 @@ func (s *Snapshot) ApplySiteAdminReadSettings(value *site.AdminReadSettings) { s.FooterText = value.FooterText s.FrontendURL = value.FrontendURL s.HideCcsImportButton = value.HideCcsImportButton + s.ShowUserID = value.ShowUserID s.HomeContent = value.HomeContent s.HomeFeaturedModels = value.HomeFeaturedModels s.LoginAgreementDocuments = value.LoginAgreementDocuments diff --git a/backend/internal/settings/composite/snapshot.go b/backend/internal/settings/composite/snapshot.go index ecb0125491..8eac89c26a 100644 --- a/backend/internal/settings/composite/snapshot.go +++ b/backend/internal/settings/composite/snapshot.go @@ -197,6 +197,7 @@ type Snapshot struct { DocURL string // source documentation destination text HomeContent string // source custom homepage content HideCcsImportButton bool // CCS import control visibility switch + ShowUserID bool // User ID display switch. PurchaseSubscriptionEnabled bool // subscription purchase entry switch PurchaseSubscriptionURL string // subscription purchase destination address TableDefaultPageSize int // default table row count diff --git a/backend/internal/settings/httpapi/composite_audit.go b/backend/internal/settings/httpapi/composite_audit.go index cd94c2d520..f5ee2cb035 100644 --- a/backend/internal/settings/httpapi/composite_audit.go +++ b/backend/internal/settings/httpapi/composite_audit.go @@ -477,6 +477,9 @@ func DiffSettings(before *composite.Snapshot, after *composite.Snapshot, beforeA if before.HideCcsImportButton != after.HideCcsImportButton { changed = append(changed, "hide_ccs_import_button") } + if before.ShowUserID != after.ShowUserID { + changed = append(changed, "show_user_id") + } if before.DefaultConcurrency != after.DefaultConcurrency { changed = append(changed, "default_concurrency") } diff --git a/backend/internal/settings/httpapi/composite_response.go b/backend/internal/settings/httpapi/composite_response.go index 7dbaf39918..48441911e8 100644 --- a/backend/internal/settings/httpapi/composite_response.go +++ b/backend/internal/settings/httpapi/composite_response.go @@ -152,6 +152,7 @@ func buildSystemSettingsResponse( DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + ShowUserID: settings.ShowUserID, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, diff --git a/backend/internal/settings/httpapi/composite_update.go b/backend/internal/settings/httpapi/composite_update.go index 8f2a408789..33965550f1 100644 --- a/backend/internal/settings/httpapi/composite_update.go +++ b/backend/internal/settings/httpapi/composite_update.go @@ -1432,6 +1432,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { DocURL: req.DocURL, HomeContent: req.HomeContent, HideCcsImportButton: req.HideCcsImportButton, + ShowUserID: boolValueOrDefault(req.ShowUserID, previousSettings.ShowUserID), PurchaseSubscriptionEnabled: purchaseEnabled, PurchaseSubscriptionURL: purchaseURL, TableDefaultPageSize: req.TableDefaultPageSize, diff --git a/backend/internal/settings/httpapi/dto/settings_views.go b/backend/internal/settings/httpapi/dto/settings_views.go index 430edaac30..57e9f68dbf 100644 --- a/backend/internal/settings/httpapi/dto/settings_views.go +++ b/backend/internal/settings/httpapi/dto/settings_views.go @@ -174,6 +174,7 @@ type SystemSettings struct { DocURL string `json:"doc_url"` // source documentation destination text HomeContent string `json:"home_content"` // source custom homepage content HideCcsImportButton bool `json:"hide_ccs_import_button"` // CCS import control visibility switch + ShowUserID bool `json:"show_user_id"` // User ID display switch. PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` // subscription purchase entry switch PurchaseSubscriptionURL string `json:"purchase_subscription_url"` // subscription purchase destination address TableDefaultPageSize int `json:"table_default_page_size"` // default table row count diff --git a/backend/internal/settings/httpapi/dto/update_request.go b/backend/internal/settings/httpapi/dto/update_request.go index adf6ff5c7a..0ec683b920 100644 --- a/backend/internal/settings/httpapi/dto/update_request.go +++ b/backend/internal/settings/httpapi/dto/update_request.go @@ -193,6 +193,9 @@ type UpdateSettingsRequest struct { DocURL string `json:"doc_url"` // source documentation destination text HomeContent string `json:"home_content"` // source custom homepage content HideCcsImportButton bool `json:"hide_ccs_import_button"` // CCS import control visibility switch + // ShowUserID updates account identifier visibility when supplied; nil leaves + // the stored switch unchanged during unrelated administrator settings updates. + ShowUserID *bool `json:"show_user_id"` // User ID display switch. // PurchaseSubscriptionEnabled and PurchaseSubscriptionURL use the prior // Snapshot when nil. Enabled purchase requires a nonempty absolute HTTP(S) URL, // while supplied URLs are validated even when the entry is disabled. diff --git a/backend/internal/site/admin_settings.go b/backend/internal/site/admin_settings.go index 66def04580..99989b719f 100644 --- a/backend/internal/site/admin_settings.go +++ b/backend/internal/site/admin_settings.go @@ -31,6 +31,7 @@ type AdminSettings struct { FooterText string `json:"footer_text"` // additional footer text FrontendURL string `json:"frontend_url"` // deployment frontend address HideCcsImportButton bool `json:"hide_ccs_import_button"` // hide import action + ShowUserID bool `json:"show_user_id"` // User ID display switch. HomeContent string `json:"home_content"` // homepage HTML or link HomeFeaturedModels string `json:"home_featured_models"` // featured model JSON // LoginAgreementDocuments supplies the agreement list; marshaling normalizes @@ -103,6 +104,7 @@ func PrepareAdminSettings(settings *AdminSettings) (map[string]string, error) { updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyHomeContent] = settings.HomeContent updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) + updates[SettingKeyShowUserID] = strconv.FormatBool(settings.ShowUserID) updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) tableDefaultPageSize, tablePageSizeOptions := NormalizeTablePreferences( diff --git a/backend/internal/site/admin_settings_read.go b/backend/internal/site/admin_settings_read.go index 85d6357c8f..3dcf532f62 100644 --- a/backend/internal/site/admin_settings_read.go +++ b/backend/internal/site/admin_settings_read.go @@ -24,6 +24,7 @@ type AdminReadSettings struct { FooterText string // additional footer text FrontendURL string // deployment frontend address HideCcsImportButton bool // hide import action + ShowUserID bool // User ID display switch. HomeContent string // homepage HTML or link HomeFeaturedModels string // featured model JSON // LoginAgreementDocuments carries normalized stored documents; missing, @@ -87,6 +88,7 @@ func ReadAdminSettings(settings map[string]string) *AdminReadSettings { result.DocURL = settings[SettingKeyDocURL] result.HomeContent = settings[SettingKeyHomeContent] result.HideCcsImportButton = settings[SettingKeyHideCcsImportButton] == "true" + result.ShowUserID = settings[SettingKeyShowUserID] == "true" result.PurchaseSubscriptionEnabled = settings[SettingKeyPurchaseSubscriptionEnabled] == "true" result.PurchaseSubscriptionURL = strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]) result.CustomMenuItems = settings[SettingKeyCustomMenuItems] diff --git a/backend/internal/site/httpapi/dto/public.go b/backend/internal/site/httpapi/dto/public.go index 8ff82c18c4..89fd5bd922 100644 --- a/backend/internal/site/httpapi/dto/public.go +++ b/backend/internal/site/httpapi/dto/public.go @@ -71,6 +71,7 @@ type ( DocURL string `json:"doc_url"` // resolved documentation link HomeContent string `json:"home_content"` // resolved homepage content HideCcsImportButton bool `json:"hide_ccs_import_button"` // hide import action + ShowUserID bool `json:"show_user_id"` // User ID display switch. PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` // subscription purchase switch PurchaseSubscriptionURL string `json:"purchase_subscription_url"` // resolved purchase destination TableDefaultPageSize int `json:"table_default_page_size"` // default rows per page diff --git a/backend/internal/site/httpapi/public.go b/backend/internal/site/httpapi/public.go index c91ffaa347..d70e30eb7e 100644 --- a/backend/internal/site/httpapi/public.go +++ b/backend/internal/site/httpapi/public.go @@ -102,6 +102,7 @@ func (h *PublicHandler) GetPublicSettings(c *gin.Context) { DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + ShowUserID: settings.ShowUserID, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, diff --git a/backend/internal/site/httpapi/public_test.go b/backend/internal/site/httpapi/public_test.go index 812dab6a75..c76e6ffd00 100644 --- a/backend/internal/site/httpapi/public_test.go +++ b/backend/internal/site/httpapi/public_test.go @@ -15,8 +15,15 @@ import ( "github.com/TokenFlux/TokenRouter/internal/site" ) -// calendarPublicSource supplies empty settings so the test exercises the injected calendar. -type calendarPublicSource struct{} +// public_test.go checks the public settings API against the HTML injection path. +// Controlled display values and injected calendars verify that both transports +// expose the same feature decisions and timezone metadata before browsers render +// account controls. + +// calendarPublicSource supplies a display switch alongside injected calendar settings. +type calendarPublicSource struct { + showUserID string // Stored display-switch value. +} // TestPublicCalendarMatchesAPIAndInjection supplies calendars with distinct zone names and display labels. // It verifies that API and HTML injection return the configured label and the @@ -58,11 +65,11 @@ func TestPublicCalendarMatchesAPIAndInjection(t *testing.T) { } } -// LoadSitePublicInputs supplies empty display, auth, and usage inputs for calendar tests. +// LoadSitePublicInputs supplies display-switch values with empty auth and usage inputs. // Each call allocates a fresh values map, keeping fixture data independent of // the response service's language and navigation transformations. -func (calendarPublicSource) LoadSitePublicInputs(context.Context) (site.PublicInputs, error) { - return site.PublicInputs{Values: map[string]string{}}, nil +func (source calendarPublicSource) LoadSitePublicInputs(context.Context) (site.PublicInputs, error) { + return site.PublicInputs{Values: map[string]string{site.SettingKeyShowUserID: source.showUserID}}, nil } // PublicVersion returns the fixed source build label used by injection fixtures. @@ -70,3 +77,33 @@ func (calendarPublicSource) PublicVersion() string { return "calendar-contract" // LoadSiteShareImage leaves this text-only fixture without an uploaded cover. func (calendarPublicSource) LoadSiteShareImage(context.Context) (string, error) { return "", nil } + +// TestPublicUserIDSwitchMatchesAPIAndInjection supplies missing, disabled, enabled, +// and invalid stored values. Both public transports must agree so initial HTML +// rendering and subsequent API refreshes expose the same account display policy. +func TestPublicUserIDSwitchMatchesAPIAndInjection(t *testing.T) { + for _, value := range []string{"", "false", "true", "invalid"} { + t.Run(value, func(t *testing.T) { + service := site.NewPublicService(calendarPublicSource{showUserID: value}, timezone.NewCalendar(time.UTC), "UTC") + handler := NewPublicHandler(service, "test") + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) + handler.GetPublicSettings(ctx) + require.Equal(t, http.StatusOK, response.Code) + var envelope struct { + Data map[string]any `json:"data"` // Public settings response. + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope)) + require.Equal(t, value == "true", envelope.Data["show_user_id"]) + + injection, err := service.GetPublicSettingsForInjection(context.Background()) + require.NoError(t, err) + encoded, err := json.Marshal(injection) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.Equal(t, value == "true", fields["show_user_id"]) + }) + } +} diff --git a/backend/internal/site/public.go b/backend/internal/site/public.go index 1f92f42286..d49522330f 100644 --- a/backend/internal/site/public.go +++ b/backend/internal/site/public.go @@ -63,6 +63,7 @@ const ( SettingKeyFooterText = "footer_text" SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup" SettingKeyHideCcsImportButton = "hide_ccs_import_button" + SettingKeyShowUserID = "show_user_id" SettingKeyHomeContent = "home_content" SettingKeyHomeFeaturedModels = "home_featured_models" SettingKeyInvitationCodeEnabled = "invitation_code_enabled" @@ -306,6 +307,7 @@ func (s *PublicService) GetPublicSettings(ctx context.Context) (*PublicSettings, DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + ShowUserID: settings[SettingKeyShowUserID] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), TableDefaultPageSize: tableDefaultPageSize, @@ -632,6 +634,7 @@ func (s *PublicService) GetPublicSettingsForInjection(ctx context.Context) (any, DocURL string `json:"doc_url,omitempty"` // resolved documentation link HomeContent string `json:"home_content,omitempty"` // resolved homepage content HideCcsImportButton bool `json:"hide_ccs_import_button"` // hide import action + ShowUserID bool `json:"show_user_id"` // User ID display switch. PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` // subscription purchase switch PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` // resolved purchase destination TableDefaultPageSize int `json:"table_default_page_size"` // default rows per page @@ -726,6 +729,7 @@ func (s *PublicService) GetPublicSettingsForInjection(ctx context.Context) (any, DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + ShowUserID: settings.ShowUserID, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, @@ -1030,6 +1034,7 @@ func PublicValueKeys() []string { SettingKeyFooterText, SettingKeyForceEmailOnThirdPartySignup, SettingKeyHideCcsImportButton, + SettingKeyShowUserID, SettingKeyHomeContent, SettingKeyHomeFeaturedModels, SettingKeyInvitationCodeEnabled, diff --git a/backend/internal/site/public_source.go b/backend/internal/site/public_source.go index 24b9c6901d..6691539fb4 100644 --- a/backend/internal/site/public_source.go +++ b/backend/internal/site/public_source.go @@ -119,6 +119,7 @@ func PublicInputKeys() []string { "doc_url", "home_content", "hide_ccs_import_button", + "show_user_id", "purchase_subscription_enabled", "purchase_subscription_url", "table_default_page_size", diff --git a/backend/internal/site/public_types.go b/backend/internal/site/public_types.go index a7b8d0235e..8fad0f2b1e 100644 --- a/backend/internal/site/public_types.go +++ b/backend/internal/site/public_types.go @@ -77,6 +77,9 @@ type ( DocURL string // resolved documentation link HomeContent string // resolved homepage content HideCcsImportButton bool // hide import action + // ShowUserID is enabled when the stored switch equals true. Missing + // settings keep account identifiers hidden in the account menu and profile. + ShowUserID bool // User ID display switch. PurchaseSubscriptionEnabled bool // subscription purchase switch PurchaseSubscriptionURL string // resolved purchase destination diff --git a/backend/internal/site/settings_participant.go b/backend/internal/site/settings_participant.go index 3e9fa4c8a2..9614ad9a83 100644 --- a/backend/internal/site/settings_participant.go +++ b/backend/internal/site/settings_participant.go @@ -51,6 +51,7 @@ func SettingsParticipant() settings.Participant { SettingKeyFooterText, SettingKeyFrontendURL, SettingKeyHideCcsImportButton, + SettingKeyShowUserID, SettingKeyHomeContent, SettingKeyHomeFeaturedModels, SettingKeyLoginAgreementDocuments, diff --git a/docs/interfaces/configuration.md b/docs/interfaces/configuration.md index aee2ab2c46..bb2426f67f 100644 --- a/docs/interfaces/configuration.md +++ b/docs/interfaces/configuration.md @@ -192,6 +192,8 @@ setup 的数据库和 Redis 连接测试由精简版 bootstrap 执行,输入 usage、audit 和 ops 的静态参数,由 app 整理成各模块的 Options;动态的 Ops 设置和日志配置,由数据库里的键控制。统一的预聚合控制器在 `settings/preaggregation`,带十五秒缓存和更新通知。运行日志按"应用、持久化失败时回滚、清理 Reload"的顺序处理。 +管理员在“系统设置 → 功能开关”管理 `show_user_id`。缺键时默认关闭;开启后,右上角头像下拉菜单和个人资料显示当前登录用户的 `#ID`,点击可复制包含 `#` 的完整文本。综合设置 GET/PUT 读写该布尔字段,更新时省略字段会保留原值。公共设置 API 和 HTML 注入都返回展示开关,用户 ID 取自当前登录用户资料。 + ### 模块读取器 创作台的运行开关和模型列表,由 app 直接注入 `creative.RuntimeSettings`,每次即时读取;路由容量使用同一个 `provider.QuotaSettingsCache`。各个读取器共用已经装配好的缓存实例。`settings/composite` 负责综合设置的组合、准备顺序和提交后的应用;`settings/httpapi` 负责扁平的请求绑定、权限、审计和响应。 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 85df69f1b9..f160337d43 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -741,6 +741,7 @@ export interface SystemSettings { contact_info: string // Configured contact info. doc_url: string // Configured doc url. home_content: string // Configured home content. + show_user_id: boolean // Account identifier display switch. hide_ccs_import_button: boolean // Hide ccs import button. table_default_page_size: number // Table default page size. table_page_size_options: number[] // Table page size options. @@ -1107,6 +1108,7 @@ export interface UpdateSettingsRequest { contact_info?: string // Requested contact info. doc_url?: string // Requested doc url. home_content?: string // Requested home content. + show_user_id?: boolean // Requested account identifier visibility. hide_ccs_import_button?: boolean // Hide ccs import button. table_default_page_size?: number // Table default page size. table_page_size_options?: number[] // Table page size options. diff --git a/frontend/src/api/settings.types.ts b/frontend/src/api/settings.types.ts index 0f6a80c850..de43dfc14f 100644 --- a/frontend/src/api/settings.types.ts +++ b/frontend/src/api/settings.types.ts @@ -116,6 +116,9 @@ export interface PublicSettings { contact_info: string // Site contact details. doc_url: string // Site documentation URL. home_content: string // Homepage content markup. + // Older public settings can omit show_user_id; consumers show account identifiers + // only when the switch is true. + show_user_id?: boolean // Account identifier display switch. hide_ccs_import_button: boolean // CCS import hiding switch. payment_enabled: boolean // Payment checkout availability. team_enabled?: boolean // Team feature availability. diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 1634a84081..a375d322f7 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -191,6 +191,8 @@ /> + +
{{ @@ -316,6 +318,7 @@ import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue' import AnnouncementBell from '@/components/announcements/AnnouncementBell.vue' import UserAvatar from '@/components/common/UserAvatar.vue' +import UserIdBadge from '@/components/user/UserIdBadge.vue' import HeaderContactSupport from '@/components/layout/HeaderContactSupport.vue' import GlobalSearchTrigger from '@/components/layout/search/GlobalSearchTrigger.vue' import Icon from '@/components/icons/Icon.vue' @@ -329,8 +332,9 @@ import { useTheme, type ThemeMode } from '@/composables/useTheme' // entries, balance, and account menu. Marketplace, announcement, docs, support, and // language entries show text from md upward so new users can read what each entry does; // narrower headers fall back to icons. Authenticated users choose themes in the account -// menu, while guests keep a header theme toggle. Public pages add guest navigation, -// authenticated menus follow public feature settings, and the creative workspace +// menu, which also offers optional user ID copying. Guests keep a header theme toggle. +// Public pages add guest navigation, authenticated menus follow public feature settings, +// and the creative workspace // provides a dashboard return link beside the brand. Menu listeners belong to the // mounted header. diff --git a/frontend/src/components/layout/__tests__/AppHeader.spec.ts b/frontend/src/components/layout/__tests__/AppHeader.spec.ts index cc6e306b58..cda1f4654c 100644 --- a/frontend/src/components/layout/__tests__/AppHeader.spec.ts +++ b/frontend/src/components/layout/__tests__/AppHeader.spec.ts @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ settings: {} as Record, // Public feature switch values. setThemeMode: vi.fn(), // Theme selection action recorder. toggleTheme: vi.fn(), // Guest theme action recorder. + copyUserId: vi.fn(), // Account identifier clipboard recorder. })) vi.mock('vue-i18n', async (importOriginal) => ({ ...(await importOriginal()), @@ -35,6 +36,9 @@ vi.mock('@/stores', () => ({ useAuthStore: () => ({ user: state.user, isAdmin: false, logout: vi.fn() }), useOnboardingStore: () => ({ replay: vi.fn() }), })) +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ cachedPublicSettings: state.settings }), +})) vi.mock('@/composables/useBalanceDisplay', () => ({ useBalanceDisplay: () => ({ formatBalanceAmount: () => '0', balanceUnitSymbol: '$' }), })) @@ -46,6 +50,9 @@ vi.mock('@/composables/useTheme', () => ({ toggleTheme: state.toggleTheme, }), })) +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ copyToClipboard: state.copyUserId }), +})) vi.mock('@/directives/segmented', () => ({ vSegmented: {} })) enableAutoUnmount(afterEach) @@ -59,7 +66,7 @@ beforeEach(() => { function mountHeader() { return shallowMount(AppHeader, { props: { publicPage: true }, - global: { renderStubDefaultSlot: true, stubs: { RouterLink: true } }, + global: { renderStubDefaultSlot: true, stubs: { RouterLink: true, UserIdBadge: false } }, }) } @@ -74,6 +81,20 @@ async function openUserMenu() { } describe('AppHeader controls', () => { + // Signed-in users copy their account ID from the avatar dropdown. The closed + // menu keeps that identifier out of the top-level header controls. + it('places the copyable account ID inside the avatar dropdown', async () => { + state.user = { id: 1233, email: 'reader@example.test', role: 'user' } + state.settings = { show_user_id: true } + const wrapper = mountHeader() + expect(wrapper.text()).not.toContain('#1233') + await wrapper.get('.header-status-user-button').trigger('click') + const button = wrapper.get('.user-menu-panel button[aria-label="profile.copyUserId"]') + expect(button.text()).toBe('#1233') + await button.trigger('click') + expect(state.copyUserId).toHaveBeenCalledExactlyOnceWith('#1233') + }) + // Guests need a direct theme action because they have no account menu. A // signed-in header moves that action into the account menu's theme controls. it('keeps the header theme button only for guests', async () => { diff --git a/frontend/src/components/user/UserIdBadge.vue b/frontend/src/components/user/UserIdBadge.vue new file mode 100644 index 0000000000..bca1bf3267 --- /dev/null +++ b/frontend/src/components/user/UserIdBadge.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/user/__tests__/UserIdBadge.spec.ts b/frontend/src/components/user/__tests__/UserIdBadge.spec.ts new file mode 100644 index 0000000000..c5e4e36031 --- /dev/null +++ b/frontend/src/components/user/__tests__/UserIdBadge.spec.ts @@ -0,0 +1,74 @@ +import { mount, enableAutoUnmount } from '@vue/test-utils' +import { reactive, nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import UserIdBadge from '../UserIdBadge.vue' + +// UserIdBadge.spec.ts checks account identifier visibility and clipboard actions +// against changing public settings and account props. Clipboard writes use a +// recorder so the suite verifies the exact copied value without browser permissions. + +// state records clipboard calls and supplies public settings for each mounted account control. +const state = vi.hoisted(() => ({ + copy: vi.fn(), // Clipboard operation recorder. + settings: {} as { show_user_id?: boolean }, // Public display-switch fixture. +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ cachedPublicSettings: state.settings }), +})) +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ copyToClipboard: state.copy }), +})) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (_key: string, params: { id: number }) => `Copy user ID #${params.id}`, + }), +})) + +enableAutoUnmount(afterEach) +beforeEach(() => { + state.settings = reactive({}) + state.copy.mockClear() +}) + +describe('UserIdBadge', () => { + // Older settings omit the display switch and installations can disable it. + // Neither state should render a copy action for an existing account. + it.each([undefined, false])('hides identifiers when the switch is %s', (enabled) => { + state.settings.show_user_id = enabled + expect( + mount(UserIdBadge, { props: { userId: 1233 } }) + .find('button') + .exists(), + ).toBe(false) + }) + + // An enabled installation exposes a labeled control for the current account. + // Copying includes the hash prefix requested for the visible identifier. + it('displays and copies the hash-prefixed account ID', async () => { + state.settings.show_user_id = true + const wrapper = mount(UserIdBadge, { props: { userId: 1233 } }) + const button = wrapper.get('button') + expect(button.text()).toBe('#1233') + expect(button.attributes('aria-label')).toBe('Copy user ID #1233') + await button.trigger('click') + expect(state.copy).toHaveBeenCalledExactlyOnceWith('#1233') + }) + + // Public settings and account identity can change while the shell remains mounted. + // The control follows both changes and disappears when the account becomes absent. + it('follows switch changes and copies the current account', async () => { + const wrapper = mount(UserIdBadge, { props: { userId: 1233 } }) + state.settings.show_user_id = true + await nextTick() + await wrapper.setProps({ userId: 42 }) + await wrapper.get('button').trigger('click') + expect(state.copy).toHaveBeenLastCalledWith('#42') + state.settings.show_user_id = false + await nextTick() + expect(wrapper.find('button').exists()).toBe(false) + state.settings.show_user_id = true + await wrapper.setProps({ userId: undefined }) + expect(wrapper.find('button').exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 69a33c12ed..efbea31fbf 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -19,6 +19,7 @@

{{ displayName }}

+ {{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }} @@ -154,6 +155,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' import UserAvatar from '@/components/common/UserAvatar.vue' +import UserIdBadge from '@/components/user/UserIdBadge.vue' import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue' diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index c212e5b383..5e48f5ec29 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -185,6 +185,13 @@ export default { 'If the window and bucket size would produce more than 720 bars, the backend widens the bucket size automatically.', }, features: { + userId: { + title: 'User ID', + description: 'Control account identifier visibility.', + enabled: 'Show User ID', + enabledHint: + 'Show a copyable ID such as #1233 in the avatar menu and profile. Disabled by default.', + }, bstats: { title: 'bStats', description: diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 5e2ec4abab..d9cf961b98 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -717,6 +717,7 @@ export default { }, // Profile profile: { + copyUserId: 'Copy user ID #{id}', title: 'Profile Settings', description: 'Manage your account information and settings', accountBalance: 'Account Balance', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 3d65d43084..47dd611bbb 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -177,6 +177,12 @@ export default { bucketLimitHint: '如果窗口和粒度会产生超过 720 根柱子,后端会自动放大单柱时间窗口。', }, features: { + userId: { + title: '用户 ID', + description: '管理用户 ID 的展示。', + enabled: '显示用户 ID', + enabledHint: '在右上角头像菜单和个人资料中显示可复制的用户 ID,例如 #1233。默认关闭。', + }, bstats: { title: 'bStats', description: diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 7b4202c92e..f81d676725 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -696,6 +696,7 @@ export default { }, // Profile profile: { + copyUserId: '复制用户 ID #{id}', title: '个人设置', description: '管理您的账户信息和设置', accountBalance: '账户余额', diff --git a/frontend/src/views/admin/settings/FeatureSettings.vue b/frontend/src/views/admin/settings/FeatureSettings.vue index 367c511bb9..ae0c3ae3f6 100644 --- a/frontend/src/views/admin/settings/FeatureSettings.vue +++ b/frontend/src/views/admin/settings/FeatureSettings.vue @@ -1,4 +1,18 @@