feat(deepseek-account,ui-settings-account): display profile avatars with icon fallback

This commit is contained in:
lsdsjy committed 2026-09-20 11:31:41 +08:00
1 parent adcd85c08a
commit 2d0f431181
19 files changed
+90 -15

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: 196e16b03bef4a817b1ff28c8b4c5e3d7ed5309d
README.zh.md: 22a55a767d48ab8dc6eeef86b06fb4526f5db11d
README.md: e27803ee5ac9ebc3d96a4f77f179bfa696c6bbbf
README.zh.md: 24ebc4e9afb76d5db5b6a701707556699717829e
@@ -20,6 +20,8 @@ The Account settings section displays DeepSeek login state and offers browser si
<a id="use-this-package"></a>
## Use this package
The sidebar and Account settings display the profile avatar as a circular image, with the account icon as fallback when the URL is absent or the image fails to load.
The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
Balances use Platform Web currency formatting: two decimal places and digit grouping, positive amounts truncated to cents, positive sub-cent amounts shown as <0.01, and negative amounts rounded with a minimum displayed magnitude of 0.01. Raw Host balance strings remain unchanged.
@@ -20,6 +20,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
侧边栏和账号设置以圆形图片显示账号头像;头像 URL 缺失或图片加载失败时使用账号图标兜底。
该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
余额沿用 Platform Web 的金额格式:两位小数和千分位分组,正金额截断至分,小于一分的正金额显示为 <0.01,负金额按舍入规则处理且显示绝对值至少为 0.01。Host 返回的原始余额字符串保持不变。
@@ -0,0 +1 @@
.image { display: block; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
@@ -0,0 +1,16 @@
/** Shared account picture with an icon fallback for missing or unavailable images. */
import { useState } from 'react'
import { IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './AccountAvatar.module.css'
/**
* Render a decorative avatar next to the account identity.
* @param props - profile picture URL; absent while signed out or loading.
* @returns picture or the default account icon.
*/
export function AccountAvatar({ url }: { url?: string | null | undefined }) {
const [failedUrl, setFailedUrl] = useState<string>()
return url && url !== failedUrl
? <img className={css.image} src={url} alt="" referrerPolicy="no-referrer" onError={() => { setFailedUrl(url) }} />
: <IconUserOutlineMedium size={16} />
}
@@ -5,6 +5,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
import type { AccountSectionInjected } from './AccountSection.tsx'
import { SignInDialog } from './SignInDialog.tsx'
import { LogoutIcon } from './LogoutIcon.tsx'
import { AccountAvatar } from './AccountAvatar.tsx'
import css from './AccountMenu.module.css'
/** Account launcher composed by the settings shell. */
@@ -36,7 +37,7 @@ export function AccountMenu({
<Menu open={open} side="top" portal autoFocus className={css.anchor}
anchor={<button type="button" className={css.trigger} aria-label={t('menu')}
aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
<span className={css.avatar}><IconUserOutlineMedium size={16} /></span>
<span className={css.avatar}><AccountAvatar url={signedIn && profile?.status === 'ready' ? profile.value.avatarUrl : null} /></span>
{wide && <span className={css.label}>{signedIn ? label : t('signedOut')}</span>}
</button>}
items={[
@@ -1,10 +1,11 @@
/** Account settings renders safe Host state and explicit login actions. */
import { useEffect, useState } from 'react'
import { Button, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
import { formatBalance } from './formatBalance.ts'
import { AccountAvatar } from './AccountAvatar.tsx'
import css from './AccountSection.module.css'
/** Safe account snapshot shared by the settings page and launcher. */
@@ -92,7 +93,7 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
loadingLabel={t('loading')} failureLabel={t('failed')} onClose={() => { setPlatformPage(undefined) }} />}
<div className={css.card}>
<div className={css.identity}>
<span className={css.avatar}><IconUserOutlineMedium size={16} /></span>
<span className={css.avatar}><AccountAvatar url={signedIn ? profile?.avatarUrl : null} /></span>
<div className={css.identityCopy}>
<span className={css.name}>{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')}</span>
<span className={css.status} role="status">{status}</span>
@@ -173,3 +173,36 @@ it.each([
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
})
it('shows the profile image in settings and the sidebar, with independent load-error fallbacks', async () => {
const operations = mount({ status: 'credential-stored', attempt: null }, en, {
profile: { status: 'ready', value: { id: null, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' } },
})
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
const images = document.querySelectorAll('img')
expect(images).toHaveLength(2)
for (const image of images) {
expect(image.getAttribute('src')).toBe('https://example.test/avatar.png')
const parent = image.parentElement!
fireEvent.error(image)
expect(parent.querySelector('img')).toBeNull()
expect(parent.querySelector('svg')).not.toBeNull()
}
})
it('retries a changed avatar URL after an image fails', async () => {
const { AccountAvatar } = await import('../src/client/AccountAvatar.tsx')
const view = render(<AccountAvatar />)
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
view.rerender(<AccountAvatar url="https://example.test/old.png" />)
fireEvent.error(view.container.querySelector('img')!)
expect(view.container.querySelector('svg')).not.toBeNull()
view.rerender(<AccountAvatar url="https://example.test/new.png" />)
expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/new.png')
view.rerender(<AccountAvatar url={null} />)
expect(view.container.querySelector('img')).toBeNull()
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md
README.md: 388e65e84f8011318f4e18746919b4582e12b712
README.zh.md: b65a8c0a12fd075d71ada02f1a4f6220aec799c1
README.md: bf18ce3cde01503bc5bd2bc2cfb02fe787e7b918
README.zh.md: c7971c0d89aa00383a47a41313f0b8cd6adc8b9e
@@ -26,7 +26,9 @@ Sign in through the system browser and keep the account credential in the existi
<a id="use-this-package"></a>
## Use this package
getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, phone or email exactly as masked by Platform, and normal_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries.
The profile projection maps `id_profile.picture` to `avatarUrl`, using null when no picture is configured.
getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries.
Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, and attemptTimeoutMs in the plugin row. HTTP is accepted only for explicitly enabled loopback development. The provider registers /oauth/callback on the existing Host webServer before auth_init, validates state, exchanges an S256 PKCE code once, and commits a grant before redirecting to auth_exchange.biz_data.authorized_url. Browser destinations default to the configured platform origin and always require the fixed /dsh/authorize or /dsh/authorized path. The completion URL sets `client_type` to the initiating client (`web` or `desktop`) and preserves other Platform-supplied query parameters. Device identity is a separate random UUID record shared by processes using the same credential store; device_model reports OS and architecture.
@@ -26,7 +26,9 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导
<a id="use-this-package"></a>
## 使用此包
getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。
账号资料投影将 `id_profile.picture` 映射为 `avatarUrl`,没有配置头像时返回 null。
getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。
在插件行配置 platformOrigin、allowLoopbackHttp、requestTimeoutMs 和 attemptTimeoutMs。HTTP 仅用于显式启用的本机开发。提供者先在现有 Host webServer 注册 /oauth/callback,再调用 auth_init;校验 state、使用 S256 PKCE 授权码兑换一次,并在跳转 auth_exchange.biz_data.authorized_url 前提交授权记录。浏览器地址默认要求匹配配置的平台来源,并始终要求固定的 /dsh/authorize 或 /dsh/authorized 路径。完成页地址将 `client_type` 设为发起登录的客户端类型(`web` 或 `desktop`),并保留平台返回的其他查询参数。设备标识是独立的随机 UUID 记录,由使用同一凭证存储的进程共享;device_model 报告操作系统和架构。
@@ -6,7 +6,7 @@ import { PlatformAuthError, requestAccount } from './protocol.ts'
const user = z.object({
id: z.string().nullish(),
email: z.string(), mobile: z.string().optional(), mobile_number: z.string().optional(),
id_profile: z.object({ name: z.string().nullable() }).nullish(),
id_profile: z.object({ name: z.string().nullable(), picture: z.string().nullish() }).nullish(),
})
const summary = z.object({ normal_wallets: z.array(z.object({
currency: z.enum(['CNY', 'USD']), balance: z.string().regex(/^-?\d+(?:\.\d+)?$/),
@@ -22,6 +22,7 @@ export function profile(value: unknown): AccountProfile {
const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = parsed.data
return {
id: parsed.data.id == null ? null : parsed.data.id as AccountUserId,
avatarUrl: identity?.picture || null,
name: identity?.name || null, contact: mobile || mobileNumber || email || null,
}
}
@@ -298,7 +298,7 @@ it('queries Platform Web endpoints with the stored grant and projects only maske
expect(await readDetails(f.account)).toBeNull()
await storeAccount(f)
expect(await readDetails(f.account)).toEqual({
profile: { status: 'ready', value: { id: 'test-user', name: 'Test Account', contact: '138****5678' } },
profile: { status: 'ready', value: { id: 'test-user', name: 'Test Account', avatarUrl: null, contact: '138****5678' } },
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '123.45' }, { currency: 'USD', balance: '6.78' }] },
})
expect(f.detailRequests).toEqual(expect.arrayContaining([
@@ -353,7 +353,7 @@ it.each([
const f = await fixture(input)
await storeAccount(f)
expect((await readDetails(f.account))?.profile).toEqual({
status: 'ready', value: { id: 'test-user', name: 'Test Account', contact: expected },
status: 'ready', value: { id: 'test-user', name: 'Test Account', avatarUrl: null, contact: expected },
})
})
@@ -589,6 +589,7 @@ it('uses exchange user for the first profile read and fetches current on refresh
{
"status": "ready",
"value": {
"avatarUrl": null,
"contact": "e***@example.invalid",
"id": "exchange-user",
"name": "Exchange User",
@@ -0,0 +1,7 @@
import { expect, it } from 'vitest'
import { profile } from '../src/details.ts'
it.each([undefined, null, '', 'https://example.test/avatar.png'])('projects Platform picture %s', (picture) => {
expect(profile({ id: 'user', email: '[email protected]', id_profile: { name: 'User', picture } }).avatarUrl)
.toBe(picture || null)
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md
README.md: f56907a93d0b0c6cd4aa1b6a639d09aa9a6d160a
README.zh.md: f9f2dd77b88988599df409e392dfaa168e5f77c3
README.md: 396463e0207671faf70e8fcd5457b2c8431622fd
README.zh.md: f7d8b93cfcbccd23b6fc6c6c72e72db624f58e78
@@ -24,6 +24,8 @@ Account consumers read stored login state, start or cancel a browser login, and
<a id="use-this-package"></a>
## Use this package
`AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar.
The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken.
<a id="understand-the-implementation"></a>
@@ -24,6 +24,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token
<a id="use-this-package"></a>
## 使用此包
`AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。
该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。
<a id="understand-the-implementation"></a>
@@ -32,6 +32,8 @@ export interface AccountProfile {
readonly id: AccountUserId | null
readonly name: string | null
readonly contact: string | null
/** Profile image URL supplied by Platform, absent when no picture is configured. */
readonly avatarUrl?: string | null
}
/** Platform recharge-wallet balance; decimal strings retain server precision. */
export interface AccountWallet {
@@ -4059,7 +4059,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AccountProfile',
declaration: 'export interface AccountProfile {\n readonly id: AccountUserId | null;\n readonly name: string | null;\n readonly contact: string | null;\n}',
declaration: 'export interface AccountProfile {\n readonly id: AccountUserId | null;\n readonly name: string | null;\n readonly contact: string | null;\n readonly avatarUrl?: string | null;\n}',
},
{
name: 'AccountUserId',