1
0
forked from boos/BoosAPI

feat: add masked token placeholders and desktop icons

This commit is contained in:
cita
2026-03-16 21:36:23 +08:00
parent 2409d9f184
commit 0a06f15e13
37 changed files with 2954 additions and 136 deletions
+156 -49
View File
@@ -23,8 +23,11 @@ type AccountTokenSyncResult = {
success?: boolean;
synced?: boolean;
message?: string;
reason?: string;
created?: number;
updated?: number;
maskedPending?: number;
pendingTokenIds?: number[];
accountId?: number;
accountName?: string;
account?: {
@@ -65,6 +68,12 @@ const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, f
return message || fallback;
};
const isMaskedPendingToken = (token: any): boolean => token?.valueStatus === 'masked_pending';
const isMaskedPendingSyncResult = (result: AccountTokenSyncResult | null | undefined) =>
String(result?.reason || '').trim().toLowerCase() === 'upstream_masked_tokens'
&& Number(result?.maskedPending || 0) > 0;
const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => {
const name = typeof result?.accountName === 'string' ? result.accountName.trim() : '';
if (name) return name;
@@ -130,8 +139,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const [savingEdit, setSavingEdit] = useState(false);
const [editingToken, setEditingToken] = useState<any | null>(null);
const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
const [editingTokenPendingMessage, setEditingTokenPendingMessage] = useState('');
const [createHintModelName, setCreateHintModelName] = useState('');
const [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState<number | null>(null);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -173,8 +184,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
if (!hasCurrentSelected) {
setSyncingAccountId(syncableAccounts[0]?.id || 0);
}
return {
tokens: nextTokens,
accounts: latestAccounts,
};
} catch (e: any) {
toast.error(e.message || '加载令牌失败');
return {
tokens: [] as any[],
accounts: [] as any[],
};
} finally {
setLoading(false);
}
@@ -346,6 +365,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, tokens]);
const focusTokenRow = useCallback((tokenId: number) => {
const row = rowRefs.current.get(tokenId);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setHighlightTokenId(tokenId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(() => {
setHighlightTokenId((current) => (current === tokenId ? null : current));
}, 2200);
}, []);
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true }));
try {
@@ -422,13 +453,24 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setCreateHintModelName('');
setEditingToken(token);
editingTokenIdRef.current = token.id;
setEditingTokenPendingMessage(
isMaskedPendingToken(token)
? '请粘贴完整明文 token;当前本地仅保存了上游返回的脱敏占位值。'
: '',
);
setEditForm({
name: token?.name || '',
token: '',
group: (token?.tokenGroup || '').trim() || 'default',
enabled: token?.enabled !== false,
enabled: isMaskedPendingToken(token) ? true : token?.enabled !== false,
isDefault: !!token?.isDefault,
});
if (isMaskedPendingToken(token)) {
setEditingTokenValueLoading(false);
return;
}
setEditingTokenValueLoading(true);
void api.getAccountTokenValue(token.id)
@@ -454,6 +496,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditingToken(null);
setSavingEdit(false);
setEditingTokenValueLoading(false);
setEditingTokenPendingMessage('');
setEditForm({
name: '',
token: '',
@@ -465,6 +508,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const saveEditPanel = async () => {
if (!editingToken) return;
if (isMaskedPendingToken(editingToken) && !editForm.token.trim()) {
toast.error('请粘贴完整明文 token 后再保存');
return;
}
setSavingEdit(true);
try {
await api.updateAccountToken(editingToken.id, {
@@ -484,6 +531,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}
};
useEffect(() => {
if (!pendingAutoOpenTokenId || loading) return;
const token = tokens.find((item: any) => item.id === pendingAutoOpenTokenId);
if (!token) return;
focusTokenRow(token.id);
openEditPanel(token);
setPendingAutoOpenTokenId(null);
}, [focusTokenRow, loading, openEditPanel, pendingAutoOpenTokenId, tokens]);
const handleTokenRowClick = (tokenId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedTokenIds.includes(tokenId);
@@ -551,6 +607,25 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const status = resolveSyncStatus(res);
if (status === 'failed') {
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
} else if (isMaskedPendingSyncResult(res)) {
toast.info(resolveSyncMessage(res, '上游返回了脱敏令牌,请补全明文 token'));
const loaded = await load();
const pendingIds = Array.isArray(res.pendingTokenIds)
? res.pendingTokenIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: [];
const nextTokens = Array.isArray(loaded?.tokens) ? loaded.tokens : [];
if (pendingIds.length === 1) {
const pendingToken = nextTokens.find((token: any) => token.id === pendingIds[0]);
if (pendingToken) {
focusTokenRow(pendingToken.id);
openEditPanel(pendingToken);
} else {
setPendingAutoOpenTokenId(pendingIds[0] || null);
}
} else if (pendingIds.length > 1) {
focusTokenRow(pendingIds[0]!);
}
return;
} else if (status === 'skipped') {
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`);
} else {
@@ -562,7 +637,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
} finally {
setSyncing(false);
}
}, [load, syncingAccountId, toast]);
}, [focusTokenRow, load, openEditPanel, syncingAccountId, toast]);
const handleSyncAll = useCallback(async () => {
setSyncingAll(true);
@@ -594,12 +669,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const failedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'failed');
const skippedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'skipped');
const successRows = syncResults.filter((item) => resolveSyncStatus(item) === 'success');
const maskedRows = syncResults.filter((item) => isMaskedPendingSyncResult(item));
toast.success(`全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}`);
failedRows.slice(0, 3).forEach((item) => {
toast.error(`${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, '请检查账号配置')}`);
});
maskedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 需要补全明文 token:${resolveSyncMessage(item, '上游返回脱敏令牌')}`);
});
skippedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, '不满足同步条件')}`);
});
@@ -771,6 +850,20 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
>
账号: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'}
</div>
{editingTokenPendingMessage ? (
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-warning) 12%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-warning) 28%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '8px 10px',
}}
>
{editingTokenPendingMessage}
</div>
) : null}
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}>基本信息</div>
<div>
@@ -1008,6 +1101,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<div className="mobile-card-list">
{accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<MobileCard
key={token.id}
@@ -1049,8 +1143,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<MobileField
label="状态"
value={(
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
<span className={`badge ${isPending ? 'badge-warning' : (token.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 11 }}>
{isPending ? '待补全' : (token.enabled ? '启用' : '禁用')}
</span>
)}
/>
@@ -1060,7 +1154,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
/>
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
<div className="mobile-card-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1073,31 +1167,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info"
>
编辑
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -1135,6 +1233,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<tbody>
{accountClusteredTokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<tr
key={token.id}
@@ -1179,15 +1278,19 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<td>{token.account?.username || `account-${token.accountId}`}</td>
<td>{token.tokenGroup || 'default'}</td>
<td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
{isPending ? (
<span className="badge badge-warning" style={{ fontSize: 11 }}>待补全</span>
) : (
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
)}
</td>
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
<td className="token-actions-cell" style={{ textAlign: 'right' }}>
<div className="token-table-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1200,31 +1303,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info token-table-action-btn"
>
编辑
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
@@ -11,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
getAccountTokenGroups: vi.fn(),
syncAccountTokens: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
@@ -56,6 +58,26 @@ function buildRoot() {
);
}
function buildTokensRoot() {
return create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<TokensPanel />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr' || element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
}
describe('Tokens edit modal and row selection', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -87,6 +109,7 @@ describe('Tokens edit modal and row selection', () => {
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
valueStatus: 'ready',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
@@ -106,6 +129,13 @@ describe('Tokens edit modal and row selection', () => {
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
apiMock.syncAccountTokens.mockResolvedValue({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 0,
});
});
afterEach(() => {
@@ -228,4 +258,133 @@ describe('Tokens edit modal and row selection', () => {
root?.unmount();
}
});
it('shows placeholder guidance and saves masked_pending tokens as ready values', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 33,
name: 'masked-token',
tokenMasked: 'sk-abc***xyz',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 08:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('待补全');
expect(rendered).not.toContain('复制');
expect(rendered).not.toContain('设默认');
const editButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('编辑'));
expect(editButton).toBeTruthy();
await act(async () => {
editButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
await flushMicrotasks();
const afterOpen = JSON.stringify(root.toJSON());
expect(afterOpen).toContain('请粘贴完整明文 token');
expect(afterOpen).toContain('编辑令牌');
const textarea = root.root.findAll((node) => node.type === 'textarea')[0];
expect(textarea).toBeTruthy();
await act(async () => {
textarea.props.onChange({ target: { value: 'sk-complete-real-token' } });
});
await flushMicrotasks();
const saveButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('保存修改'));
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateAccountToken).toHaveBeenCalledWith(33, expect.objectContaining({
token: 'sk-complete-real-token',
enabled: true,
}));
} finally {
root?.unmount();
}
});
it('opens the placeholder edit modal automatically after a sync creates one pending token', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 44,
name: 'masked-after-sync',
tokenMasked: 'sk-xyz***123',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 09:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.syncAccountTokens.mockResolvedValueOnce({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
message: '上游返回 1 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。',
maskedPending: 1,
pendingTokenIds: [44],
created: 1,
updated: 0,
});
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const syncButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).trim() === '同步站点令牌');
expect(syncButton).toBeTruthy();
await act(async () => {
await syncButton!.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('编辑令牌');
expect(rendered).toContain('请粘贴完整明文 token');
expect(rendered).toContain('上游返回 1 条脱敏令牌');
expect(apiMock.syncAccountTokens).toHaveBeenCalledWith(1);
expect(apiMock.getAccountTokenGroups).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB