feat(desktop): DB-T3 侧栏用户卡 + /me 资料页(§21 U-4)
- 侧栏底部用户卡:U-4 展示名 + 邮箱,离线 amber 圆点,点击进 /me - /me 资料页(ROUTES_AREA workspace 页):三字段白名单编辑 (username/nickname/phone,只提交改动项),离线缓存数据时间横幅, 登出入口(登出≠解绑 U-1) - electron:PATCH /api/v2/me 客户端 + update-profile IPC(email 等 非白名单键一律丢弃;响应四字段合并进完整快照,不覆盖 plan 等) - i18n userAccount.me 20 键 × 6 语言;electron +3 / ui +8 测试全绿 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
login: payload => ipcRenderer.invoke('hermes:account:login', payload),
|
||||
status: () => ipcRenderer.invoke('hermes:account:status'),
|
||||
me: () => ipcRenderer.invoke('hermes:account:me'),
|
||||
updateProfile: payload => ipcRenderer.invoke('hermes:account:update-profile', payload),
|
||||
logout: () => ipcRenderer.invoke('hermes:account:logout'),
|
||||
registerStart: payload => ipcRenderer.invoke('hermes:account:register-start', payload),
|
||||
registerResend: payload => ipcRenderer.invoke('hermes:account:register-resend', payload),
|
||||
|
||||
@@ -183,6 +183,39 @@ export async function relayAccountFetchMe(
|
||||
return parseRelayMe(payload)
|
||||
}
|
||||
|
||||
/** 本人可改字段(服务端 PROFILE_FIELDS 同口径;邮箱永不可改)。 */
|
||||
export interface RelayAccountProfilePatch {
|
||||
username?: string | null
|
||||
nickname?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v2/me。响应的 profile 只含 email/username/nickname/phone/status,
|
||||
* 不含 plan/periodEnd/dataAsOf——调用方负责并入已有快照,不得以偏概全。
|
||||
*/
|
||||
export async function relayAccountUpdateProfile(
|
||||
site: string,
|
||||
accessToken: string,
|
||||
patch: RelayAccountProfilePatch,
|
||||
fetcher: FetchLike
|
||||
): Promise<Pick<RelayAccountProfile, 'email' | 'username' | 'nickname' | 'phone'>> {
|
||||
const payload = await request(fetcher, `${site}/api/v2/me`, {
|
||||
method: 'PATCH',
|
||||
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
})
|
||||
|
||||
const profile = payload?.profile ?? {}
|
||||
|
||||
return {
|
||||
email: typeof profile.email === 'string' ? profile.email : '',
|
||||
username: typeof profile.username === 'string' ? profile.username : '',
|
||||
nickname: typeof profile.nickname === 'string' ? profile.nickname : '',
|
||||
phone: typeof profile.phone === 'string' ? profile.phone : ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册第一步:投递验证邮件。202 与「邮箱已被注册」同形(防枚举)。 */
|
||||
export async function relayAccountRegisterStart(
|
||||
site: string,
|
||||
|
||||
@@ -347,3 +347,88 @@ test('register-start 失败码透传(weak_password / registration_rate_limited
|
||||
code: 'weak_password'
|
||||
})
|
||||
})
|
||||
|
||||
test('update-profile:PATCH /me 白名单三字段,合并回完整快照落盘', async () => {
|
||||
const { handlers, ipcMain } = fakeIpcMain()
|
||||
const { io } = fakeIo()
|
||||
|
||||
const { fetcher, calls } = makeFetcher((url, init) => {
|
||||
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
|
||||
|
||||
if (url.endsWith('/me') && init?.method === 'PATCH') {
|
||||
return { status: 200, body: { profile: { email: 'u@example.com', username: '', nickname: '新小赫', phone: '', status: 'active' } } }
|
||||
}
|
||||
|
||||
if (url.endsWith('/me')) {return { status: 200, body: ME }}
|
||||
|
||||
return { status: 200, body: {} }
|
||||
})
|
||||
|
||||
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
|
||||
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
|
||||
|
||||
const result = await handlers.get('hermes:account:update-profile')!(null, {
|
||||
nickname: '新小赫',
|
||||
phone: null,
|
||||
email: 'hacker@example.com',
|
||||
bogus: 1
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
const patchCall = calls.find(c => c.init?.method === 'PATCH')
|
||||
assert.equal(patchCall?.url, 'https://r/api/v2/me')
|
||||
assert.equal(patchCall?.init?.headers?.authorization, 'Bearer AT-1')
|
||||
// email/bogus 被丢弃;null 清空原样转发
|
||||
assert.deepEqual(JSON.parse(patchCall?.init?.body), { nickname: '新小赫', phone: null })
|
||||
// 快照合并:PATCH 回的四字段覆盖,plan/periodEnd/dataAsOf 保留
|
||||
const stored = loadUserAccount(io)
|
||||
assert.equal(stored?.profile?.nickname, '新小赫')
|
||||
assert.equal(stored?.profile?.dataAsOf, 't0')
|
||||
assert.equal(result.profile.dataAsOf, 't0')
|
||||
})
|
||||
|
||||
test('update-profile:空补丁不出网;服务端拒绝码透传', async () => {
|
||||
const { handlers, ipcMain } = fakeIpcMain()
|
||||
const { io } = fakeIo()
|
||||
|
||||
const { fetcher, calls } = makeFetcher(url => {
|
||||
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
|
||||
|
||||
if (url.endsWith('/me')) {return { status: 200, body: ME }}
|
||||
|
||||
return { status: 200, body: {} }
|
||||
})
|
||||
|
||||
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
|
||||
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
|
||||
const before = calls.length
|
||||
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { email: 'x@y.z' }), {
|
||||
ok: false,
|
||||
code: 'nothing_to_update'
|
||||
})
|
||||
assert.equal(calls.length, before)
|
||||
})
|
||||
|
||||
test('update-profile:服务端 403 profile_self_service_disabled 结构化透传', async () => {
|
||||
const { handlers, ipcMain } = fakeIpcMain()
|
||||
const { io } = fakeIo()
|
||||
|
||||
const { fetcher } = makeFetcher((url, init) => {
|
||||
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
|
||||
|
||||
if (url.endsWith('/me') && init?.method === 'PATCH') {return { status: 403, text: 'profile_self_service_disabled' }}
|
||||
|
||||
if (url.endsWith('/me')) {return { status: 200, body: ME }}
|
||||
|
||||
return { status: 200, body: {} }
|
||||
})
|
||||
|
||||
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
|
||||
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
|
||||
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { nickname: 'x' }), {
|
||||
ok: false,
|
||||
code: 'profile_self_service_disabled'
|
||||
})
|
||||
// 失败不落盘改动
|
||||
assert.equal(loadUserAccount(io)?.profile?.nickname, '小赫')
|
||||
})
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
relayAccountRegisterStart,
|
||||
relayAccountResetConfirm,
|
||||
relayAccountResetRequest,
|
||||
type RelayAccountSession
|
||||
type RelayAccountSession,
|
||||
relayAccountUpdateProfile
|
||||
} from './relay-account'
|
||||
import {
|
||||
loadUserAccount,
|
||||
@@ -210,6 +211,36 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
||||
return { profile }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:account:update-profile', async (_event, payload) => {
|
||||
// 只放行服务端 PROFILE_FIELDS 同口径三字段;null = 清空。其余键(含
|
||||
// email)一律丢弃,绝不转发——renderer 按码上文案,不需要 email_not_editable。
|
||||
const patch: Record<string, string | null> = {}
|
||||
|
||||
for (const field of ['username', 'nickname', 'phone'] as const) {
|
||||
const value = payload?.[field]
|
||||
|
||||
if (value === null) {patch[field] = null}
|
||||
|
||||
if (typeof value === 'string') {patch[field] = value}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return { ok: false, code: 'nothing_to_update' }
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await withSession(session => relayAccountUpdateProfile(session.site, session.accessToken, patch, fetcher))
|
||||
const account = current()!
|
||||
const profile = { ...account.profile, ...updated }
|
||||
|
||||
store({ ...account, profile, profileAsOf: new Date().toISOString() })
|
||||
|
||||
return { ok: true, profile }
|
||||
} catch (error) {
|
||||
return failure(error)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:account:logout', async () => {
|
||||
const account = current()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -1932,6 +1933,11 @@ export function ChatSidebar({
|
||||
|
||||
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
|
||||
{/* §21 用户卡:signed_in 才渲染,点击进 /me 资料页。 */}
|
||||
<div className="shrink-0 px-0.5 pb-0.5">
|
||||
<UserAccountCard />
|
||||
</div>
|
||||
|
||||
{/* Off, the statusbar's profile dropdown (beside the gateway switcher)
|
||||
takes over — the rail is a duplicate door for bot-only setups. */}
|
||||
{profileRailVisible && (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* user-account-card.tsx — 侧栏底部的用户卡(§21 DB-T3,U-4)。
|
||||
*
|
||||
* signed_in 时显示:字母章 + U-4 展示名 + 邮箱;离线(U-3)在名后标
|
||||
* amber 圆点,点击进 `/me` 资料页(缓存数据时间在那里注明)。
|
||||
* 未登录不渲染——那时登录门(DB-T2)本就盖着全屏。
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { ME_ROUTE } from '@/app/me'
|
||||
import { navigateToWorkspacePage } from '@/app/routes'
|
||||
import { AvatarChip } from '@/components/ui/avatar-chip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $userAccount, userAccountDisplayName } from '@/store/user-account'
|
||||
|
||||
export function UserAccountCard() {
|
||||
const { t } = useI18n()
|
||||
const account = useStore($userAccount)
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (account.status !== 'signed_in') {return null}
|
||||
const name = userAccountDisplayName(account.profile) || account.profile?.email || ''
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={t.userAccount.me.cardAria}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent"
|
||||
onClick={() => navigateToWorkspacePage(navigate, ME_ROUTE)}
|
||||
type="button"
|
||||
>
|
||||
<AvatarChip className="size-7" name={name} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
{account.offline && (
|
||||
<span
|
||||
aria-label={t.userAccount.me.cardOffline}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500"
|
||||
title={t.userAccount.me.cardOffline}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{account.profile?.email && name !== account.profile.email && (
|
||||
<span className="block truncate text-xs text-muted-foreground">{account.profile.email}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
* context; registered panes render `<WiredPane part="…"/>` to consume them.
|
||||
*/
|
||||
|
||||
import '@/app/me'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* me/index.tsx — `/me` 账号资料页(§21 DB-T3,U-4)。
|
||||
*
|
||||
* 资料卡:头像字母章 + U-4 展示名 + 邮箱(登录标识,永不可改)+ 套餐/
|
||||
* 周期/数据时间;离线(U-3)标 amber 横幅并注明缓存数据时间。
|
||||
* 可改字段只有服务端 PROFILE_FIELDS 三件套(username/nickname/phone),
|
||||
* 只提交改动项;保存失败按 code 上文案。登出入口也在此——登出≠解绑
|
||||
* (U-1),只清用户会话,门随即落回。
|
||||
*
|
||||
* 路由走 ROUTES_AREA 贡献(workspace 页),模块导入时注册,wiring 引入。
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FormEvent, useState } from 'react'
|
||||
|
||||
import { type RouteContribution, ROUTES_AREA } from '@/app/routes'
|
||||
import { AvatarChip } from '@/components/ui/avatar-chip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { Translations } from '@/i18n/types'
|
||||
import {
|
||||
$userAccount,
|
||||
type HermesUserAccountProfilePatch,
|
||||
userAccountDisplayName,
|
||||
UserAccountFailure,
|
||||
userAccountLogout,
|
||||
userAccountUpdateProfile
|
||||
} from '@/store/user-account'
|
||||
|
||||
export const ME_ROUTE = '/me'
|
||||
|
||||
function updateErrorText(copy: Translations['userAccount']['me'], failure: UserAccountFailure): string {
|
||||
switch (failure.code) {
|
||||
case 'profile_self_service_disabled':
|
||||
return copy.errorProfileDisabled
|
||||
|
||||
case 'profile_field_too_long':
|
||||
return copy.errorTooLong
|
||||
|
||||
case 'network':
|
||||
return copy.errorNetwork
|
||||
|
||||
default:
|
||||
return copy.errorServer
|
||||
}
|
||||
}
|
||||
|
||||
export function MePage() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.userAccount.me
|
||||
const account = useStore($userAccount)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [nickname, setNickname] = useState<string | null>(null)
|
||||
const [phone, setPhone] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (account.status !== 'signed_in' || !account.profile) {
|
||||
return <EmptyState title={copy.unavailable} />
|
||||
}
|
||||
|
||||
const { profile } = account
|
||||
const displayName = userAccountDisplayName(profile)
|
||||
|
||||
// null = 未碰过,跟随快照;保存成功后快照刷新,草稿位随之归零。
|
||||
const draft = {
|
||||
username: username ?? profile.username,
|
||||
nickname: nickname ?? profile.nickname,
|
||||
phone: phone ?? profile.phone
|
||||
}
|
||||
|
||||
const dirty =
|
||||
draft.username !== profile.username || draft.nickname !== profile.nickname || draft.phone !== profile.phone
|
||||
|
||||
function edit(field: 'username' | 'nickname' | 'phone', value: string) {
|
||||
setSaved(false)
|
||||
setError(null)
|
||||
|
||||
if (field === 'username') {setUsername(value)}
|
||||
|
||||
if (field === 'nickname') {setNickname(value)}
|
||||
|
||||
if (field === 'phone') {setPhone(value)}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const patch: HermesUserAccountProfilePatch = {}
|
||||
|
||||
if (draft.username !== profile!.username) {patch.username = draft.username}
|
||||
|
||||
if (draft.nickname !== profile!.nickname) {patch.nickname = draft.nickname}
|
||||
|
||||
if (draft.phone !== profile!.phone) {patch.phone = draft.phone}
|
||||
|
||||
if (Object.keys(patch).length === 0) {return}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await userAccountUpdateProfile(patch)
|
||||
setUsername(null)
|
||||
setNickname(null)
|
||||
setPhone(null)
|
||||
setSaved(true)
|
||||
} catch (failure) {
|
||||
setError(updateErrorText(copy, failure instanceof UserAccountFailure ? failure : new UserAccountFailure('server')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await userAccountLogout()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg space-y-6 p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<AvatarChip className="size-12 rounded-xl text-lg" name={displayName || profile.email} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-semibold">{displayName || profile.email}</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">{profile.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{account.offline && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600">
|
||||
{copy.offlineNotice(account.profileAsOf ?? profile.dataAsOf)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span>{copy.nicknameLabel}</span>
|
||||
<Input maxLength={64} onChange={event => edit('nickname', event.target.value)} value={draft.nickname} />
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span>{copy.usernameLabel}</span>
|
||||
<Input maxLength={64} onChange={event => edit('username', event.target.value)} value={draft.username} />
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span>{copy.phoneLabel}</span>
|
||||
<Input maxLength={32} onChange={event => edit('phone', event.target.value)} value={draft.phone} />
|
||||
</label>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button disabled={busy || !dirty} type="submit">
|
||||
{busy ? copy.saving : copy.save}
|
||||
</Button>
|
||||
{saved && !dirty && <span className="text-sm text-muted-foreground">{copy.saved}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<dl className="space-y-2 border-t border-border pt-4 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{copy.emailLabel}</dt>
|
||||
<dd className="truncate">{profile.email}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{copy.planLabel}</dt>
|
||||
<dd>{profile.planId || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{copy.periodEndLabel}</dt>
|
||||
<dd>{profile.periodEnd || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{copy.dataAsOfLabel}</dt>
|
||||
<dd>{profile.dataAsOf || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<Button disabled={busy} onClick={logout} variant="outline">
|
||||
{copy.logout}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{copy.logoutHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registry.register({
|
||||
id: 'me-page',
|
||||
area: ROUTES_AREA,
|
||||
source: 'core',
|
||||
data: { path: ME_ROUTE } satisfies RouteContribution,
|
||||
render: () => <MePage />
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
|
||||
import { MePage } from '@/app/me'
|
||||
import { $userAccount } from '@/store/user-account'
|
||||
|
||||
const PROFILE = {
|
||||
email: 'u@example.com',
|
||||
username: 'u-hermes',
|
||||
nickname: '小赫',
|
||||
phone: '13800000000',
|
||||
planId: 'pro',
|
||||
periodEnd: '2026-10-01',
|
||||
dataAsOf: 't0'
|
||||
}
|
||||
|
||||
function signedIn(overrides: Record<string, unknown> = {}) {
|
||||
$userAccount.set({ status: 'signed_in', site: 'https://r', offline: false, profile: PROFILE, profileAsOf: null, ...overrides })
|
||||
}
|
||||
|
||||
function stubBridge(overrides: Record<string, unknown> = {}) {
|
||||
const api = {
|
||||
login: vi.fn(),
|
||||
status: vi.fn(async () => ({ loggedIn: true, site: 'https://r', offline: false, profile: PROFILE })),
|
||||
me: vi.fn(async () => ({ profile: PROFILE })),
|
||||
updateProfile: vi.fn(async (payload: any): Promise<any> => ({ ok: true, profile: { ...PROFILE, ...payload } })),
|
||||
logout: vi.fn(async () => ({ ok: true })),
|
||||
registerStart: vi.fn(),
|
||||
registerResend: vi.fn(),
|
||||
resetRequest: vi.fn(),
|
||||
resetConfirm: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { userAccount: api } })
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
$userAccount.set({ status: 'unknown' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
})
|
||||
|
||||
describe('MePage', () => {
|
||||
it('signed_in:渲染 U-4 展示名、邮箱与套餐信息', () => {
|
||||
stubBridge()
|
||||
signedIn()
|
||||
render(<MePage />)
|
||||
expect(screen.getByText('小赫')).toBeTruthy()
|
||||
expect(screen.getAllByText('u@example.com').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('pro')).toBeTruthy()
|
||||
expect(screen.getByText('2026-10-01')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('离线:amber 横幅注明缓存数据时间', () => {
|
||||
stubBridge()
|
||||
signedIn({ offline: true, profileAsOf: '2026-09-19T08:00:00Z' })
|
||||
render(<MePage />)
|
||||
expect(screen.getByText(/2026-09-19T08:00:00Z/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('只提交改动字段;成功后草稿归零并显示已保存', async () => {
|
||||
const api = stubBridge()
|
||||
signedIn()
|
||||
render(<MePage />)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('小赫'), { target: { value: '新小赫' } })
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
await waitFor(() => expect(api.updateProfile).toHaveBeenCalledWith({ nickname: '新小赫' }))
|
||||
await screen.findByText('Saved.')
|
||||
// 快照回写:展示名与表单值都更新
|
||||
expect(($userAccount.get() as any).profile.nickname).toBe('新小赫')
|
||||
})
|
||||
|
||||
it('未改动时保存按钮禁用;服务端拒绝按码上文案', async () => {
|
||||
const api = stubBridge()
|
||||
api.updateProfile.mockResolvedValue({ ok: false, code: 'profile_self_service_disabled' })
|
||||
signedIn()
|
||||
render(<MePage />)
|
||||
expect((screen.getByText('Save') as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('u-hermes'), { target: { value: 'u-new' } })
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
await screen.findByText('Profile editing is disabled on this server.')
|
||||
})
|
||||
|
||||
it('登出:走 bridge 并落 signed_out(登出≠解绑,门自落)', async () => {
|
||||
const api = stubBridge()
|
||||
signedIn()
|
||||
render(<MePage />)
|
||||
fireEvent.click(screen.getByText('Sign out'))
|
||||
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
|
||||
expect(api.logout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('无会话/无资料:空态而不是崩溃', () => {
|
||||
stubBridge()
|
||||
$userAccount.set({ status: 'signed_out' })
|
||||
render(<MePage />)
|
||||
expect(screen.getByText('Account details are unavailable.')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserAccountCard', () => {
|
||||
it('signed_in:渲染展示名+邮箱;未登录不渲染', () => {
|
||||
stubBridge()
|
||||
signedIn()
|
||||
|
||||
const { unmount } = render(
|
||||
<MemoryRouter>
|
||||
<UserAccountCard />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('小赫')).toBeTruthy()
|
||||
expect(screen.getByText('u@example.com')).toBeTruthy()
|
||||
unmount()
|
||||
|
||||
$userAccount.set({ status: 'signed_out' })
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UserAccountCard />
|
||||
</MemoryRouter>
|
||||
)
|
||||
expect(screen.queryByText('小赫')).toBeNull()
|
||||
})
|
||||
|
||||
it('离线:名旁标 amber 圆点', () => {
|
||||
stubBridge()
|
||||
signedIn({ offline: true })
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UserAccountCard />
|
||||
</MemoryRouter>
|
||||
)
|
||||
expect(screen.getByLabelText('Offline — showing cached data')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Vendored
+9
-1
@@ -12,7 +12,14 @@ import type {
|
||||
PetOverlayStatePayload
|
||||
} from './store/pet-overlay'
|
||||
import type { QuickEntryStatePush, QuickEntryStatus, QuickEntrySubmitPayload } from './store/quick-entry'
|
||||
import type { HermesUserAccountProfile, HermesUserAccountStatus } from './store/user-account'
|
||||
import type {
|
||||
HermesUserAccountLoginResult,
|
||||
HermesUserAccountOpResult,
|
||||
HermesUserAccountProfile,
|
||||
HermesUserAccountProfilePatch,
|
||||
HermesUserAccountStatus,
|
||||
HermesUserAccountUpdateResult
|
||||
} from './store/user-account'
|
||||
|
||||
export {}
|
||||
|
||||
@@ -44,6 +51,7 @@ declare global {
|
||||
login: (payload: { site: string; email: string; password: string }) => Promise<HermesUserAccountLoginResult>
|
||||
status: () => Promise<HermesUserAccountStatus>
|
||||
me: () => Promise<{ profile: HermesUserAccountProfile }>
|
||||
updateProfile: (payload: HermesUserAccountProfilePatch) => Promise<HermesUserAccountUpdateResult>
|
||||
logout: () => Promise<{ ok: boolean }>
|
||||
registerStart: (payload: { site: string; email: string; password: string }) => Promise<HermesUserAccountOpResult>
|
||||
registerResend: (payload: { site: string; email: string }) => Promise<HermesUserAccountOpResult>
|
||||
|
||||
@@ -3323,7 +3323,28 @@ export const ar = defineLocale({
|
||||
lockedTitle: 'هذا الجهاز مرتبط بحساب آخر',
|
||||
lockedBody: email => `هذا الجهاز مرتبط بـ ${email}. ألغِ الارتباط هناك أولاً ثم سجّل الدخول بحساب مختلف.`,
|
||||
lockedBodyUnknown: 'هذا الجهاز مرتبط بحساب آخر. ألغِ الارتباط أولاً.',
|
||||
bindingInvalidBody: 'بيانات ارتباط الجهاز تالفة. أصلح تثبيت Hermes agent قبل تسجيل الدخول.'
|
||||
bindingInvalidBody: 'بيانات ارتباط الجهاز تالفة. أصلح تثبيت Hermes agent قبل تسجيل الدخول.',
|
||||
me: {
|
||||
cardAria: 'فتح صفحة الحساب',
|
||||
cardOffline: 'غير متصل — تُعرض بيانات مخزنة',
|
||||
unavailable: 'بيانات الحساب غير متاحة.',
|
||||
offlineNotice: asOf => `غير متصل — تُعرض البيانات كما في ${asOf}.`,
|
||||
nicknameLabel: 'الاسم المستعار',
|
||||
usernameLabel: 'اسم المستخدم',
|
||||
phoneLabel: 'الهاتف',
|
||||
emailLabel: 'البريد الإلكتروني',
|
||||
planLabel: 'الخطة',
|
||||
periodEndLabel: 'نهاية الفترة الحالية',
|
||||
dataAsOfLabel: 'البيانات كما في',
|
||||
save: 'حفظ',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
saved: 'تم الحفظ.',
|
||||
logout: 'تسجيل الخروج',
|
||||
logoutHint: 'تسجيل الخروج ينهي جلسة المستخدم فقط — يستمر عمل الجهاز ويبقى مرتبطًا.',
|
||||
errorProfileDisabled: 'تعديل الملف الشخصي معطّل على هذا الخادم.',
|
||||
errorTooLong: 'أحد الحقول يتجاوز الحد الأقصى للطول.',
|
||||
errorNetwork: 'تعذّر الوصول إلى الخادم. تحقق من الشبكة وأعد المحاولة.',
|
||||
errorServer: 'خطأ في الخادم. أعد المحاولة لاحقًا.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -4564,7 +4564,28 @@ export const en: Translations = {
|
||||
lockedTitle: 'This machine is bound to another account',
|
||||
lockedBody: email => `This machine is bound to ${email}. Unbind it there before signing in with a different account.`,
|
||||
lockedBodyUnknown: 'This machine is bound to another account. Unbind it before signing in.',
|
||||
bindingInvalidBody: 'The machine binding data is damaged. Repair the Hermes agent installation before signing in.'
|
||||
bindingInvalidBody: 'The machine binding data is damaged. Repair the Hermes agent installation before signing in.',
|
||||
me: {
|
||||
cardAria: 'Open your account page',
|
||||
cardOffline: 'Offline — showing cached data',
|
||||
unavailable: 'Account details are unavailable.',
|
||||
offlineNotice: asOf => `Offline — showing data as of ${asOf}.`,
|
||||
nicknameLabel: 'Nickname',
|
||||
usernameLabel: 'Username',
|
||||
phoneLabel: 'Phone',
|
||||
emailLabel: 'Email',
|
||||
planLabel: 'Plan',
|
||||
periodEndLabel: 'Current period ends',
|
||||
dataAsOfLabel: 'Data as of',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved.',
|
||||
logout: 'Sign out',
|
||||
logoutHint: 'Signing out only ends this user session — the machine keeps running and stays bound.',
|
||||
errorProfileDisabled: 'Profile editing is disabled on this server.',
|
||||
errorTooLong: 'A field exceeds the length limit.',
|
||||
errorNetwork: 'Cannot reach the server. Check the network and try again.',
|
||||
errorServer: 'Server error. Please try again later.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3737,7 +3737,28 @@ export const ja = defineLocale({
|
||||
lockedTitle: 'このマシンは別のアカウントにバインドされています',
|
||||
lockedBody: email => `このマシンは ${email} にバインドされています。別のアカウントでサインインする前に、そちらでバインド解除してください。`,
|
||||
lockedBodyUnknown: 'このマシンは別のアカウントにバインドされています。先にバインド解除してください。',
|
||||
bindingInvalidBody: 'マシンのバインドデータが壊れています。サインイン前に Hermes エージェントのインストールを修復してください。'
|
||||
bindingInvalidBody: 'マシンのバインドデータが壊れています。サインイン前に Hermes エージェントのインストールを修復してください。',
|
||||
me: {
|
||||
cardAria: 'アカウントページを開く',
|
||||
cardOffline: 'オフライン — キャッシュデータを表示中',
|
||||
unavailable: 'アカウント情報を利用できません。',
|
||||
offlineNotice: asOf => `オフライン — ${asOf} 時点のデータを表示しています。`,
|
||||
nicknameLabel: 'ニックネーム',
|
||||
usernameLabel: 'ユーザー名',
|
||||
phoneLabel: '電話番号',
|
||||
emailLabel: 'メールアドレス',
|
||||
planLabel: 'プラン',
|
||||
periodEndLabel: '現在の期間の終了',
|
||||
dataAsOfLabel: 'データ時点',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
saved: '保存しました。',
|
||||
logout: 'サインアウト',
|
||||
logoutHint: 'サインアウトはユーザーセッションを終了するだけです — マシンは稼働を続け、バインドも維持されます。',
|
||||
errorProfileDisabled: 'このサーバーではプロフィール編集が無効です。',
|
||||
errorTooLong: 'フィールドが長さの上限を超えています。',
|
||||
errorNetwork: 'サーバーに接続できません。ネットワークを確認して再試行してください。',
|
||||
errorServer: 'サーバーエラー。後でもう一度お試しください。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -3944,7 +3944,28 @@ export const ru = defineLocale({
|
||||
lockedTitle: 'Эта машина привязана к другому аккаунту',
|
||||
lockedBody: email => `Эта машина привязана к ${email}. Сначала отвяжите её там, затем входите с другим аккаунтом.`,
|
||||
lockedBodyUnknown: 'Эта машина привязана к другому аккаунту. Сначала отвяжите её.',
|
||||
bindingInvalidBody: 'Данные привязки машины повреждены. Восстановите установку Hermes agent перед входом.'
|
||||
bindingInvalidBody: 'Данные привязки машины повреждены. Восстановите установку Hermes agent перед входом.',
|
||||
me: {
|
||||
cardAria: 'Открыть страницу аккаунта',
|
||||
cardOffline: 'Офлайн — показаны кэшированные данные',
|
||||
unavailable: 'Данные аккаунта недоступны.',
|
||||
offlineNotice: asOf => `Офлайн — показаны данные на ${asOf}.`,
|
||||
nicknameLabel: 'Отображаемое имя',
|
||||
usernameLabel: 'Имя пользователя',
|
||||
phoneLabel: 'Телефон',
|
||||
emailLabel: 'Эл. почта',
|
||||
planLabel: 'Тариф',
|
||||
periodEndLabel: 'Конец текущего периода',
|
||||
dataAsOfLabel: 'Данные на',
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение…',
|
||||
saved: 'Сохранено.',
|
||||
logout: 'Выйти',
|
||||
logoutHint: 'Выход завершает только пользовательскую сессию — машина продолжает работать и остаётся привязанной.',
|
||||
errorProfileDisabled: 'Редактирование профиля отключено на этом сервере.',
|
||||
errorTooLong: 'Поле превышает ограничение длины.',
|
||||
errorNetwork: 'Не удаётся связаться с сервером. Проверьте сеть и повторите.',
|
||||
errorServer: 'Ошибка сервера. Повторите позже.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -3887,5 +3887,27 @@ export interface Translations {
|
||||
lockedBody: (email: string) => string
|
||||
lockedBodyUnknown: string
|
||||
bindingInvalidBody: string
|
||||
me: {
|
||||
cardAria: string
|
||||
cardOffline: string
|
||||
unavailable: string
|
||||
offlineNotice: (asOf: string) => string
|
||||
nicknameLabel: string
|
||||
usernameLabel: string
|
||||
phoneLabel: string
|
||||
emailLabel: string
|
||||
planLabel: string
|
||||
periodEndLabel: string
|
||||
dataAsOfLabel: string
|
||||
save: string
|
||||
saving: string
|
||||
saved: string
|
||||
logout: string
|
||||
logoutHint: string
|
||||
errorProfileDisabled: string
|
||||
errorTooLong: string
|
||||
errorNetwork: string
|
||||
errorServer: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3657,7 +3657,28 @@ export const zhHant = defineLocale({
|
||||
lockedTitle: '此機器已綁定其他帳號',
|
||||
lockedBody: email => `此機器已綁定 ${email}。請先在原帳號解綁,再用其他帳號登入。`,
|
||||
lockedBodyUnknown: '此機器已綁定其他帳號,請先解綁再登入。',
|
||||
bindingInvalidBody: '機器綁定資料已損毀,請先修復 Hermes agent 安裝再登入。'
|
||||
bindingInvalidBody: '機器綁定資料已損毀,請先修復 Hermes agent 安裝再登入。',
|
||||
me: {
|
||||
cardAria: '開啟帳號頁',
|
||||
cardOffline: '離線——顯示快取資料',
|
||||
unavailable: '帳號資料不可用。',
|
||||
offlineNotice: asOf => `離線——顯示 ${asOf} 的快取資料。`,
|
||||
nicknameLabel: '暱稱',
|
||||
usernameLabel: '使用者名稱',
|
||||
phoneLabel: '聯絡電話',
|
||||
emailLabel: '電子郵件',
|
||||
planLabel: '方案',
|
||||
periodEndLabel: '目前週期截止',
|
||||
dataAsOfLabel: '資料時間',
|
||||
save: '儲存',
|
||||
saving: '儲存中…',
|
||||
saved: '已儲存。',
|
||||
logout: '登出',
|
||||
logoutHint: '登出只結束使用者工作階段——機器通道繼續運作,綁定關係不變。',
|
||||
errorProfileDisabled: '伺服器已關閉自助修改資料。',
|
||||
errorTooLong: '有欄位超出長度上限。',
|
||||
errorNetwork: '無法連接伺服器,請檢查網路後重試。',
|
||||
errorServer: '伺服器錯誤,請稍後重試。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -4462,7 +4462,28 @@ export const zh = defineLocale({
|
||||
lockedTitle: '该机器已绑定其他账号',
|
||||
lockedBody: email => `该机器已绑定 ${email}。请先在原账号解绑,再用其他账号登录。`,
|
||||
lockedBodyUnknown: '该机器已绑定其他账号,请先解绑再登录。',
|
||||
bindingInvalidBody: '机器绑定数据已损坏,请先修复 Hermes agent 安装再登录。'
|
||||
bindingInvalidBody: '机器绑定数据已损坏,请先修复 Hermes agent 安装再登录。',
|
||||
me: {
|
||||
cardAria: '打开账号页',
|
||||
cardOffline: '离线——展示缓存数据',
|
||||
unavailable: '账号资料不可用。',
|
||||
offlineNotice: asOf => `离线——展示 ${asOf} 的缓存数据。`,
|
||||
nicknameLabel: '昵称',
|
||||
usernameLabel: '用户名',
|
||||
phoneLabel: '联系电话',
|
||||
emailLabel: '邮箱',
|
||||
planLabel: '套餐',
|
||||
periodEndLabel: '当前周期截止',
|
||||
dataAsOfLabel: '数据时间',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
saved: '已保存。',
|
||||
logout: '退出登录',
|
||||
logoutHint: '退出只结束用户会话——机器通道继续运行,绑定关系不变。',
|
||||
errorProfileDisabled: '服务器已关闭自助修改资料。',
|
||||
errorTooLong: '有字段超出长度上限。',
|
||||
errorNetwork: '无法连接服务器,请检查网络后重试。',
|
||||
errorServer: '服务器错误,请稍后重试。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -45,6 +45,15 @@ export const $userAccount = atom<UserAccountState>({ status: 'unknown' })
|
||||
|
||||
export type HermesUserAccountOpResult = { ok: true } | { ok: false; code: string }
|
||||
|
||||
/** 本人可改资料字段(§21 DB-T3,与服务端 PROFILE_FIELDS 同口径;null = 清空)。 */
|
||||
export interface HermesUserAccountProfilePatch {
|
||||
username?: string | null
|
||||
nickname?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
export type HermesUserAccountUpdateResult = { ok: true; profile: HermesUserAccountProfile } | { ok: false; code: string }
|
||||
|
||||
export type HermesUserAccountLoginResult =
|
||||
| { ok: true; site: string; profile: HermesUserAccountProfile }
|
||||
| { ok: false; code: string; binderEmail?: string | null }
|
||||
@@ -122,6 +131,18 @@ export async function userAccountLogout(): Promise<void> {
|
||||
$userAccount.set({ status: 'signed_out' })
|
||||
}
|
||||
|
||||
/** 改本人资料(username/nickname/phone)。成功回写 signed_in 快照;失败抛 UserAccountFailure。 */
|
||||
export async function userAccountUpdateProfile(patch: HermesUserAccountProfilePatch): Promise<void> {
|
||||
const result = await bridge().updateProfile(patch)
|
||||
|
||||
if (!result.ok) {throw new UserAccountFailure(result.code)}
|
||||
const current = $userAccount.get()
|
||||
|
||||
if (current.status === 'signed_in') {
|
||||
$userAccount.set({ ...current, profile: result.profile, profileAsOf: new Date().toISOString() })
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动/聚焦时对齐 main 侧真相(含 401 反应式刷新与离线判定)。 */
|
||||
export async function userAccountRefreshStatus(): Promise<void> {
|
||||
const status = await bridge().status()
|
||||
|
||||
Reference in New Issue
Block a user