diff --git a/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md new file mode 100644 index 00000000..dae38767 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md @@ -0,0 +1,54 @@ +# Task: Make WeChat card display name editable + +## Identity + +- Task ID: 20261014-wechat-card-name-7b36a2d9 +- Mode: Feature +- Branch: codex/20261014-wechat-card-name-7b36a2d9-wechat-card-name +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-wechat-card-name-7b36a2d9 +- Base commit: 436f7ba1eab500e41866f890f49816ff622ef6fe +- Owner: codex +- Status: In Progress + +## Scope + +- Make the WeChat card display name editable as an account-scoped local remark; save/cancel/Enter/Escape, reload persistence and failure feedback. Preserve current complete temporary runtime436f7ba and deliver the Renderer correction. + +## Intent And Constraints + +- User explicitly requests the card name (微信4) to be editable. Display remarks are local UI preferences keyed by tenant/user/stable channel account ID, following existing device-only card removal. No cloud rename operation exists; do not rename the real WeChat profile or invent server sync. Preserve all binding/QR/revision/health/operation recovery/auto-reply contracts. No live rename value, account binding mutation, model request, cloud deployment, main merge/push/cleanup or Main restart is authorized/needed. + +## Project Context Loaded + +- Task/mode/branch/worktree/base exactly match successful official check/start/status: new managed feature worktree from436f7ba1eab500e41866f890f49816ff622ef6fe, the actual current Renderer/Main source. Runtime metadata is fresh: MainPID35142/Host58003, consolidated-five-20261014-2d069b40, same trial profile/bundle. +- Read AGENTS, read-before-planning/memory-index, own record, positioning, historical integrated current-state, decision-index/system overview, community boundary, success criteria, domain WeChat identity rules, personal-cloud ADR User-Owned/Scan-Bound sections and evidence/reflection/commitment indexes. Integrated documents are historical; pointer/artifact source identify current app. +- All72 peer Scope/Intent/Promotion sections were read only. Another same-name placeholder9e3a72c4 was investigated via Agent--渠道 thread: its naming turn is interrupted and the human explicitly said to ignore it; latest agent work stops naming and diagnoses offline readiness. It remains untouched; current chat still explicitly requests card-name editing. The binding diagnostic and other ready WeChat tasks do not define a different card-name authority. No unresolved semantic conflict. +- Existing shared/Main API exposes display_name only at create; no rename/update operation. Core account ID/router/revision/health remain authoritative. Local remarks are presentation preferences, not cloned cloud configuration; no cross-device promise. Design skill/database reviewed: retain current card palette/type, visible pencil focus target, accessible labeled field and responsive wrapping. +- Evidence/constraints: existing unit/native fixtures cover binding/recovery and must remain valid. New tests focus persistence by stable identity, account isolation, cancel/validation/storage failure and no remote mutation. Explicit edit state must block automatic restore-replies start and its async preflight; auth changes hide/close old drafts immediately and writes verify current owner. +- Likely files: ChannelAccounts.tsx, a small local remark preference helper, README and current channel unit/native fixtures. Unknown: native saved/idle UI availability for refresh; check at delivery. Gate: Passed before implementation plan. + +## Plan + +1. Add current-account local remark preference and inline pencil/edit/save/cancel controls; preserve original account DTO and consistent title/aria/dialog display. Handle validation, storage failure and owner switching, guard restore preflight during editing. +2. Run focused unit/native rename and binding regressions, typecheck, scoped lint and default build. Document exact local preference scope. +3. Prepare immutable Renderer overlay over existing Main bytes, verify live saved/idle UI, reload through native CUA and confirm edit/save/cancel controls without changing the user's real card name. Finalize only after actual UI evidence. + +## Outcome + +- Added visible title/pencil editing, a labeled inline name field and save/cancel controls. Enter saves (except IME composition), Escape cancels; trim/nonempty/64-character validation and local storage failure keep the original display and draft. Remarks are scoped by tenant/user plus stable account ID, persist across refresh/revision/rebinding and are consistently used by card headings/aria/dialog title. Returning to the original source name clears its local override. +- Owner changes immediately hide the old editor/alias and close its draft; saves verify live auth owner. Missing/hidden cards close the editor rather than locking the page. Editing prevents competing binding actions and automatic restore start/preflight while read-only health refresh remains available. Main/API/DTO/account/revision/operation identities are unchanged. README describes local-only remarks honestly. +- Renderer build ready; immutable overlay preparation and actual native open/cancel verification remain before runtime finalization. + +## Verification + +- Typecheck, scoped ESLint, default full build:vite (NIANCODE_RENDERER_ONLY unset), git diff --check and task-aware doc drift passed. Independent source review found no blockers. +- 116 channel unit cases passed, including remark persistence after revision change, cancel/Escape, validation/storage retry, user+tenant isolation, malformed storage, vanished-card unlock and Worker recovery without enabling during editing. Initial storage-failure fixture spy targeted Storage.prototype despite the suite's own mock; corrected to localStorage and reran all116 successfully. +- Isolated Electron channel lifecycle scenario passed8.0s, including added keyboard edit/Escape/Enter, 64-character long remark, real reload persistence, original-name restoration and zero extra remote operations. Existing synthetic binding/switch/unbind/recovery assertions also passed. Reviewed compact980px editor screenshot; controls/badge fit, saved long name has no card overflow. Logs `/tmp/makelore-wechatname7b36-{unit,type,lint,build,electron}.log`, images `/tmp/makelore-wechatname7b36-electron`. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 02b1e42a..93ae0a51 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 +微信卡片名称可点击名称旁的铅笔修改,支持保存、取消、Enter 保存与 Escape 取消。备注名按当前账号与微信账号 ID 保存在本机,刷新或重新绑定后保留;不修改真实微信昵称或云端名称,不跨设备同步。保存失败保留原名称与编辑内容,可重试。 + ## 安装与开发 项目使用 `package.json` 中固定版本的 pnpm。 diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 60680149..82f18d01 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -6,6 +6,7 @@ import { Link2, Loader2, MoreHorizontal, + Pencil, Plus, QrCode, RefreshCw, @@ -34,6 +35,7 @@ import type { import { CloudAvatar } from './CloudAvatar'; import { CloudRecovery } from './CloudRecovery'; import { CloudWechatQr } from './CloudWechatQr'; +import { MAX_WECHAT_CARD_NAME_LENGTH, readWechatCardNames, wechatCardNameKey } from './wechat-card-names'; type Flow = { mode: 'new' | 'switch' | 'reconnect' | 'unbind' | 'delete'; @@ -42,6 +44,7 @@ type Flow = { target: string; }; type Expected = { target: string | null; enabled: boolean; disconnected?: boolean }; +type NameEdit = { ownerKey: string; accountId: string; value: string; error: string }; const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试'); const isChannelManagementOperation = (operation: string) => /channelaccount|channelbinding|^wechat/iu.test(operation); @@ -155,6 +158,22 @@ export function ChannelAccounts() { const navigate = useNavigate(); const user = useAuthStore((state) => state.user); const cardStorageKey = cardPreferenceKey(user); + const nameStorageKey = wechatCardNameKey(user); + const [cardNames, setCardNames] = useState(() => ({ + key: nameStorageKey, values: readWechatCardNames(nameStorageKey), + })); + const [nameEdit, setNameEdit] = useState(null); + const nameEditRef = useRef(null); + const editingName = nameEdit?.ownerKey === nameStorageKey ? nameEdit : null; + useEffect(() => { + setCardNames({ key: nameStorageKey, values: readWechatCardNames(nameStorageKey) }); + nameEditRef.current = null; + setNameEdit(null); + }, [nameStorageKey]); + const cardName = (account: CloudUserChannelAccount) => + cardNames.key === nameStorageKey && Object.hasOwn(cardNames.values, account.id) + ? cardNames.values[account.id] + : account.display_name; const [removedCards, setRemovedCards] = useState(() => ({ key: cardStorageKey, revisions: readRemovedCards(cardStorageKey), })); @@ -209,7 +228,50 @@ export function ChannelAccounts() { const ownerLogoutPending = accounts.some((account) => account.blockers.includes('wechat_disconnect_pending') ); - const disabled = Boolean(busy) || restoringReplies || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; + const disabled = Boolean(busy) || restoringReplies || Boolean(editingName) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; + const cancelNameEdit = () => { + nameEditRef.current = null; + setNameEdit(null); + }; + const editingAccountVisible = !editingName || visibleAccounts.some((account) => account.id === editingName.accountId); + useEffect(() => { + if (accountsLoaded && !editingAccountVisible) { + nameEditRef.current = null; + setNameEdit(null); + } + }, [accountsLoaded, editingAccountVisible]); + const startNameEdit = (account: CloudUserChannelAccount) => { + if (!nameStorageKey || busyRef.current || restoringReplies || flow || editingName) return; + const edit = { ownerKey: nameStorageKey, accountId: account.id, value: cardName(account), error: '' }; + nameEditRef.current = edit; + setNameEdit(edit); + setMore(''); + }; + const saveNameEdit = () => { + if (!editingName) return; + if (wechatCardNameKey(useAuthStore.getState().user) !== editingName.ownerKey) { + cancelNameEdit(); + return; + } + const value = editingName.value.trim(); + if (!value || value.length > MAX_WECHAT_CARD_NAME_LENGTH) { + setNameEdit({ ...editingName, error: `请填写1–${MAX_WECHAT_CARD_NAME_LENGTH}个字符的名称。` }); + return; + } + const account = accounts.find((item) => item.id === editingName.accountId); + if (!account) { cancelNameEdit(); return; } + const values = { ...readWechatCardNames(nameStorageKey) }; + if (value === account.display_name) delete values[account.id]; + else values[account.id] = value; + try { localStorage.setItem(nameStorageKey, JSON.stringify(values)); } + catch { + setNameEdit({ ...editingName, error: '名称没有保存:暂时无法保存到这台设备,请重试。' }); + return; + } + if (wechatCardNameKey(useAuthStore.getState().user) === nameStorageKey) + setCardNames({ key: nameStorageKey, values }); + cancelNameEdit(); + }; const pendingFor = (id: string) => pending.some( (item) => isChannelManagementOperation(item.operation) && item.input.channel_account_id === id @@ -573,7 +635,7 @@ export function ChannelAccounts() { if ( loading || !accountsLoaded || !agentsLoaded || agentsError || !recoveryLoaded || recoveryBlocked || ownerLogoutPending || - busy || busyRef.current || restoringReplies || flow || more || + busy || busyRef.current || restoringReplies || flow || more || editingName || checkingIds.length || replyRestoreStopped.current ) return; const candidate = accounts.find((account) => @@ -587,7 +649,7 @@ export function ChannelAccounts() { setRestoringReplies(true); void run('restore-replies', candidate.id, async () => { const [catalog, recovery] = await Promise.all([loadAgents(), cloudAgentsApi.recovery()]); - if (!alive.current || flowRef.current) return { restored: false }; + if (!alive.current || flowRef.current || nameEditRef.current) return { restored: false }; agentsRef.current = catalog; setAgents(catalog); setPending(recovery.pending); @@ -599,7 +661,7 @@ export function ChannelAccounts() { if (!account || account.target_agent_slug !== candidate.target_agent_slug) return { restored: false, stop: true }; if ( - !alive.current || flowRef.current || ownerPending.current || + !alive.current || flowRef.current || nameEditRef.current || ownerPending.current || observed.some((item) => item.blockers.includes('wechat_disconnect_pending')) || !canRestoreReplies(account, catalog) ) return { restored: false }; @@ -619,7 +681,7 @@ export function ChannelAccounts() { if (alive.current) setRestoringReplies(false); }); }, [accounts, accountsLoaded, agents, agentsLoaded, agentsError, recoveryLoaded, - recoveryBlocked, ownerLogoutPending, busy, restoringReplies, flow, more, + recoveryBlocked, ownerLogoutPending, busy, restoringReplies, flow, more, editingName, checkingIds, loading, run, readAccounts, operationId, verifyFinal]); useEffect(() => { if (flow || recoveryBlocked || ownerLogoutPending || !accounts.some((account) => @@ -848,6 +910,8 @@ export function ChannelAccounts() { > {visibleAccounts.map((account) => { const agent = agents.find((item) => item.slug === account.target_agent_slug); + const displayName = cardName(account); + const edit = editingName?.accountId === account.id ? editingName : null; const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); const targetDisabled = agents.some( @@ -868,15 +932,51 @@ export function ChannelAccounts() { return (
-
+

