diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a1f0f04034..bafd3dd58b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/relay-account.ts b/apps/desktop/electron/relay-account.ts index 84d2311e02..4efb3c3d8b 100644 --- a/apps/desktop/electron/relay-account.ts +++ b/apps/desktop/electron/relay-account.ts @@ -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> { + 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, diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts index 8722ec52e2..8483a2b235 100644 --- a/apps/desktop/electron/user-account-ipc.test.ts +++ b/apps/desktop/electron/user-account-ipc.test.ts @@ -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, '小赫') +}) diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts index 9dbba3e25f..66d24eaf7b 100644 --- a/apps/desktop/electron/user-account-ipc.ts +++ b/apps/desktop/electron/user-account-ipc.ts @@ -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 = {} + + 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() diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 3268772faa..afe4b3b37f 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 && } + {/* §21 用户卡:signed_in 才渲染,点击进 /me 资料页。 */} +
+ +
+ {/* Off, the statusbar's profile dropdown (beside the gateway switcher) takes over — the rail is a duplicate door for bot-only setups. */} {profileRailVisible && ( diff --git a/apps/desktop/src/app/chat/sidebar/user-account-card.tsx b/apps/desktop/src/app/chat/sidebar/user-account-card.tsx new file mode 100644 index 0000000000..638a459873 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/user-account-card.tsx @@ -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 ( + + ) +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index edc08b3bed..65d7a4959d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -8,6 +8,8 @@ * context; registered panes render `` 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' diff --git a/apps/desktop/src/app/me/index.tsx b/apps/desktop/src/app/me/index.tsx new file mode 100644 index 0000000000..0a1d90e51a --- /dev/null +++ b/apps/desktop/src/app/me/index.tsx @@ -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(null) + const [nickname, setNickname] = useState(null) + const [phone, setPhone] = useState(null) + const [busy, setBusy] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + if (account.status !== 'signed_in' || !account.profile) { + return + } + + 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 ( +
+
+ +
+

{displayName || profile.email}

+

{profile.email}

+
+
+ + {account.offline && ( +

+ {copy.offlineNotice(account.profileAsOf ?? profile.dataAsOf)} +

+ )} + +
+ + + + {error &&

{error}

} +
+ + {saved && !dirty && {copy.saved}} +
+
+ +
+
+
{copy.emailLabel}
+
{profile.email}
+
+
+
{copy.planLabel}
+
{profile.planId || '—'}
+
+
+
{copy.periodEndLabel}
+
{profile.periodEnd || '—'}
+
+
+
{copy.dataAsOfLabel}
+
{profile.dataAsOf || '—'}
+
+
+ +
+ +

{copy.logoutHint}

+
+
+ ) +} + +registry.register({ + id: 'me-page', + area: ROUTES_AREA, + source: 'core', + data: { path: ME_ROUTE } satisfies RouteContribution, + render: () => +}) diff --git a/apps/desktop/src/app/me/me-page.test.tsx b/apps/desktop/src/app/me/me-page.test.tsx new file mode 100644 index 0000000000..f43c4dc99d --- /dev/null +++ b/apps/desktop/src/app/me/me-page.test.tsx @@ -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 = {}) { + $userAccount.set({ status: 'signed_in', site: 'https://r', offline: false, profile: PROFILE, profileAsOf: null, ...overrides }) +} + +function stubBridge(overrides: Record = {}) { + 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 => ({ 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() + 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() + expect(screen.getByText(/2026-09-19T08:00:00Z/)).toBeTruthy() + }) + + it('只提交改动字段;成功后草稿归零并显示已保存', async () => { + const api = stubBridge() + signedIn() + render() + + 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() + 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() + 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() + expect(screen.getByText('Account details are unavailable.')).toBeTruthy() + }) +}) + +describe('UserAccountCard', () => { + it('signed_in:渲染展示名+邮箱;未登录不渲染', () => { + stubBridge() + signedIn() + + const { unmount } = render( + + + + ) + + expect(screen.getByText('小赫')).toBeTruthy() + expect(screen.getByText('u@example.com')).toBeTruthy() + unmount() + + $userAccount.set({ status: 'signed_out' }) + render( + + + + ) + expect(screen.queryByText('小赫')).toBeNull() + }) + + it('离线:名旁标 amber 圆点', () => { + stubBridge() + signedIn({ offline: true }) + render( + + + + ) + expect(screen.getByLabelText('Offline — showing cached data')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index e2ce7106a8..1a7349da3f 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 status: () => Promise me: () => Promise<{ profile: HermesUserAccountProfile }> + updateProfile: (payload: HermesUserAccountProfilePatch) => Promise logout: () => Promise<{ ok: boolean }> registerStart: (payload: { site: string; email: string; password: string }) => Promise registerResend: (payload: { site: string; email: string }) => Promise diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 325ec35493..0a2015a5c1 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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: 'خطأ في الخادم. أعد المحاولة لاحقًا.' + } } -}) - +}) \ No newline at end of file diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d93ba09ec1..852d3848a4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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.' + } } -} - +} \ No newline at end of file diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index c59c80a34f..98f767bb74 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: 'サーバーエラー。後でもう一度お試しください。' + } } -}) - +}) \ No newline at end of file diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 048d808440..dcee47bafd 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -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: 'Ошибка сервера. Повторите позже.' + } } -}) - +}) \ No newline at end of file diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 77675a545a..b029c1144c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 + } } } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 1d94e7f2e9..fcce20e512 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '伺服器錯誤,請稍後重試。' + } } -}) - +}) \ No newline at end of file diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index d8be3c1fb9..6ad3a73a45 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '服务器错误,请稍后重试。' + } } -}) - +}) \ No newline at end of file diff --git a/apps/desktop/src/store/user-account.ts b/apps/desktop/src/store/user-account.ts index 603356b826..42e2daa800 100644 --- a/apps/desktop/src/store/user-account.ts +++ b/apps/desktop/src/store/user-account.ts @@ -45,6 +45,15 @@ export const $userAccount = atom({ 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 { $userAccount.set({ status: 'signed_out' }) } +/** 改本人资料(username/nickname/phone)。成功回写 signed_in 快照;失败抛 UserAccountFailure。 */ +export async function userAccountUpdateProfile(patch: HermesUserAccountProfilePatch): Promise { + 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 { const status = await bridge().status()