🐛 fix(redis): 修复列表新增与精确删除

- 对齐新版 Redis List 头尾新增的 Wails 参数契约

- 分别使用 LPUSH 与 RPUSH 保证插入位置语义

- 按索引原子删除并避免重复值误删

- 补充多语言文案与前后端回归测试

Refs #639
This commit is contained in:
Syngnat
2026-08-05 22:33:53 +08:00
parent 450b70178c
commit 005113dd79
17 changed files with 622 additions and 70 deletions

View File

@@ -34,6 +34,7 @@ const redisBackend = vi.hoisted(() => ({
RedisScanKeys: vi.fn(),
RedisGetValue: vi.fn(),
RedisGetListValue: vi.fn(),
RedisListPush: vi.fn(),
RedisListRemove: vi.fn(),
RedisListSet: vi.fn(),
RedisExportKeys: vi.fn(),
@@ -44,6 +45,7 @@ const redisBackend = vi.hoisted(() => ({
const antdState = vi.hoisted(() => ({
treeProps: null as any,
tableProps: [] as any[],
modalConfirm: vi.fn(),
message: {
error: vi.fn(),
success: vi.fn(),
@@ -133,7 +135,7 @@ vi.mock('antd', async () => {
onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null,
onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null,
]);
}, { confirm: vi.fn() }),
}, { confirm: antdState.modalConfirm }),
Form: FormComponent,
InputNumber: ({ ...props }: any) => React.createElement('input', props),
Popconfirm: passthrough('span'),
@@ -206,6 +208,7 @@ describe('RedisViewer tree interactions', () => {
vi.clearAllMocks();
antdState.treeProps = null;
antdState.tableProps = [];
antdState.modalConfirm.mockImplementation(() => ({ update: vi.fn(), destroy: vi.fn() }));
storeState.connections = [
{
id: 'redis-1',
@@ -238,6 +241,7 @@ describe('RedisViewer tree interactions', () => {
success: true,
data: { key: 'app:user:1', type: 'list', ttl: -1, value: [], length: 0 },
});
redisBackend.RedisListPush.mockResolvedValue({ success: true });
redisBackend.RedisListRemove.mockResolvedValue({ success: true });
redisBackend.RedisListSet.mockResolvedValue({ success: true });
redisBackend.RedisExportKeys.mockResolvedValue({
@@ -1183,10 +1187,13 @@ describe('RedisViewer tree interactions', () => {
renderer!.unmount();
});
it('removes one selected List value', async () => {
it.each([
{ buttonText: 'Push to tail', inputId: 'new-list-value', value: 'tail-item', position: 'right' },
{ buttonText: 'Push to head', inputId: 'new-list-value-left', value: 'head-item', position: 'left' },
] as const)('pushes a List value through the $buttonText action', async ({ buttonText, inputId, value, position }) => {
redisBackend.RedisGetValue.mockResolvedValue({
success: true,
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['todo', 'review'], length: 2 },
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['existing'], length: 1 },
});
let renderer: ReactTestRenderer;
@@ -1201,15 +1208,73 @@ describe('RedisViewer tree interactions', () => {
});
await flushEffects();
const listTables = antdState.tableProps.filter((props) =>
Array.isArray(props.dataSource) && props.dataSource[0]?.value === 'todo',
const pushButton = findButtonByText(renderer!, buttonText);
expect(pushButton).toBeTruthy();
await act(async () => {
pushButton!.props.onClick?.();
});
expect(antdState.modalConfirm).toHaveBeenCalledTimes(1);
const modalConfig = antdState.modalConfirm.mock.calls[0][0];
const getElementById = vi.fn((id: string) => id === inputId ? { value } : null);
vi.stubGlobal('document', { getElementById });
await act(async () => {
await modalConfig.onOk();
});
await flushEffects();
expect(getElementById).toHaveBeenCalledWith(inputId);
expect(redisBackend.RedisListPush).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
'app:user:1',
{ values: [value], position },
);
renderer!.unmount();
});
it('removes the selected duplicate List value by its original index after descending sort', async () => {
redisBackend.RedisGetValue.mockResolvedValue({
success: true,
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['duplicate', 'middle', 'duplicate'], length: 3 },
});
redisBackend.RedisGetListValue.mockResolvedValue({
success: true,
data: { key: 'app:user:1', type: 'list', ttl: -1, value: ['duplicate', 'middle', 'duplicate'], length: 3 },
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const leafNode = findFirstLeafNode(antdState.treeProps.treeData);
await act(async () => {
antdState.treeProps.onSelect?.([leafNode.key]);
});
await flushEffects();
const listTables = antdState.tableProps.filter((props) => Array.isArray(props.dataSource)
&& props.dataSource[0]?.value === 'duplicate'
&& props.dataSource[0]?.index === 0);
const listTable = listTables[listTables.length - 1];
expect(listTable).toBeTruthy();
const actionColumn = listTable.columns.find((column: any) => column.key === 'action');
await act(async () => {
listTable.onChange?.({}, {}, { columnKey: 'index', order: 'descend' });
});
await flushEffects();
const descendingTables = antdState.tableProps.filter((props) => Array.isArray(props.dataSource)
&& props.dataSource[0]?.value === 'duplicate'
&& props.dataSource[0]?.index === 2);
const descendingTable = descendingTables[descendingTables.length - 1];
expect(descendingTable).toBeTruthy();
const actionColumn = descendingTable.columns.find((column: any) => column.key === 'action');
let actionRenderer: ReactTestRenderer;
await act(async () => {
actionRenderer = create(actionColumn.render(null, { index: 1, value: 'review' }));
actionRenderer = create(actionColumn.render(null, descendingTable.dataSource[0]));
});
const confirmation = actionRenderer!.root
.findAllByType('span')
@@ -1224,7 +1289,8 @@ describe('RedisViewer tree interactions', () => {
expect(redisBackend.RedisListRemove).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
'app:user:1',
'review',
2,
'duplicate',
);
expect(antdState.message.success).toHaveBeenCalledWith('Deleted');

View File

@@ -37,6 +37,7 @@ import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import RedisResizableDivider from './RedisResizableDivider';
import { RedisListPush, RedisListRemove } from '../../wailsjs/go/app/App';
const { Search } = Input;
@@ -1673,7 +1674,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (!config) return;
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey}`)) return;
try {
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
const res = await RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
if (res.success) {
await loadKeyValue(selectedKey);
message.success(tr('redis_viewer.message.add_success'));
@@ -1685,12 +1686,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
};
const handleDeleteListItem = async (value: string) => {
const handleDeleteListItem = async (index: number, value: string) => {
const config = getConfig();
if (!config) return;
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey}`)) return;
if (!await confirmRedisMutation(`db${redisDB} / ${selectedKey} / ${index}`)) return;
try {
const res = await (window as any).go.app.App.RedisListRemove(buildRpcConnectionConfig(config), selectedKey, value);
const res = await RedisListRemove(buildRpcConnectionConfig(config), selectedKey, index, value);
if (res.success) {
await loadKeyValue(selectedKey);
message.success(tr('redis_viewer.message.delete_success'));
@@ -1827,7 +1828,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setJsonEditModalOpen(true);
}} />
)}
<Popconfirm title={tr('redis_viewer.confirm.delete_list_item')} onConfirm={() => handleDeleteListItem(record.value)}>
<Popconfirm title={tr('redis_viewer.confirm.delete_list_item')} onConfirm={() => handleDeleteListItem(record.index, record.value)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>

View File

@@ -416,9 +416,9 @@ export function RedisImportKeys(arg1:connection.ConnectionConfig,arg2:app.RedisI
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:app.RedisListPushOptions):Promise<connection.QueryResult>;
export function RedisListRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RedisListRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;

View File

@@ -822,8 +822,8 @@ export function RedisListPush(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
}
export function RedisListRemove(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListRemove'](arg1, arg2, arg3);
export function RedisListRemove(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisListRemove'](arg1, arg2, arg3, arg4);
}
export function RedisListSet(arg1, arg2, arg3, arg4) {

View File

@@ -1325,6 +1325,20 @@ export namespace app {
this.file = source["file"];
}
}
export class RedisListPushOptions {
values: string[];
position: string;
static createFrom(source: any = {}) {
return new RedisListPushOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.values = source["values"];
this.position = source["position"];
}
}
export class SecurityUpdateOptions {
allowPartial?: boolean;
writeBackup?: boolean;
@@ -2795,4 +2809,3 @@ export namespace sync {
}
}