微信

-

- {account.display_name} -

+ {edit ? ( +
{ event.preventDefault(); saveNameEdit(); }}> + setNameEdit({ ...edit, value: event.target.value, error: '' })} + onKeyDown={(event) => { + if (event.key === 'Escape') { event.preventDefault(); cancelNameEdit(); } + if (event.key === 'Enter' && event.nativeEvent.isComposing) event.preventDefault(); + }} + className="h-10 min-w-0 rounded-xl" + /> +

+ {edit.error ? {edit.error} : '备注名仅保存在这台电脑的当前账号中。'} +

+
+ + +
+
+ ) : ( +

+ +

+ )}
{flow?.mode === 'new' ? '选一个智能体,再用微信扫码。' - : (selected?.display_name ?? '微信绑定')} + : (selected ? cardName(selected) : '微信绑定')} {feedback} diff --git a/src/pages/CloudAgents/wechat-card-names.ts b/src/pages/CloudAgents/wechat-card-names.ts new file mode 100644 index 00000000..51fe8cad --- /dev/null +++ b/src/pages/CloudAgents/wechat-card-names.ts @@ -0,0 +1,22 @@ +import type { AuthUser } from '@/stores/auth'; + +export const MAX_WECHAT_CARD_NAME_LENGTH = 64; + +export function wechatCardNameKey(user: AuthUser | null) { + return user?.userId + ? 'makelore.wechat-card-names:' + JSON.stringify([user.tenantId, user.userId]) + : ''; +} + +export function readWechatCardNames(key: string): Record { + if (!key) return {}; + try { + const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}'); + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).flatMap(([id, name]) => { + if (typeof name !== 'string') return []; + const trimmed = name.trim(); + return trimmed && trimmed.length <= MAX_WECHAT_CARD_NAME_LENGTH ? [[id, trimmed]] : []; + })); + } catch { return {}; } +} diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 450ee90d..a46a7412 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -578,6 +578,34 @@ test('WeChat binding cards connect an account and switch its Agent through the c ).toHaveCount(0); await expect(legacyCard.getByText('状态待确认', { exact: true })).toHaveCount(0); await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); + const callsBeforeRemarks = await app.evaluate(() => (globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string }[]; + }).cloudChannelFixtureCalls.filter((item) => item.operation !== 'channelAccounts')); + await spareCard.getByRole('button', { name: '修改微信卡片名称:备用微信', exact: true }).click(); + const cardNameField = page.getByRole('textbox', { name: '微信卡片名称', exact: true }); + await cardNameField.fill('未保存备注'); + await cardNameField.press('Escape'); + await expect(cardNameField).toHaveCount(0); + await spareCard.getByRole('button', { name: '修改微信卡片名称:备用微信', exact: true }).click(); + await cardNameField.fill('A'.repeat(64)); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 700)); + await page.screenshot({ path: testInfo.outputPath('wechat-card-name-edit-compact.png'), fullPage: true }); + await cardNameField.press('Enter'); + const remarkedCard = page.getByRole('article', { name: 'A'.repeat(64) + '微信', exact: true }); + await expect(remarkedCard).toBeVisible(); + expect(await remarkedCard.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.reload(); + await expect(remarkedCard).toBeVisible(); + await expect(remarkedCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); + await remarkedCard.getByRole('button', { name: '修改微信卡片名称:' + 'A'.repeat(64), exact: true }).click(); + await cardNameField.fill('备用微信'); + await cardNameField.press('Enter'); + await expect(spareCard).toBeVisible(); + const callsAfterRemarks = await app.evaluate(() => (globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string }[]; + }).cloudChannelFixtureCalls.filter((item) => item.operation !== 'channelAccounts')); + expect(callsAfterRemarks).toEqual(callsBeforeRemarks); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1366, 900)); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index b6a76a8e..5b02e95a 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -197,6 +197,103 @@ afterEach(() => { vi.useRealTimers(); }); +const nameKey = (userId = 'creator', tenantId: string | null = null) => + 'makelore.wechat-card-names:' + JSON.stringify([tenantId, userId]); +async function editCardName(name = legacy.display_name) { + const current = await card(name); + fireEvent.click(within(current).getByRole('button', { name: '修改微信卡片名称:' + name })); + return within(current).getByRole('textbox', { name: '微信卡片名称' }); +} +it('saves a local card remark through refresh and revision changes without touching the binding', async () => { + const original = structuredClone(accounts); + const view = renderPage(); + const field = await editCardName(); + fireEvent.change(field, { target: { value: ' 工作微信 ' } }); + fireEvent.submit(field.closest('form')!); + expect(within(await card('工作微信')).getByRole('heading', { name: '工作微信' })).toBeVisible(); + expect(accounts).toEqual(original); + expect(writes()).toEqual([]); + view.unmount(); + accounts[0].revision += 1; + renderPage(); + expect(await card('工作微信')).toBeVisible(); + expect(await card(spare.display_name)).toBeVisible(); + expect(writes()).toEqual([]); +}); +it('cancels typed remarks with either Cancel or Escape', async () => { + renderPage(); + let field = await editCardName(); + fireEvent.change(field, { target: { value: '不保存的名称' } }); + fireEvent.click(screen.getByRole('button', { name: '取消', exact: true })); + expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument(); + field = await editCardName(); + fireEvent.change(field, { target: { value: '也不保存' } }); + fireEvent.keyDown(field, { key: 'Escape' }); + expect(await card()).toBeVisible(); + expect(localStorage.getItem(nameKey())).toBeNull(); + expect(writes()).toEqual([]); +}); +it('keeps the original name and typed draft on validation or storage failure, then allows retry', async () => { + renderPage(); + const field = await editCardName(); + fireEvent.change(field, { target: { value: ' ' } }); + fireEvent.submit(field.closest('form')!); + expect(screen.getByRole('alert')).toHaveTextContent('请填写1–64个字符'); + expect(localStorage.getItem(nameKey())).toBeNull(); + fireEvent.change(field, { target: { value: '生活微信' } }); + const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('storage unavailable'); }); + fireEvent.submit(field.closest('form')!); + expect(screen.getByRole('alert')).toHaveTextContent('名称没有保存'); + expect(field).toHaveValue('生活微信'); + expect(await card()).toBeVisible(); + write.mockRestore(); + fireEvent.submit(field.closest('form')!); + expect(await card('生活微信')).toBeVisible(); + expect(writes()).toEqual([]); +}); +it.each([ + ['account', 'another-creator', null], + ['tenant', 'creator', 'another-tenant'], +] as const)('isolates card remarks and closes the old draft when the %s changes', async (_scope, userId, tenantId) => { + localStorage.setItem(nameKey(), JSON.stringify({ [legacy.id]: '本账号备注' })); + localStorage.setItem(nameKey(userId, tenantId), JSON.stringify({ [legacy.id]: '其他账号备注' })); + renderPage(); + const field = await editCardName('本账号备注'); + fireEvent.change(field, { target: { value: '旧账号未保存内容' } }); + await act(async () => useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } })); + expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument(); + expect(await card('其他账号备注')).toBeVisible(); + expect(JSON.parse(localStorage.getItem(nameKey())!)[legacy.id]).toBe('本账号备注'); + expect(JSON.parse(localStorage.getItem(nameKey(userId, tenantId))!)[legacy.id]).toBe('其他账号备注'); +}); +it('ignores malformed local remarks and falls back to the original card name', async () => { + localStorage.setItem(nameKey(), JSON.stringify({ [legacy.id]: { name: 'not a string' }, [spare.id]: ' ' })); + renderPage(); + expect(await card()).toBeVisible(); + expect(await card(spare.display_name)).toBeVisible(); +}); +it('closes an edit when its card disappears on refresh and leaves the page usable', async () => { + renderPage(); + await editCardName(); + accounts = [{ ...spare }]; + fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' })); + await waitFor(() => expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument()); + expect(await card(spare.display_name)).toBeVisible(); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + expect(writes()).toEqual([]); +}); +it('does not restore replies while a card name is being edited after Worker recovery', async () => { + accounts = [restorable({ worker_online: false, blockers: ['worker_offline', 'binding_paused'] })]; + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderPage(); + await editCardName(); + accounts[0].worker_online = true; + accounts[0].blockers = ['binding_paused']; + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(calls('enableChannelAccount')).toHaveLength(0); + expect(screen.getByRole('textbox', { name: '微信卡片名称' })).toBeVisible(); +}); + it.each([ ['none', []], ['drafts', [{ ...agentA, published_version: null }]],