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:
m4
2026-09-19 13:35:00 +08:00
parent 357ac45281
commit 9d606be4f1
18 changed files with 752 additions and 20 deletions
+1
View File
@@ -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),
+33
View File
@@ -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, '小赫')
})
+32 -1
View File
@@ -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>
)
}
+2
View File
@@ -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'
+201
View File
@@ -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 />
})
+145
View File
@@ -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()
})
})
+9 -1
View File
@@ -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>
+24 -3
View File
@@ -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: 'خطأ في الخادم. أعد المحاولة لاحقًا.'
}
}
})
})
+24 -3
View File
@@ -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.'
}
}
}
}
+24 -3
View File
@@ -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: 'サーバーエラー。後でもう一度お試しください。'
}
}
})
})
+24 -3
View File
@@ -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: 'Ошибка сервера. Повторите позже.'
}
}
})
})
+22
View File
@@ -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
}
}
}
+24 -3
View File
@@ -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: '伺服器錯誤,請稍後重試。'
}
}
})
})
+24 -3
View File
@@ -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: '服务器错误,请稍后重试。'
}
}
})
})
+21
View File
@@ -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()