feat(deepseek-account,ui-settings-account): display profile avatars with icon fallback
This commit is contained in:
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',
|
||||
|
||||
Reference in new issue
Block a user