添加设定界面 - #18 #21

Merged
lsr merged 24 commits from feat/settings into main 2023-06-10 15:23:21 +00:00
5 changed files with 151 additions and 117 deletions
Showing only changes of commit 8e2d13f54a - Show all commits

View File

@ -1,36 +1,40 @@
import {
Box,
Button,
Center,
Flex,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Spacer,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Text,
VStack,
useBreakpointValue,
} from '@chakra-ui/react';
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
import { useState } from 'react';
import { MdExpandMore, MdMenu } from 'react-icons/md';
import { useAppDispatch } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{
name: '其它/待定',
Tab: () => (
<TabPanel>
<Text></Text>
</TabPanel>
),
Tab: () => <Text></Text>,
},
];
export function Settings() {
const dispatch = useAppDispatch();
const isLargeWidthDevice =
useBreakpointValue({
base: false,
@ -41,6 +45,8 @@ export function Settings() {
const handleTabChange = (idx: number) => {
setTabIndex(idx);
};
const handleResetSettings = () => dispatch(discardStagingChanges());
const handleApplySettings = () => dispatch(commitStagingChange());
return (
<Flex flexDir="column" flex={1}>
@ -86,7 +92,26 @@ export function Settings() {
<TabPanels>
{TABS.map(({ name, Tab }) => (
<Tab key={name} />
<Flex as={TabPanel} flex={1} flexDir="column" h="100%" key={name}>
<Flex h="100%" flex={1} minH={0}>
<Tab />
</Flex>
<VStack mt="4" alignItems="flex-start" w="full">
<Flex flexDir="row" gap="2" w="full">
<Center>
<Box color="gray"></Box>
</Center>
<Spacer />
<HStack gap="2" justifyContent="flex-end">
<Button onClick={handleResetSettings} colorScheme="red" variant="ghost" title="重置为更改前的状态">
</Button>
<Button onClick={handleApplySettings}></Button>
</HStack>
</Flex>
</VStack>
</Flex>
))}
</TabPanels>
</Tabs>

View File

@ -2,7 +2,6 @@ import {
Box,
Button,
ButtonGroup,
Center,
Flex,
HStack,
Heading,
@ -19,84 +18,34 @@ import {
MenuDivider,
MenuItem,
MenuList,
Spacer,
TabPanel,
Text,
VStack,
useToast,
} from '@chakra-ui/react';
import { useDispatch, useSelector } from 'react-redux';
import { selectQM2CSettings, updateQMC2Keys } from '../settingsSlice';
import React, { useEffect, useState } from 'react';
import { nanoid } from 'nanoid';
import { produce } from 'immer';
import { MdAdd, MdAndroid, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
import { objectify } from 'radash';
interface InternalQMCKeys {
id: string;
name: string;
key: string;
}
import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector';
import React from 'react';
import { MdAdd, MdAndroid, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
export function PanelQMCv2Key() {
const toast = useToast();
const dispatch = useDispatch();
const qmcSettings = useSelector(selectQM2CSettings);
const [isModified, setIsModified] = useState(false);
const [qmcKeys, setQMCKeys] = useState<InternalQMCKeys[]>([]);
const resetQmcKeys = () => {
const result: InternalQMCKeys[] = [];
for (const [name, key] of Object.entries(qmcSettings.keys)) {
result.push({ id: name, name, key });
}
setQMCKeys(result);
};
const addRow = () => {
setIsModified(true);
setQMCKeys((prev) => [...prev, { id: nanoid(), key: '', name: '' }]);
};
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) => {
setIsModified(true);
setQMCKeys((prev) =>
produce(prev, (draft) => {
const item = draft.find((item) => item.id === id);
if (item) {
item[prop] = e.target.value;
}
})
);
};
const applyChanges = () => {
dispatch(
updateQMC2Keys(
objectify(
qmcKeys,
(item) => item.name,
(item) => item.key
)
)
);
const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys;
toast({
title: 'QMCv2 密钥的更改已保存。',
status: 'success',
isClosable: true,
duration: 2500,
});
};
const clearAll = () => setQMCKeys([]);
useEffect(resetQmcKeys, [qmcSettings.keys]);
const addKey = () => dispatch(qmc2AddKey());
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
const deleteKey = (id: string) => dispatch(qmc2DeleteKey({ id }));
const clearAll = () => dispatch(qmc2ClearKeys());
return (
<Flex as={TabPanel} flexDir="column" h="100%">
<Flex minH={0} flexDir="column" flex={1}>
<Heading as="h2" size="lg">
</Heading>
<Box pb="2">
<Box pb={2} pt={2}>
<ButtonGroup isAttached variant="outline">
<Button onClick={addRow} leftIcon={<Icon as={MdAdd} />}>
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
</Button>
<Menu>
@ -120,8 +69,8 @@ export function PanelQMCv2Key() {
<Box flex={1} minH={0} overflow="auto" pr="4">
<List spacing={3}>
{qmcKeys.map(({ id, key, name }, i) => (
<ListItem key={id}>
{qmc2Keys.map(({ id, key, name }, i) => (
<ListItem key={id} mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
<HStack>
<Text w="2em" textAlign="center">
{i + 1}
@ -147,37 +96,21 @@ export function PanelQMCv2Key() {
</InputRightElement>
</InputGroup>
</VStack>
<IconButton
aria-label="删除该密钥"
icon={<Icon as={MdDelete} boxSize={6} />}
variant="ghost"
colorScheme="red"
type="button"
onClick={() => deleteKey(id)}
/>
</HStack>
</ListItem>
))}
</List>
{qmcKeys.length === 0 && <Text></Text>}
{qmc2Keys.length === 0 && <Text></Text>}
</Box>
<VStack mt="4" alignItems="flex-start" w="full">
<Flex flexDir="row" gap="2" w="full">
<Center>
<Text as={Box} color="gray">
</Text>
</Center>
<Spacer />
<HStack gap="2" justifyContent="flex-end">
<Button
disabled={!isModified}
onClick={resetQmcKeys}
colorScheme="red"
variant="ghost"
title="重置为更改前的状态"
>
</Button>
<Button disabled={!isModified} onClick={applyChanges}>
</Button>
</HStack>
</Flex>
</VStack>
</Flex>
);
}

View File

@ -2,14 +2,14 @@ import { debounce } from 'radash';
import { produce } from 'immer';
import type { AppStore } from '~/store';
import { SettingsState, settingsSlice, updateSettings } from './settingsSlice';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils';
const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeSettings(settings: SettingsState): SettingsState {
return produce(settingsSlice.getInitialState(), (draft) => {
function mergeSettings(settings: ProductionSettings): ProductionSettings {
return produce(settingsSlice.getInitialState().production, (draft) => {
for (const [k, v] of enumObject(settings.qmc2?.keys)) {
if (typeof v === 'string') {
draft.qmc2.keys[k] = v;
@ -22,10 +22,10 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
let lastSettings: unknown;
try {
const loadedSettings: SettingsState = JSON.parse(localStorage.getItem(storageKey) ?? '');
const loadedSettings: ProductionSettings = JSON.parse(localStorage.getItem(storageKey) ?? '');
if (loadedSettings) {
const mergedSettings = mergeSettings(loadedSettings);
store.dispatch(updateSettings(mergedSettings));
store.dispatch(setProductionChanges(mergedSettings));
getLogger().debug('settings loaded');
}
} catch {
@ -34,7 +34,7 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
return store.subscribe(
debounce({ delay: 150 }, () => {
const currentSettings = store.getState().settings;
const currentSettings = store.getState().settings.production;
if (lastSettings !== currentSettings) {
lastSettings = currentSettings;
localStorage.setItem(storageKey, JSON.stringify(currentSettings));

View File

@ -2,8 +2,11 @@ import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import type { RootState } from '~/store';
import { hasOwn } from '~/util/objects';
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
const qmc2Keys = state.settings.qmc2.keys;
const qmc2Keys = selectFinalQMCv2Settings(state).keys;
return {
qmc2Key: hasOwn(qmc2Keys, name) ? qmc2Keys[name] : undefined,

View File

@ -1,28 +1,92 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '~/store';
import { nanoid } from 'nanoid';
import { objectify } from 'radash';
export interface QMCSettings {
keys: Record<string, string>; // { [fileName]: ekey }
export interface StagingSettings {
qmc2: {
keys: { id: string; name: string; key: string }[];
};
}
export interface ProductionSettings {
qmc2: {
keys: Record<string, string>; // { [fileName]: ekey }
};
}
export interface SettingsState {
qmc2: QMCSettings;
staging: StagingSettings;
production: ProductionSettings;
}
const initialState: SettingsState = {
qmc2: { keys: {} },
staging: {
qmc2: {
keys: [],
},
},
production: {
qmc2: {
keys: {},
},
},
};
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
qmc2: {
keys: objectify(
staging.qmc2.keys,
(item) => item.name,
(item) => item.key
),
},
});
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
qmc2: {
keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })),
},
});
export const settingsSlice = createSlice({
name: 'settings',
initialState,
reducers: {
updateSettings: (_state, { payload }: PayloadAction<SettingsState>) => {
return payload;
setProductionChanges: (_state, { payload }: PayloadAction<ProductionSettings>) => {
return {
production: payload,
staging: productionToStaging(payload),
};
},
updateQMC2Keys: (state, { payload }: PayloadAction<QMCSettings['keys']>) => {
state.qmc2.keys = payload;
qmc2AddKey(state) {
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
},
qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
const qmc2 = state.staging.qmc2;
qmc2.keys = qmc2.keys.filter((item) => item.id !== id);
},
qmc2UpdateKey(
state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: 'name' | 'key'; value: string }>
) {
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
if (keyItem) {
keyItem[field] = value;
}
},
qmc2ClearKeys(state) {
state.staging.qmc2.keys = [];
},
discardStagingChanges: (state) => {
state.staging = productionToStaging(state.production);
},
commitStagingChange: (state) => {
const production = stagingToProduction(state.staging);
return {
// Sync back to staging
staging: productionToStaging(production),
production,
};
},
resetConfig: () => {
return initialState;
@ -30,8 +94,17 @@ export const settingsSlice = createSlice({
},
});
export const { updateSettings, resetConfig, updateQMC2Keys } = settingsSlice.actions;
export const {
setProductionChanges,
resetConfig,
export const selectQM2CSettings = (state: RootState) => state.settings.qmc2;
qmc2AddKey,
qmc2UpdateKey,
qmc2DeleteKey,
qmc2ClearKeys,
commitStagingChange,
discardStagingChanges,
} = settingsSlice.actions;
export default settingsSlice.reducer;