fix(ui-settings-account,deepseek-account): reset copy feedback and retain successful profiles
This commit is contained in:
1 parent
52d5ecd7c6
commit
575de9ccbd
10 files changed
+80
-14
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: 2a7d70882a06d8cbdd5b9e6922b403faaadeb4f4
|
||||
README.zh.md: a8fdd70ff1c82db37cba64c072e67ec068de6cd9
|
||||
README.md: 196e16b03bef4a817b1ff28c8b4c5e3d7ed5309d
|
||||
README.zh.md: 22a55a767d48ab8dc6eeef86b06fb4526f5db11d
|
||||
@@ -40,7 +40,7 @@ The Account section appears first in Settings only while signed in; it is hidden
|
||||
|
||||
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
|
||||
|
||||
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
|
||||
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -40,7 +40,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
|
||||
|
||||
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
|
||||
|
||||
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮仍可使用。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
|
||||
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -17,9 +17,14 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copyCount, setCopyCount] = useState(0)
|
||||
const attempt = account.view?.attempt
|
||||
useEffect(() => { setCopied(false) }, [attempt?.id, attempt?.authorizeUrl])
|
||||
useEffect(() => { setCopyCount(0) }, [attempt?.id, attempt?.authorizeUrl])
|
||||
useEffect(() => {
|
||||
if (copyCount === 0) return
|
||||
const timer = setTimeout(() => { setCopyCount(0) }, 2000)
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [copyCount])
|
||||
const phase = attempt?.phase
|
||||
const active = busy || phase === 'initializing' || phase === 'waiting-browser' || phase === 'exchanging' || phase === 'committing'
|
||||
const expired = phase === 'expired'
|
||||
@@ -49,9 +54,9 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
|
||||
{t('browserPrompt')}<button type="button" className={css.link} disabled={!attempt?.authorizeUrl}
|
||||
onClick={() => {
|
||||
if (attempt?.authorizeUrl) void navigator.clipboard.writeText(attempt.authorizeUrl)
|
||||
.then(() => { setCopied(true) }, () => { setFailed(true) })
|
||||
.then(() => { setCopyCount(count => count + 1) }, () => { setFailed(true) })
|
||||
}}>
|
||||
{t(copied ? 'copiedLink' : 'copyLink')}
|
||||
{t(copyCount > 0 ? 'copiedLink' : 'copyLink')}
|
||||
</button>{t('browserDescription')}
|
||||
</p> : <p className={css.description}>
|
||||
{expired ? t('timeoutDescription') : error ? t('failed') : t('loginDescription')}
|
||||
|
||||
@@ -47,3 +47,29 @@ it('keeps an admitted credential commit open on Escape and close', () => {
|
||||
expect(props.close).not.toHaveBeenCalled()
|
||||
expect(props.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([en, zh])('restores the copy label two seconds after the latest successful copy', async (copy) => {
|
||||
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
const writeText = vi.fn(async () => {})
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
|
||||
mount({ id, phase: 'waiting-browser', authorizeUrl }, copy)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
|
||||
expect(writeText).toHaveBeenLastCalledWith(authorizeUrl)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copiedLink })) })
|
||||
expect(writeText).toHaveBeenCalledTimes(2)
|
||||
act(() => { vi.advanceTimersByTime(1999) })
|
||||
expect(screen.getByRole('button', { name: copy.copiedLink })).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
|
||||
expect(writeText).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
if (clipboard) Object.defineProperty(navigator, 'clipboard', clipboard)
|
||||
else Reflect.deleteProperty(navigator, 'clipboard')
|
||||
}
|
||||
})
|
||||
@@ -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: 7cc76d5cb003b98d8512601c498731d03385f7ba
|
||||
README.zh.md: b3df2f2da2309929eab6e53ef125dd55a1d99e3b
|
||||
README.md: 0d44a85b9802eef9e750ac092ff90e7474c22bd7
|
||||
README.zh.md: 746a2938d583e6eac82212ef67b81aa3ac28388b
|
||||
@@ -85,7 +85,7 @@ No model request prefix changes.
|
||||
|
||||
The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
|
||||
|
||||
The first profile read after sign-in uses the sanitized user returned by auth_exchange. A null or malformed user falls back to current; subsequent refreshes and Host restarts also query current. Exchange registers the submitted device information.
|
||||
The first profile read after sign-in uses the sanitized user returned by auth_exchange. A null or malformed user falls back to current; subsequent refreshes and Host restarts also query current. Failed current requests retain the latest successful profile for the same credential in Host memory; credential changes and disposal clear it. Exchange registers the submitted device information.
|
||||
|
||||
accountRequestHeaders overlays requestHeaders for current, balance and embedded Platform page/API requests. Cookie pairs merge by name so routing overrides retain other deployment cookies. Authorization initialization, exchange, cancellation and logout keep requestHeaders. Both maps remain private to Host and Electron main; renderer bootstrap receives only origin and token.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ inferenceOrigin 默认为 `https://api.deepseek.com`。私有部署 patch 可将
|
||||
|
||||
[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
|
||||
|
||||
登录后首次读取资料使用 auth_exchange 返回并经筛选的 user。user 为 null 或格式无效时回退到 current;后续刷新及 Host 重启也查询 current。exchange 负责登记提交的设备信息。
|
||||
登录后首次读取资料使用 auth_exchange 返回并经筛选的 user。user 为 null 或格式无效时回退到 current;后续刷新及 Host 重启也查询 current。current 请求失败时保留 Host 内存中同一凭证最近一次成功的资料;凭证变更或提供者销毁时清空。exchange 负责登记提交的设备信息。
|
||||
|
||||
accountRequestHeaders 覆盖 requestHeaders,供 current、余额以及内嵌 Platform 页面/API 请求使用。Cookie 按名称合并,路由覆盖保留其他部署 Cookie。授权初始化、兑换、取消和退登保持使用 requestHeaders。两组请求头仅供 Host 和 Electron 主进程使用;渲染层 bootstrap 仅接收 origin 和 token。
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export class PlatformAccount extends DeepSeekAccount {
|
||||
private readonly revocations = new Set<Promise<void>>()
|
||||
private attempt: Attempt | undefined
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private lastProfile: Extract<AccountDetails['profile'], { status: 'ready' }> | undefined
|
||||
private detailsLifetime = new AbortController()
|
||||
private closed = false
|
||||
private removing: Promise<AccountView> | undefined
|
||||
@@ -174,7 +175,13 @@ export class PlatformAccount extends DeepSeekAccount {
|
||||
}
|
||||
}
|
||||
|
||||
override getProfile(): Promise<AccountDetails['profile'] | null> { return this.getDetail('profile') }
|
||||
override async getProfile(): Promise<AccountDetails['profile'] | null> {
|
||||
const lifetime = this.detailsLifetime
|
||||
const result = await this.getDetail('profile')
|
||||
if (this.detailsLifetime !== lifetime) return null
|
||||
if (result?.status === 'ready') this.lastProfile = result
|
||||
return result?.status === 'failed' ? this.lastProfile ?? result : result
|
||||
}
|
||||
|
||||
override getBalance(): Promise<AccountDetails['balance'] | null> { return this.getDetail('balance') }
|
||||
|
||||
@@ -334,6 +341,7 @@ export class PlatformAccount extends DeepSeekAccount {
|
||||
}
|
||||
|
||||
private invalidateDetails(): void {
|
||||
this.lastProfile = undefined
|
||||
this.detailsLifetime.abort()
|
||||
this.detailsLifetime = new AbortController()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ async function fixture(
|
||||
let origin = ''
|
||||
let detailsHold = false
|
||||
let balanceHold = false
|
||||
let profileFailed = false
|
||||
let summaryFailed = false
|
||||
let logoutFailed = false
|
||||
let logoutHold = false
|
||||
@@ -83,7 +84,8 @@ async function fixture(
|
||||
bonus_wallets: [{ currency: 'CNY', balance: '10.00' }] }
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ code: 0, data: {
|
||||
biz_code: summaryFailed && req.url === '/api/v0/users/get_user_summary' ? 17 : 0, biz_data: value,
|
||||
biz_code: (profileFailed && req.url === '/auth-api/v0/users/current')
|
||||
|| (summaryFailed && req.url === '/api/v0/users/get_user_summary') ? 17 : 0, biz_data: value,
|
||||
} }))
|
||||
return
|
||||
}
|
||||
@@ -148,6 +150,7 @@ async function fixture(
|
||||
ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount,
|
||||
holdLogout: () => { logoutHold = true },
|
||||
failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted,
|
||||
failProfile: (failed: boolean) => { profileFailed = failed },
|
||||
holdBalance: () => { balanceHold = true },
|
||||
holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true },
|
||||
invalidateSummary: () => { invalidSummary = true }, dispose: () => provider.dispose(), exchanged, release,
|
||||
@@ -717,3 +720,27 @@ it('rejects unsupported native desktop platforms in configuration', () => {
|
||||
// @ts-expect-error Configuration files can name unsupported operating systems.
|
||||
expect(() => Config({ desktopPlatform: 'linux' })).toThrow()
|
||||
})
|
||||
|
||||
it('retains successful profile data on current failure only for the same credential', async () => {
|
||||
const f = await fixture()
|
||||
f.exchangeResponse({ user: { email: 'e***@example.invalid', id_profile: { name: 'Exchange User' } } })
|
||||
await f.account.startSignIn('en', f.callbackOrigin, 'desktop')
|
||||
await f.wait('waiting-browser')
|
||||
await fetch(f.callback(), { redirect: 'manual' })
|
||||
const initial = await f.account.getProfile()
|
||||
expect(initial).toMatchObject({ status: 'ready', value: { name: 'Exchange User' } })
|
||||
f.failProfile(true)
|
||||
expect(await f.account.getProfile()).toEqual(initial)
|
||||
f.failProfile(false)
|
||||
const refreshed = await f.account.getProfile()
|
||||
expect(refreshed).toMatchObject({ status: 'ready', value: { name: 'Test Account' } })
|
||||
f.failProfile(true)
|
||||
expect(await f.account.getProfile()).toEqual(refreshed)
|
||||
const key = credentialKey('deepseek-account-platform', 'default')
|
||||
await f.ctx.credentials.modifyRecord(key, () => Promise.resolve({
|
||||
kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'replacement-token' },
|
||||
}))
|
||||
expect(await f.account.getProfile()).toEqual({ status: 'failed' })
|
||||
await f.account.signOut()
|
||||
expect(await f.account.getProfile()).toBeNull()
|
||||
})
|
||||
Reference in new issue
Block a user