feat: allow editing local WeChat card names

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 14:20:17 +08:00
1 parent 436f7ba1ea
commit f1dabdb358
6 files changed
+314 -11

No files matched your search

@@ -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.
+2
View File
@@ -68,6 +68,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
- 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。
- 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。
微信卡片名称可点击名称旁的铅笔修改,支持保存、取消、Enter 保存与 Escape 取消。备注名按当前账号与微信账号 ID 保存在本机,刷新或重新绑定后保留;不修改真实微信昵称或云端名称,不跨设备同步。保存失败保留原名称与编辑内容,可重试。
## 安装与开发 ## 安装与开发
项目使用 `package.json` 中固定版本的 pnpm。 项目使用 `package.json` 中固定版本的 pnpm。
+111 -11
View File
@@ -6,6 +6,7 @@ import {
Link2, Link2,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Pencil,
Plus, Plus,
QrCode, QrCode,
RefreshCw, RefreshCw,
@@ -34,6 +35,7 @@ import type {
import { CloudAvatar } from './CloudAvatar'; import { CloudAvatar } from './CloudAvatar';
import { CloudRecovery } from './CloudRecovery'; import { CloudRecovery } from './CloudRecovery';
import { CloudWechatQr } from './CloudWechatQr'; import { CloudWechatQr } from './CloudWechatQr';
import { MAX_WECHAT_CARD_NAME_LENGTH, readWechatCardNames, wechatCardNameKey } from './wechat-card-names';
type Flow = { type Flow = {
mode: 'new' | 'switch' | 'reconnect' | 'unbind' | 'delete'; mode: 'new' | 'switch' | 'reconnect' | 'unbind' | 'delete';
@@ -42,6 +44,7 @@ type Flow = {
target: string; target: string;
}; };
type Expected = { target: string | null; enabled: boolean; disconnected?: boolean }; 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 errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试');
const isChannelManagementOperation = (operation: string) => const isChannelManagementOperation = (operation: string) =>
/channelaccount|channelbinding|^wechat/iu.test(operation); /channelaccount|channelbinding|^wechat/iu.test(operation);
@@ -155,6 +158,22 @@ export function ChannelAccounts() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const cardStorageKey = cardPreferenceKey(user); const cardStorageKey = cardPreferenceKey(user);
const nameStorageKey = wechatCardNameKey(user);
const [cardNames, setCardNames] = useState(() => ({
key: nameStorageKey, values: readWechatCardNames(nameStorageKey),
}));
const [nameEdit, setNameEdit] = useState<NameEdit | null>(null);
const nameEditRef = useRef<NameEdit | null>(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(() => ({ const [removedCards, setRemovedCards] = useState(() => ({
key: cardStorageKey, revisions: readRemovedCards(cardStorageKey), key: cardStorageKey, revisions: readRemovedCards(cardStorageKey),
})); }));
@@ -209,7 +228,50 @@ export function ChannelAccounts() {
const ownerLogoutPending = accounts.some((account) => const ownerLogoutPending = accounts.some((account) =>
account.blockers.includes('wechat_disconnect_pending') 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) => const pendingFor = (id: string) =>
pending.some( pending.some(
(item) => isChannelManagementOperation(item.operation) && item.input.channel_account_id === id (item) => isChannelManagementOperation(item.operation) && item.input.channel_account_id === id
@@ -573,7 +635,7 @@ export function ChannelAccounts() {
if ( if (
loading || !accountsLoaded || !agentsLoaded || agentsError || loading || !accountsLoaded || !agentsLoaded || agentsError ||
!recoveryLoaded || recoveryBlocked || ownerLogoutPending || !recoveryLoaded || recoveryBlocked || ownerLogoutPending ||
busy || busyRef.current || restoringReplies || flow || more || busy || busyRef.current || restoringReplies || flow || more || editingName ||
checkingIds.length || replyRestoreStopped.current checkingIds.length || replyRestoreStopped.current
) return; ) return;
const candidate = accounts.find((account) => const candidate = accounts.find((account) =>
@@ -587,7 +649,7 @@ export function ChannelAccounts() {
setRestoringReplies(true); setRestoringReplies(true);
void run('restore-replies', candidate.id, async () => { void run('restore-replies', candidate.id, async () => {
const [catalog, recovery] = await Promise.all([loadAgents(), cloudAgentsApi.recovery()]); 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; agentsRef.current = catalog;
setAgents(catalog); setAgents(catalog);
setPending(recovery.pending); setPending(recovery.pending);
@@ -599,7 +661,7 @@ export function ChannelAccounts() {
if (!account || account.target_agent_slug !== candidate.target_agent_slug) if (!account || account.target_agent_slug !== candidate.target_agent_slug)
return { restored: false, stop: true }; return { restored: false, stop: true };
if ( if (
!alive.current || flowRef.current || ownerPending.current || !alive.current || flowRef.current || nameEditRef.current || ownerPending.current ||
observed.some((item) => item.blockers.includes('wechat_disconnect_pending')) || observed.some((item) => item.blockers.includes('wechat_disconnect_pending')) ||
!canRestoreReplies(account, catalog) !canRestoreReplies(account, catalog)
) return { restored: false }; ) return { restored: false };
@@ -619,7 +681,7 @@ export function ChannelAccounts() {
if (alive.current) setRestoringReplies(false); if (alive.current) setRestoringReplies(false);
}); });
}, [accounts, accountsLoaded, agents, agentsLoaded, agentsError, recoveryLoaded, }, [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]); checkingIds, loading, run, readAccounts, operationId, verifyFinal]);
useEffect(() => { useEffect(() => {
if (flow || recoveryBlocked || ownerLogoutPending || !accounts.some((account) => if (flow || recoveryBlocked || ownerLogoutPending || !accounts.some((account) =>
@@ -848,6 +910,8 @@ export function ChannelAccounts() {
> >
{visibleAccounts.map((account) => { {visibleAccounts.map((account) => {
const agent = agents.find((item) => item.slug === account.target_agent_slug); 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 = const uncertain =
!recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
const targetDisabled = agents.some( const targetDisabled = agents.some(
@@ -868,15 +932,51 @@ export function ChannelAccounts() {
return ( return (
<article <article
key={account.id} key={account.id}
aria-label={`${account.display_name}微信`} aria-label={`${displayName}微信`}
className="flex min-w-0 flex-col rounded-[26px] border border-[#e4e2de] bg-white p-5 shadow-[0_3px_0_0_#f1eeea] sm:p-6" className="flex min-w-0 flex-col rounded-[26px] border border-[#e4e2de] bg-white p-5 shadow-[0_3px_0_0_#f1eeea] sm:p-6"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="text-xs font-medium text-[#788271]">微信</p> <p className="text-xs font-medium text-[#788271]">微信</p>
<h2 className="mt-1 break-words text-lg font-bold text-[#303747]"> {edit ? (
{account.display_name} <form className="mt-2 space-y-2" onSubmit={(event) => { event.preventDefault(); saveNameEdit(); }}>
</h2> <Input
autoFocus
aria-label="微信卡片名称"
aria-invalid={Boolean(edit.error)}
aria-describedby={'wechat-name-help-' + account.id}
maxLength={MAX_WECHAT_CARD_NAME_LENGTH}
value={edit.value}
onChange={(event) => 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"
/>
<p id={'wechat-name-help-' + account.id} className="text-xs leading-5 text-muted-foreground">
{edit.error ? <span role="alert" className="text-destructive">{edit.error}</span> : '备注名仅保存在这台电脑的当前账号中。'}
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" size="sm" className="rounded-lg">保存</Button>
<Button type="button" variant="outline" size="sm" className="rounded-lg" onClick={cancelNameEdit}>取消</Button>
</div>
</form>
) : (
<h2 className="mt-1 text-lg font-bold text-[#303747]">
<button
type="button"
aria-label={`修改微信卡片名称:${displayName}`}
title="修改卡片名称"
disabled={!nameStorageKey || Boolean(busy) || restoringReplies || Boolean(flow) || Boolean(editingName)}
className="flex max-w-full items-start gap-2 rounded-md text-left hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 disabled:cursor-default disabled:opacity-60"
onClick={() => startNameEdit(account)}
>
<span className="min-w-0 break-words">{displayName}</span>
<Pencil aria-hidden="true" className="mt-1.5 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</h2>
)}
</div> </div>
<span <span
className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold ${state.tone}`} className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold ${state.tone}`}
@@ -1027,7 +1127,7 @@ export function ChannelAccounts() {
<DialogDescription> <DialogDescription>
{flow?.mode === 'new' {flow?.mode === 'new'
? '选一个智能体,再用微信扫码。' ? '选一个智能体,再用微信扫码。'
: (selected?.display_name ?? '微信绑定')} : (selected ? cardName(selected) : '微信绑定')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{feedback} {feedback}
@@ -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<string, string> {
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 {}; }
}
+28
View File
@@ -578,6 +578,34 @@ test('WeChat binding cards connect an account and switch its Agent through the c
).toHaveCount(0); ).toHaveCount(0);
await expect(legacyCard.getByText('状态待确认', { exact: true })).toHaveCount(0); await expect(legacyCard.getByText('状态待确认', { exact: true })).toHaveCount(0);
await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); 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.getByRole('dialog')).toHaveCount(0);
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
+97
View File
@@ -197,6 +197,103 @@ afterEach(() => {
vi.useRealTimers(); 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([ it.each([
['none', []], ['none', []],
['drafts', [{ ...agentA, published_version: null }]], ['drafts', [{ ...agentA, published_version: null }]],