diff --git a/package.json b/package.json
index 7a11b27..8d00f68 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"react-redux": "^9.2.0",
"react-router": "^7.6.0",
"react-syntax-highlighter": "^15.6.1",
+ "react-toastify": "^11.0.5",
"sass": "^1.89.0",
"sql.js": "^1.13.0",
"workbox-build": "^7.3.0"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e14dcd0..a61154b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -77,6 +77,9 @@ importers:
react-syntax-highlighter:
specifier: ^15.6.1
version: 15.6.1(react@19.1.0)
+ react-toastify:
+ specifier: ^11.0.5
+ version: 11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sass:
specifier: ^1.89.0
version: 1.89.0
@@ -1971,6 +1974,10 @@ packages:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -3414,6 +3421,12 @@ packages:
peerDependencies:
react: '>= 0.14.0'
+ react-toastify@11.0.5:
+ resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==}
+ peerDependencies:
+ react: ^18 || ^19
+ react-dom: ^18 || ^19
+
react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
@@ -6102,6 +6115,8 @@ snapshots:
slice-ansi: 5.0.0
string-width: 7.2.0
+ clsx@2.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -7562,6 +7577,12 @@ snapshots:
react: 19.1.0
refractor: 3.6.0
+ react-toastify@11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ clsx: 2.1.1
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
react@19.1.0: {}
readdirp@4.1.2: {}
diff --git a/src/components/AppRoot.tsx b/src/components/AppRoot.tsx
index 2b571e1..7382a2f 100644
--- a/src/components/AppRoot.tsx
+++ b/src/components/AppRoot.tsx
@@ -10,6 +10,8 @@ import { persistSettings } from '~/features/settings/persistSettings';
import { setupStore } from '~/store';
import { Footer } from '~/components/Footer';
import { FaqTab } from '~/tabs/FaqTab';
+import { SETTINGS_TABS } from '~/features/settings/settingsTabs';
+import { Bounce, ToastContainer } from 'react-toastify';
// Private to this file only.
const store = setupStore();
@@ -40,11 +42,26 @@ export function AppRoot() {
-
+
+ {Object.entries(SETTINGS_TABS).map(([key, { Tab }]) => (
+
+ ))}
+
+
+
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx
index 4bc1e25..3035259 100644
--- a/src/components/Dialog.tsx
+++ b/src/components/Dialog.tsx
@@ -27,7 +27,7 @@ export function Dialog({ closeButton, backdropClose, title, children, show, onCl
)}
-
{title}
+ {title}
{children}
{backdropClose && (
diff --git a/src/components/ExtLink.tsx b/src/components/ExtLink.tsx
index edd9da1..83c78b5 100644
--- a/src/components/ExtLink.tsx
+++ b/src/components/ExtLink.tsx
@@ -7,9 +7,9 @@ export type ExtLinkProps = AnchorHTMLAttributes & {
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
return (
-
+
{children}
- {icon && }
+ {icon && }
);
}
diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx
new file mode 100644
index 0000000..78c2214
--- /dev/null
+++ b/src/components/InfoModal.tsx
@@ -0,0 +1,25 @@
+import { Dialog } from '~/components/Dialog.tsx';
+import React, { useState } from 'react';
+
+interface InfoModalProps {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ children?: React.ReactNode;
+}
+
+export function InfoModal(props: InfoModalProps) {
+ const { title, description, children } = props;
+
+ const [showModal, setShowModal] = useState(false);
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/InstructionsTabs.tsx b/src/components/InstructionsTabs.tsx
new file mode 100644
index 0000000..f3860e2
--- /dev/null
+++ b/src/components/InstructionsTabs.tsx
@@ -0,0 +1,33 @@
+import React, { Fragment, useId } from 'react';
+
+export type InstructionTab = {
+ id: string | number;
+ label: React.ReactNode;
+ content: React.ReactNode;
+};
+
+export interface InstructionsTabsProps {
+ tabs: InstructionTab[];
+}
+
+export function InstructionsTabs({ tabs }: InstructionsTabsProps) {
+ const id = useId();
+ return (
+
+ );
+}
diff --git a/src/components/KeyInput.tsx b/src/components/KeyInput.tsx
new file mode 100644
index 0000000..489b2c2
--- /dev/null
+++ b/src/components/KeyInput.tsx
@@ -0,0 +1,85 @@
+import { PiFileAudio } from 'react-icons/pi';
+import { MdDelete, MdVpnKey } from 'react-icons/md';
+import React from 'react';
+
+export interface KeyInputProps {
+ sequence: number;
+
+ name: string;
+ value: string;
+ isValidKey?: boolean;
+ onSetName: (name: string) => void;
+ onSetValue: (value: string) => void;
+ onDelete: () => void;
+
+ nameLabel?: React.ReactNode;
+ valueLabel?: React.ReactNode;
+ namePlaceholder?: string;
+ valuePlaceholder?: string;
+}
+
+export function KeyInput(props: KeyInputProps) {
+ const {
+ nameLabel,
+ valueLabel,
+ namePlaceholder,
+ valuePlaceholder,
+ sequence,
+ name,
+ value,
+ onSetName,
+ onSetValue,
+ onDelete,
+ isValidKey,
+ } = props;
+
+ return (
+
+
+ {sequence}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Ruby.tsx b/src/components/Ruby.tsx
new file mode 100644
index 0000000..3ad33b9
--- /dev/null
+++ b/src/components/Ruby.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+export interface RubyProps {
+ caption: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Ruby(props: RubyProps) {
+ const { caption, children, ...rest } = props;
+
+ return (
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx
index b30c254..ef19496 100644
--- a/src/features/settings/Settings.tsx
+++ b/src/features/settings/Settings.tsx
@@ -1,158 +1,102 @@
-import {
- Box,
- Button,
- Center,
- chakra,
- Flex,
- HStack,
- Icon,
- IconButton,
- Menu,
- MenuButton,
- MenuItem,
- MenuList,
- Portal,
- Spacer,
- Tab,
- TabList,
- TabPanel,
- TabPanels,
- Tabs,
- Text,
- useToast,
- VStack,
-} from '@chakra-ui/react';
-import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
-import { useState, type FC } from 'react';
-import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
import { useAppDispatch, useAppSelector } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
-import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector';
-import { PanelQingTing } from './panels/PanelQingTing';
-import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
-
-const TABS: { name: string; Tab: FC }[] = [
- { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
- { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
- { name: 'KGG 密钥', Tab: PanelKGGKey },
- { name: '蜻蜓 FM', Tab: PanelQingTing },
- {
- name: '其它/待定',
- Tab: () => 这里空空如也~,
- },
-];
+import { NavLink, Outlet } from 'react-router';
+import { SETTINGS_TABS } from '~/features/settings/settingsTabs.tsx';
+import { MdOutlineSettingsBackupRestore } from 'react-icons/md';
+import { toast } from 'react-toastify';
export function Settings() {
- const toast = useToast();
const dispatch = useAppDispatch();
- const isLargeWidthDevice = false;
- const [tabIndex, setTabIndex] = useState(0);
- const handleTabChange = (idx: number) => {
- setTabIndex(idx);
- };
const handleResetSettings = () => {
dispatch(discardStagingChanges());
- toast({
- status: 'info',
- title: '未储存的设定已舍弃',
- description: '已还原到更改前的状态。',
- isClosable: true,
- });
+ toast.info(() => (
+
+
未储存的设定已舍弃
+
已还原到更改前的状态。
+
+ ));
};
const handleApplySettings = () => {
dispatch(commitStagingChange());
- toast({
- status: 'success',
- title: '设定已应用',
- isClosable: true,
- });
+ toast.success('设定已应用');
};
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
return (
-
-
+
+
+ {Object.entries(SETTINGS_TABS).map(([id, { name }]) => (
+
+ {name}
+
+ ))}
+
-
-
- {TABS.map(({ name }) => (
- {name}
- ))}
-
+ {/* TODO: ensure this flex div does not overflow */}
+
+
+
-
- {TABS.map(({ name, Tab }) => (
-
-
-
-
+
- ))}
-
-
-
+
+
+
+
+
+
+ {/*
*/}
+ {/* */}
+ {/* */}
+ {/* {isSettingsNotSaved ? (*/}
+ {/* */}
+ {/* 有未储存的更改{' '}*/}
+ {/* */}
+ {/* 设定将在保存后生效*/}
+ {/* */}
+ {/* */}
+ {/* ) : (*/}
+ {/* 设定将在保存后生效*/}
+ {/* )}*/}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* }*/}
+ {/* onClick={handleResetSettings}*/}
+ {/* colorScheme="red"*/}
+ {/* variant="ghost"*/}
+ {/* title="放弃未储存的更改,将设定还原未储存前的状态。"*/}
+ {/* aria-label="放弃未储存的更改"*/}
+ {/* />*/}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* ))}*/}
+ {/* */}
+ {/**/}
+
);
}
diff --git a/src/features/settings/panels/PanelQMCv2Key.tsx b/src/features/settings/panels/PanelQMCv2Key.tsx
index 2e6c3ad..319f611 100644
--- a/src/features/settings/panels/PanelQMCv2Key.tsx
+++ b/src/features/settings/panels/PanelQMCv2Key.tsx
@@ -1,4 +1,4 @@
-import { Select, useToast } from '@chakra-ui/react';
+import { useToast } from '@chakra-ui/react';
import { useDispatch, useSelector } from 'react-redux';
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector';
@@ -12,10 +12,11 @@ import { getFileName } from '~/util/pathHelper';
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
import { AddKey } from '~/components/AddKey';
-import { Dialog } from '~/components/Dialog';
+import { InfoModal } from '~/components/InfoModal.tsx';
+import { Ruby } from '~/components/Ruby.tsx';
+import { ExtLink } from '~/components/ExtLink.tsx';
export function PanelQMCv2Key() {
- const [showFuzzyNameSearchInfo, setShowFuzzyNameSearchInfo] = useState(false);
const toast = useToast();
const dispatch = useDispatch();
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
@@ -74,18 +75,13 @@ export function PanelQMCv2Key() {
};
return (
-
+ <>
QMCv2 解密密钥
-
- QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。
-
- 在使用「QQ 音乐」安卓、Mac 或 iOS 客户端,以及在使用「豆瓣 FM」安卓客户端的情况下,
- 其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
-
-
+
QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。
+
「QQ 音乐」安卓、Mac 或 iOS 客户端,或「豆瓣 FM」安卓客户端会将密钥存储在外部的数据库文件内。
-
+
-
-
+ }
>
-
若文件名匹配失败,则使用相似文件名的密钥。
-
- 该匹配使用「
-
- 莱文斯坦距离
-
-
-
-
- 」算法来计算文件名的相似程度。
-
-
若密钥数量过多,匹配时可能会造成浏览器卡顿或无响应一段时间。
-
若不确定,请勾选该项。
-
+ 这是什么?
+
密钥管理
setShowImportModal(true)} clearKeys={clearAll} />
-
-
- {qmc2Keys.map(({ id, ekey, name }, i) => (
-
- ))}
-
- {qmc2Keys.length === 0 &&
还没有密钥。
}
+
+ {qmc2Keys.length > 0 && (
+
+ {qmc2Keys.map(({ id, ekey, name }, i) => (
+
+ ))}
+
+ )}
+ {qmc2Keys.length === 0 &&
还没有密钥。
}
setSecretType(e.target.value as 'qm' | 'douban')}
- variant="flushed"
- display="inline"
- css={{ paddingLeft: '0.75rem', width: 'auto' }}
+ className="inline mx-1 px-1 border-b border-accent/50 bg-base-100"
>
-
+
}
show={showImportModal}
onClose={() => setShowImportModal(false)}
@@ -153,6 +144,6 @@ export function PanelQMCv2Key() {
{secretType === 'qm' && }
{secretType === 'douban' && }
-
+ >
);
}
diff --git a/src/features/settings/panels/QMCv2/InstructionsPC.tsx b/src/features/settings/panels/QMCv2/InstructionsPC.tsx
index dd7afc1..a566a74 100644
--- a/src/features/settings/panels/QMCv2/InstructionsPC.tsx
+++ b/src/features/settings/panels/QMCv2/InstructionsPC.tsx
@@ -1,10 +1,16 @@
-import { Text } from '@chakra-ui/react';
-
export function InstructionsPC() {
return (
<>
- 使用 Windows 19.51 或更低版本下载的歌曲文件无需密钥。
- 使用 Windows 19.57 或更高版本下载的歌曲文件需要导入密钥,但方法尚未公开。
+
+ 使用 19.51 或更低版本下载的歌曲文件
+ 无需密钥。
+
+
+ 使用 19.57 或更高版本下载的歌曲文件
+ 需要导入密钥。
+
+ 目前未公开密钥获取方式。
+
>
);
}
diff --git a/src/features/settings/panels/QMCv2/QMCv2DoubanAllInstructions.tsx b/src/features/settings/panels/QMCv2/QMCv2DoubanAllInstructions.tsx
index b3595c5..33208a1 100644
--- a/src/features/settings/panels/QMCv2/QMCv2DoubanAllInstructions.tsx
+++ b/src/features/settings/panels/QMCv2/QMCv2DoubanAllInstructions.tsx
@@ -1,17 +1,14 @@
-import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
+import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs.tsx';
export function QMCv2DoubanAllInstructions() {
- return (
- <>
-
- 安卓
-
-
-
-
-
-
- >
- );
+ const tabs: InstructionTab[] = [
+ {
+ id: 'android',
+ label: '安卓',
+ content: ,
+ },
+ ];
+
+ return ;
}
diff --git a/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx b/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
index a5a742e..1e3c08b 100644
--- a/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
+++ b/src/features/settings/panels/QMCv2/QMCv2EKeyItem.tsx
@@ -1,54 +1,25 @@
-import { MdDelete, MdVpnKey } from 'react-icons/md';
import { qmc2DeleteKey, qmc2UpdateKey } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks';
import { memo } from 'react';
+import { KeyInput } from '~/components/KeyInput.tsx';
export const QMCv2EKeyItem = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
const dispatch = useAppDispatch();
- const updateKey = (prop: 'name' | 'ekey', e: React.ChangeEvent) =>
- dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
- const deleteKey = () => dispatch(qmc2DeleteKey({ id }));
-
- const isValidEKey = [364, 704].includes(ekey.length);
+ const ekeyLen = ekey.length;
+ const isValidEKey = ekeyLen === 364 || ekeyLen === 704;
return (
-
-
- {i + 1}
-
-
-
-
-
-
-
-
-
+ dispatch(qmc2UpdateKey({ id, field: 'name', value }))}
+ onSetValue={(value) => dispatch(qmc2UpdateKey({ id, field: 'ekey', value }))}
+ onDelete={() => dispatch(qmc2DeleteKey({ id }))}
+ sequence={i + 1}
+ namePlaceholder="文件名,包括后缀名。如 “AAA - BBB.mflac”"
+ valuePlaceholder="密钥,通常包含 364 或 704 位字符,没有空格。"
+ />
);
});
diff --git a/src/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions.tsx b/src/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions.tsx
index 239f1b6..ba46b77 100644
--- a/src/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions.tsx
+++ b/src/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions.tsx
@@ -1,32 +1,20 @@
-import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsIOS } from './InstructionsIOS';
import { InstructionsMac } from './InstructionsMac';
import { InstructionsPC } from './InstructionsPC';
+import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs.tsx';
export function QMCv2QQMusicAllInstructions() {
- return (
- <>
-
- 安卓
- iOS
- Mac
- Windows
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
+ const tabs: InstructionTab[] = [
+ {
+ id: 'android',
+ label: '安卓',
+ content: ,
+ },
+ { id: 'ios', label: 'iOS', content: },
+ { id: 'mac', label: 'Mac', content: },
+ { id: 'windows', label: 'Windows', content: },
+ ];
+
+ return ;
}
diff --git a/src/features/settings/settingsTabs.tsx b/src/features/settings/settingsTabs.tsx
new file mode 100644
index 0000000..fdc6c61
--- /dev/null
+++ b/src/features/settings/settingsTabs.tsx
@@ -0,0 +1,13 @@
+import type { FC } from 'react';
+import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx';
+import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx';
+import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
+import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx';
+
+export const SETTINGS_TABS: Record = {
+ qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
+ kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
+ kgg: { name: 'KGG 密钥', Tab: PanelKGGKey },
+ qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing },
+ // misc: { name: '其它/待定', Tab: () => 这里空空如也~
},
+} as const;