diff --git a/package-lock.json b/package-lock.json index 8a4fa20..ca85877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -2368,6 +2369,116 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", + "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", diff --git a/src/main/adder/common.ts b/src/main/adder/common.ts index 4600689..3f98734 100644 --- a/src/main/adder/common.ts +++ b/src/main/adder/common.ts @@ -22,13 +22,17 @@ export async function addGameToDB({ id, preExistingDbId, screenshotUrl, - playingTime + playingTime, + noWatcherAction = false, + noIpcAction = false }: { dataSource: string id: string preExistingDbId?: string screenshotUrl?: string playingTime?: number + noWatcherAction?: boolean + noIpcAction?: boolean }): Promise { const metadata = await getGameMetadata(dataSource, id) const coverUrl = await getGameCover(dataSource, id) @@ -74,17 +78,20 @@ export async function addGameToDB({ const mainWindow = BrowserWindow.getAllWindows()[0] - stopWatcher() - - await setupWatcher(mainWindow) + if (!noWatcherAction) { + stopWatcher() + await setupWatcher(mainWindow) + } - mainWindow.webContents.send('reload-db-values', `games/${dbId}/cover.webp`) - mainWindow.webContents.send('reload-db-values', `games/${dbId}/background.webp`) - mainWindow.webContents.send('reload-db-values', `games/${dbId}/icon.png`) - mainWindow.webContents.send('reload-db-values', `games/${dbId}/metadata.json`) - mainWindow.webContents.send('reload-db-values', `games/${dbId}/record.json`) - await rebuildIndex() - await rebuildRecords() + if (!noIpcAction) { + mainWindow.webContents.send('reload-db-values', `games/${dbId}/cover.webp`) + mainWindow.webContents.send('reload-db-values', `games/${dbId}/background.webp`) + mainWindow.webContents.send('reload-db-values', `games/${dbId}/icon.png`) + mainWindow.webContents.send('reload-db-values', `games/${dbId}/metadata.json`) + mainWindow.webContents.send('reload-db-values', `games/${dbId}/record.json`) + await rebuildIndex() + await rebuildRecords() + } } /** diff --git a/src/main/adder/services.ts b/src/main/adder/services.ts index da36ebd..3021119 100644 --- a/src/main/adder/services.ts +++ b/src/main/adder/services.ts @@ -14,16 +14,28 @@ export async function addGameToDatabase({ id, preExistingDbId, screenshotUrl, - playingTime + playingTime, + noWatcherAction = false, + noIpcAction = false }: { dataSource: string id: string preExistingDbId?: string screenshotUrl?: string playingTime?: number + noWatcherAction?: boolean + noIpcAction?: boolean }): Promise { try { - await addGameToDB({ dataSource, id, preExistingDbId, screenshotUrl, playingTime }) + await addGameToDB({ + dataSource, + id, + preExistingDbId, + screenshotUrl, + playingTime, + noWatcherAction, + noIpcAction + }) } catch (error) { log.error('Error adding game to database:', error) throw error diff --git a/src/main/importer/steam/common.ts b/src/main/importer/steam/common.ts index 49cd12b..93b25ca 100644 --- a/src/main/importer/steam/common.ts +++ b/src/main/importer/steam/common.ts @@ -2,15 +2,14 @@ import { FormattedGameInfo, GetOwnedGamesResponse } from './types' import { addGameToDatabase } from '~/adder' import { BrowserWindow } from 'electron' import { stopWatcher, setupWatcher } from '~/watcher' +import { rebuildIndex } from '~/database/gameIndex' +import { rebuildRecords } from '~/database/record' /** * Getting information about a user's Steam library - * @param steamId - Steam User ID - * @returns Formatted Game Information Array */ export async function getUserSteamGames(steamId: string): Promise { try { - // Building API URLs const url = new URL('https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/') url.searchParams.append('key', import.meta.env.VITE_STEAM_API_KEY) url.searchParams.append('steamid', steamId) @@ -18,7 +17,6 @@ export async function getUserSteamGames(steamId: string): Promise ({ appId: game.appid, name: game.name, - totalPlayingTime: game.playtime_forever * 60 * 1000 // Convert to milliseconds + totalPlayingTime: game.playtime_forever * 60 * 1000 })) } catch (error) { console.error('获取 Steam 游戏库失败:', error) @@ -45,32 +41,26 @@ export async function getUserSteamGames(steamId: string): Promise { - // Get window instance +export async function importSelectedSteamGames(games: FormattedGameInfo[]): Promise { const mainWindow = BrowserWindow.getAllWindows()[0] stopWatcher() try { - // Get user's Steam game list - const games = await getUserSteamGames(steamId) const totalGames = games.length - // If there is no game, return directly to if (totalGames === 0) { mainWindow.webContents.send('import-steam-games-progress', { current: 0, total: 0, status: 'completed', - message: '没有找到游戏' + message: '没有选择要导入的游戏' }) return 0 } - // Send Initial Progress + // Send initial progress mainWindow.webContents.send('import-steam-games-progress', { current: 0, total: totalGames, @@ -85,7 +75,9 @@ export async function importUserSteamGamesToDatabase(steamId: string): Promise setTimeout(resolve, 300)) } - // Send Completion Message + await rebuildIndex() + await rebuildRecords() + await setupWatcher(mainWindow) + + // Send a completion message mainWindow.webContents.send('import-steam-games-progress', { current: totalGames, total: totalGames, @@ -128,15 +124,13 @@ export async function importUserSteamGamesToDatabase(steamId: string): Promise { +export async function getSteamGames(steamId: string): Promise { try { - await importUserSteamGamesToDatabase(steamId) + return await getUserSteamGames(steamId) } catch (error) { - log.error('Error adding user steam games to database:', error) + log.error('获取 Steam 游戏库失败:', error) + throw error + } +} + +/** + * Import selected Steam games to the database + * @param games Selected Steam games + * @returns Number of games imported + */ +export async function importSteamGames(games: FormattedGameInfo[]): Promise { + try { + return await importSelectedSteamGames(games) + } catch (error) { + log.error('导入 Steam 游戏失败:', error) throw error } } diff --git a/src/main/ipc/importer.ts b/src/main/ipc/importer.ts index b961c63..9b0c7f5 100644 --- a/src/main/ipc/importer.ts +++ b/src/main/ipc/importer.ts @@ -1,5 +1,6 @@ import { importV1DataToV2 } from '~/importer/versionConverter' -import { importUserSteamGamesToDB } from '~/importer/steam' +import { getSteamGames, importSteamGames } from '~/importer/steam' +import { FormattedGameInfo } from '~/importer/steam/types' import { ipcMain, BrowserWindow } from 'electron' export function setupImporterIPC(mainWindow: BrowserWindow): void { @@ -7,8 +8,12 @@ export function setupImporterIPC(mainWindow: BrowserWindow): void { return await importV1DataToV2(dataPath) }) - ipcMain.handle('import-user-steam-games', async (_, steamId: string) => { - return await importUserSteamGamesToDB(steamId) + ipcMain.handle('get-steam-games', async (_event, steamId: string) => { + return await getSteamGames(steamId) + }) + + ipcMain.handle('import-selected-steam-games', async (_event, games: FormattedGameInfo[]) => { + return await importSteamGames(games) }) mainWindow.webContents.send('importerIPCReady') diff --git a/src/renderer/src/components/ui/checkbox.tsx b/src/renderer/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c176add --- /dev/null +++ b/src/renderer/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +'use client' + +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' + +import { cn } from '~/utils' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/renderer/src/pages/Importer/SteamImporter/main.tsx b/src/renderer/src/pages/Importer/SteamImporter/main.tsx index 59870df..efa5180 100644 --- a/src/renderer/src/pages/Importer/SteamImporter/main.tsx +++ b/src/renderer/src/pages/Importer/SteamImporter/main.tsx @@ -1,24 +1,34 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { Button } from '@ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@ui/dialog' import { Input } from '@ui/input' import { Progress } from '@ui/progress' import { ScrollArea } from '@ui/scroll-area' import { Alert, AlertDescription, AlertTitle } from '@ui/alert' -import { CheckCircle2, XCircle, AlertCircle } from 'lucide-react' -import { useSteamImporterStore } from './store' +import { CheckCircle2, XCircle, AlertCircle, Loader2, Search } from 'lucide-react' +import { useSteamImporterStore, GameInfo } from './store' import { ipcInvoke, ipcOnUnique } from '~/utils' import { cn } from '~/utils' import { useState } from 'react' import { toast } from 'sonner' +import { Checkbox } from '@ui/checkbox' export function SteamImporter(): JSX.Element { - const [isLoading, setIsLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [isImportLoading, setIsImportLoading] = useState(false) + const [hasEverFetched, setHasEverFetched] = useState(false) + const { isOpen, setIsOpen, steamId, setSteamId, + games, + setGames, + isLoadingGames, + setIsLoadingGames, + toggleGameSelection, + toggleAllGames, progress, status, message, @@ -40,13 +50,57 @@ export function SteamImporter(): JSX.Element { } }, [updateProgress]) - const startImport = async (): Promise => { + // Get Game List + const fetchGames = async (): Promise => { if (!steamId) return try { - setIsLoading(true) + setIsLoadingGames(true) + const gamesData = (await ipcInvoke('get-steam-games', steamId)) as GameInfo[] + setGames( + gamesData.map((game) => ({ + ...game, + selected: false + })) + ) + setHasEverFetched(true) // Mark the list of games that have been fetched + } catch (error) { + console.error('获取游戏列表失败:', error) + toast.error('获取游戏列表失败,请重试') + } finally { + setIsLoadingGames(false) + } + } + + // Start importing the selected game + const startImport = async (): Promise => { + const selectedGames = games.filter((game) => game.selected) + if (selectedGames.length === 0) { + toast.error('请至少选择一个游戏') + return + } + + try { + setIsImportLoading(true) reset() - await ipcInvoke('import-user-steam-games', steamId) + await ipcInvoke( + 'import-selected-steam-games', + selectedGames.map((game) => ({ + appId: game.appId, + name: game.name, + totalPlayingTime: game.totalPlayingTime + })) + ) + setIsImportLoading(false) + + // Remove the selected game from the game list after the import is complete + const remainingGames = games.filter((game) => !game.selected) + setGames(remainingGames) + + // If there are no games left, it shows that all imports are complete + if (remainingGames.length === 0) { + toast.success('所有游戏已导入完成') + } } catch (error) { console.error('导入失败:', error) updateProgress({ @@ -55,11 +109,14 @@ export function SteamImporter(): JSX.Element { status: 'error', message: '导入失败,请重试' }) - } finally { - setIsLoading(false) } } + // Filter games + const filteredGames = games.filter((game) => + game.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + // Calculate the number of successes and failures const successCount = gameLogs.filter((g) => g.status === 'success').length const errorCount = gameLogs.filter((g) => g.status === 'error').length @@ -69,18 +126,36 @@ export function SteamImporter(): JSX.Element { if (status !== 'processing') { setIsOpen(false) reset() + setGames([]) + setSteamId('') + setSearchQuery('') + setHasEverFetched(false) // Reset acquisition state } else { toast.warning('请等待导入完成') } } + + const isImporting = status === 'processing' + const selectedCount = games.filter((game) => game.selected).length + + // Defining state types + type ImportStatus = 'initial' | 'ready' | 'loading' | 'empty' | 'hasGames' + + const currentStatus = useMemo((): ImportStatus => { + if (!steamId) return 'initial' + if (steamId && !isLoadingGames && games.length === 0 && !hasEverFetched) return 'ready' + if (isLoadingGames) return 'loading' + if (games.length === 0 && hasEverFetched) return 'empty' + return 'hasGames' + }, [steamId, isLoadingGames, games.length, hasEverFetched]) + return ( { e.preventDefault() }} - className={cn('transition-all duration-300')} - onClose={handleClose} + className={cn('transition-all duration-300 max-w-xl')} > 导入 Steam 游戏 @@ -89,7 +164,7 @@ export function SteamImporter(): JSX.Element { - {/* Steam ID input */} + {/* Steam ID Input */}
setSteamId(e.target.value)} placeholder="输入 Steam ID" - className={cn('col-span-3')} - disabled={status === 'processing' || isLoading} + className="col-span-3" + disabled={isLoadingGames || isImporting} />
- {/* progress indicator */} -
- {/* progress bar */} -
- -

- {progress}% -

+ {/* Game List */} + {!isImporting && ( +
+ {((): JSX.Element => { + switch (currentStatus) { + case 'initial': + return ( + + + 开始导入 + 请输入你的 Steam ID 获取游戏列表 + + ) + case 'ready': + return ( + + + 准备获取 + {'请点击"获取"按钮来获取游戏列表'} + + ) + case 'loading': + return ( + + + 加载中 + 正在获取游戏列表... + + ) + case 'empty': + return ( + + + 已全部导入 + 所有游戏都已成功导入 + + ) + case 'hasGames': + return ( + // Game List Contents + <> +
+
+ toggleAllGames(!!checked)} + /> + +
+ setSearchQuery(e.target.value)} + className="max-w-xs" + /> +
+ + +
+ {filteredGames.map((game) => ( +
+ toggleGameSelection(game.appId)} + /> + + + {Math.round(game.totalPlayingTime / (1000 * 60 * 60))}小时 + +
+ ))} +
+
+ + + + ) + } + })()}
+ )} - {/* status message */} - - - - {status === 'processing' && '正在导入'} - {status === 'completed' && '导入完成'} - {status === 'error' && '导入错误'} - - {message} - + {/* Import progress */} + {isImporting && ( +
+
+ +

{progress}%

+
+ + + + 正在导入 + {message} + - {/* Game Import Log */} -
- -
+ +
{gameLogs.map((game, index) => (
-
+
{game.status === 'success' ? ( - + ) : ( - + )} - {game.name} + {game.name}
{game.error && ( - - {game.error} - + {game.error} )}
))}
+ )} - {/* Completion statistics */} - {status === 'completed' && ( - - - 导入完成 - - 成功: {successCount} 个 失败: {errorCount} 个 - - - )} -
+ {/* 导入完成或错误状态显示,只在状态为 completed 或 error 时显示 */} + {(status === 'completed' || status === 'error') && ( + + + {status === 'completed' ? '导入完成' : '导入错误'} + + {status === 'completed' ? `成功: ${successCount} 个 失败: ${errorCount} 个` : message} + + + )}
) diff --git a/src/renderer/src/pages/Importer/SteamImporter/store.ts b/src/renderer/src/pages/Importer/SteamImporter/store.ts index 286942d..064659f 100644 --- a/src/renderer/src/pages/Importer/SteamImporter/store.ts +++ b/src/renderer/src/pages/Importer/SteamImporter/store.ts @@ -1,5 +1,12 @@ import { create } from 'zustand' +export interface GameInfo { + appId: number + name: string + totalPlayingTime: number + selected?: boolean +} + interface GameLog { name: string status: 'success' | 'error' @@ -9,7 +16,7 @@ interface GameLog { interface ProgressMessage { current: number total: number - status: 'started' | 'processing' | 'completed' | 'error' + status: 'started' | 'processing' | 'completed' | 'error' | 'idle' message: string game?: GameLog } @@ -17,6 +24,9 @@ interface ProgressMessage { interface SteamImporterState { isOpen: boolean steamId: string + // Game List Related + games: GameInfo[] + isLoadingGames: boolean // Progress-related progress: number status: ProgressMessage['status'] @@ -25,7 +35,11 @@ interface SteamImporterState { // Operating Methods setIsOpen: (open: boolean) => void setSteamId: (id: string) => void - updateProgress: (ProgressMessage) => void + setGames: (games: GameInfo[]) => void + toggleGameSelection: (appId: number) => void + toggleAllGames: (selected: boolean) => void + setIsLoadingGames: (loading: boolean) => void + updateProgress: (data: ProgressMessage) => void reset: () => void } @@ -33,6 +47,8 @@ export const useSteamImporterStore = create((set) => ({ // initial state isOpen: false, steamId: '', + games: [], + isLoadingGames: false, progress: 0, status: 'started', message: '', @@ -41,6 +57,18 @@ export const useSteamImporterStore = create((set) => ({ // methods setIsOpen: (open): void => set({ isOpen: open }), setSteamId: (id): void => set({ steamId: id }), + setGames: (games): void => set({ games }), + setIsLoadingGames: (loading): void => set({ isLoadingGames: loading }), + toggleGameSelection: (appId): void => + set((state) => ({ + games: state.games.map((game) => + game.appId === appId ? { ...game, selected: !game.selected } : game + ) + })), + toggleAllGames: (selected): void => + set((state) => ({ + games: state.games.map((game) => ({ ...game, selected })) + })), updateProgress: (data): void => { const { current, total, status, message, game } = data set((state) => ({ @@ -50,7 +78,6 @@ export const useSteamImporterStore = create((set) => ({ gameLogs: game ? [...state.gameLogs, game] : state.gameLogs })) }, - reset: (): void => { set({ progress: 0,