mirror of
https://github.com/lifegpc/garbro-rs.git
synced 2026-06-17 16:35:37 +08:00
Basic Picture Preview Impl
This commit is contained in:
98
src/App.css
Normal file
98
src/App.css
Normal file
@@ -0,0 +1,98 @@
|
||||
:root {
|
||||
color: #17221d;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(250, 225, 177, 0.7), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(77, 138, 116, 0.22), transparent 24%),
|
||||
linear-gradient(145deg, #f5efe6 0%, #ebe4d7 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Two-panel layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 360px;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(23, 34, 29, 0.12);
|
||||
background: rgba(255, 251, 244, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* FileExplorer */
|
||||
.file-explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(23, 34, 29, 0.08);
|
||||
background: rgba(255, 253, 248, 0.9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-wrapper,
|
||||
.file-explorer .ant-spin-nested-loading,
|
||||
.file-explorer .ant-spin-container,
|
||||
.file-explorer .ant-table,
|
||||
.file-explorer .ant-table-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-body {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.file-explorer__row--selected td {
|
||||
background: rgba(22, 119, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-row:hover td {
|
||||
background: rgba(23, 34, 29, 0.04) !important;
|
||||
}
|
||||
|
||||
.file-explorer__row--selected:hover td {
|
||||
background: rgba(22, 119, 255, 0.15) !important;
|
||||
}
|
||||
56
src/App.tsx
Normal file
56
src/App.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { App as AntApp } from "antd";
|
||||
import { getStartDirectory } from "./api";
|
||||
import { FileExplorer } from "./components/FileExplorer";
|
||||
import { ImagePreviewPanel } from "./components/ImagePreviewPanel";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Entry, FileOptions } from "./types";
|
||||
import "./App.css";
|
||||
|
||||
interface PreviewTarget {
|
||||
path: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [startDirectory, setStartDirectory] = useState<string>("");
|
||||
const [preview, setPreview] = useState<PreviewTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getStartDirectory().then(setStartDirectory);
|
||||
}, []);
|
||||
|
||||
const handleEntrySelect = (entry: Entry, fullPath: string, options?: FileOptions[]) => {
|
||||
if (entry.entry_type === 'Image') {
|
||||
setPreview({ path: fullPath, options });
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="app-sidebar">
|
||||
{startDirectory && (
|
||||
<FileExplorer initialPath={startDirectory} onEntrySelect={handleEntrySelect} />
|
||||
)}
|
||||
</div>
|
||||
<div className="app-content">
|
||||
{preview ? (
|
||||
<ImagePreviewPanel path={preview.path} options={preview.options} />
|
||||
) : (
|
||||
<span style={{ color: "#8c8c8c" }}>选择图片文件以预览</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AntApp>
|
||||
<AppContent />
|
||||
</AntApp>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
19
src/api.ts
Normal file
19
src/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GameTitle, Entry, FileOptions } from "./types";
|
||||
|
||||
export async function getStartDirectory(): Promise<string> {
|
||||
return await invoke("get_start_directory");
|
||||
}
|
||||
|
||||
export async function getXp3SupportedGames(): Promise<GameTitle[]> {
|
||||
return await invoke("get_xp3_supported_games");
|
||||
}
|
||||
|
||||
export async function listDirectory(path: string, options?: FileOptions[]): Promise<Entry[]> {
|
||||
return await invoke("list_directory", { path, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function previewImage(path: string, options?: FileOptions[]): Promise<Uint8Array> {
|
||||
const bytes: number[] = await invoke("preview_image", { path, options: options ?? null });
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
390
src/components/FileExplorer.tsx
Normal file
390
src/components/FileExplorer.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Tooltip, Table, App as AntApp } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
SoundOutlined,
|
||||
CompressOutlined,
|
||||
FileTextOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Entry, EntryType, FileOptions } from '../types';
|
||||
import { listDirectory } from '../api';
|
||||
import { Xp3OptionsDialog } from './Xp3OptionsDialog';
|
||||
|
||||
interface FileExplorerProps {
|
||||
initialPath: string;
|
||||
onEntrySelect?: (entry: Entry, fullPath: string, options?: FileOptions[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A location in the navigation history.
|
||||
* archiveSubdir === undefined -> FS directory (always call backend)
|
||||
* archiveSubdir === "" -> root of an archive
|
||||
* archiveSubdir === "bg/" -> virtual subdir inside an archive
|
||||
*
|
||||
* backendPath for nested archives: "game.xp3|inner.dat"
|
||||
* options[N] corresponds to the N-th archive in the pipe-separated backendPath
|
||||
*/
|
||||
interface Location {
|
||||
backendPath: string;
|
||||
archiveSubdir?: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
function locationDisplayPath(loc: Location): string {
|
||||
if (loc.archiveSubdir === undefined || loc.archiveSubdir === '') return loc.backendPath;
|
||||
return `${loc.backendPath}|${loc.archiveSubdir}`;
|
||||
}
|
||||
|
||||
function parsePath(path: string): Location {
|
||||
const pipeIdx = path.indexOf('|');
|
||||
if (pipeIdx === -1) return { backendPath: path, archiveSubdir: undefined };
|
||||
return { backendPath: path.slice(0, pipeIdx), archiveSubdir: path.slice(pipeIdx + 1) };
|
||||
}
|
||||
|
||||
function fsDirParent(path: string): string | null {
|
||||
const norm = path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const lastSlash = norm.lastIndexOf('/');
|
||||
if (lastSlash === -1) return null;
|
||||
if (lastSlash === 0) return '/';
|
||||
const parent = norm.slice(0, lastSlash);
|
||||
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
|
||||
return parent;
|
||||
}
|
||||
|
||||
function archiveSubdirParent(subdir: string): string | null {
|
||||
if (subdir === '') return null;
|
||||
const trimmed = subdir.replace(/\/$/, '');
|
||||
const lastSlash = trimmed.lastIndexOf('/');
|
||||
if (lastSlash === -1) return '';
|
||||
return trimmed.slice(0, lastSlash + 1);
|
||||
}
|
||||
|
||||
function getParentLocation(loc: Location): Location | null {
|
||||
if (loc.archiveSubdir === undefined) {
|
||||
const parent = fsDirParent(loc.backendPath);
|
||||
return parent ? { backendPath: parent } : null;
|
||||
}
|
||||
const parentSubdir = archiveSubdirParent(loc.archiveSubdir);
|
||||
if (parentSubdir !== null) return { ...loc, archiveSubdir: parentSubdir };
|
||||
const archiveParent = fsDirParent(loc.backendPath);
|
||||
return archiveParent ? { backendPath: archiveParent } : null;
|
||||
}
|
||||
|
||||
function joinFsPath(dir: string, name: string): string {
|
||||
return dir.replace(/[/\\]+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function sortEntries(entries: Entry[]): Entry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const aIsFolder = a.entry_type === 'Folder' ? 0 : 1;
|
||||
const bIsFolder = b.entry_type === 'Folder' ? 0 : 1;
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder;
|
||||
// 文件夹之间按名字排序,其他类型保持原始顺序
|
||||
if (aIsFolder === 0) return a.name.localeCompare(b.name);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat archive listing into entries visible at the given virtual subdir.
|
||||
* virtualSubdir: "" = root, "bg/" = inside bg folder
|
||||
*/
|
||||
function buildVirtualDirEntries(flatEntries: Entry[], virtualSubdir: string): Entry[] {
|
||||
const result = new Map<string, Entry>();
|
||||
for (const entry of flatEntries) {
|
||||
const fullName = entry.name.replace(/\\/g, '/');
|
||||
if (!fullName.startsWith(virtualSubdir)) continue;
|
||||
const rest = fullName.slice(virtualSubdir.length);
|
||||
if (rest === '') continue;
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx === -1) {
|
||||
result.set(rest, { ...entry, name: rest });
|
||||
} else {
|
||||
const folderName = rest.slice(0, slashIdx);
|
||||
if (!result.has(folderName)) {
|
||||
result.set(folderName, {
|
||||
name: folderName,
|
||||
is_dir: true,
|
||||
entry_type: 'Folder',
|
||||
msg_tool_type: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortEntries(Array.from(result.values()));
|
||||
}
|
||||
|
||||
function getEntryIcon(entryType: EntryType): React.ReactNode {
|
||||
switch (entryType) {
|
||||
case 'Folder': return <FolderOutlined style={{ color: '#faad14' }} />;
|
||||
case 'Image': return <FileImageOutlined style={{ color: '#52c41a' }} />;
|
||||
case 'Audio': return <SoundOutlined style={{ color: '#1677ff' }} />;
|
||||
case 'Archive': return <CompressOutlined style={{ color: '#eb2f96' }} />;
|
||||
case 'Text': return <FileTextOutlined style={{ color: '#722ed1' }} />;
|
||||
default: return <FileOutlined style={{ color: '#8c8c8c' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(entryType: EntryType): string {
|
||||
switch (entryType) {
|
||||
case 'Folder': return '文件夹';
|
||||
case 'Archive': return '归档';
|
||||
case 'Image': return '图片';
|
||||
case 'Audio': return '音频';
|
||||
case 'Text': return '文本';
|
||||
default: return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(size?: number): string {
|
||||
if (size == null) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface NavState {
|
||||
history: Location[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function FileExplorer({ initialPath, onEntrySelect }: FileExplorerProps) {
|
||||
const { message: messageApi } = AntApp.useApp();
|
||||
|
||||
const [nav, setNav] = useState<NavState>({
|
||||
history: [{ backendPath: initialPath, archiveSubdir: undefined }],
|
||||
index: 0,
|
||||
});
|
||||
const currentLoc = nav.history[nav.index];
|
||||
|
||||
const [pathInput, setPathInput] = useState(initialPath);
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
const archiveCache = useRef<Map<string, Entry[]>>(new Map());
|
||||
|
||||
// Pending navigation for XP3 dialog
|
||||
const [xp3DialogOpen, setXp3DialogOpen] = useState(false);
|
||||
const pendingNavRef = useRef<{ target: Location; currentOptions?: FileOptions[] } | null>(null);
|
||||
|
||||
const loadLocation = useCallback(async (loc: Location) => {
|
||||
setLoading(true);
|
||||
setSelectedKey(null);
|
||||
try {
|
||||
if (loc.archiveSubdir !== undefined) {
|
||||
const cacheKey = loc.options
|
||||
? `${loc.backendPath}::${JSON.stringify(loc.options)}`
|
||||
: loc.backendPath;
|
||||
let flat = archiveCache.current.get(cacheKey);
|
||||
if (!flat) {
|
||||
flat = await listDirectory(loc.backendPath, loc.options);
|
||||
archiveCache.current.set(cacheKey, flat);
|
||||
}
|
||||
setEntries(buildVirtualDirEntries(flat, loc.archiveSubdir));
|
||||
} else {
|
||||
const result = await listDirectory(loc.backendPath);
|
||||
setEntries(sortEntries(result));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { msg?: string };
|
||||
messageApi.error(`无法打开: ${e?.msg ?? String(err)}`);
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
useEffect(() => {
|
||||
setPathInput(locationDisplayPath(currentLoc));
|
||||
loadLocation(currentLoc);
|
||||
}, [currentLoc, loadLocation]);
|
||||
|
||||
const navigateTo = useCallback((loc: Location) => {
|
||||
setNav(prev => ({
|
||||
history: [...prev.history.slice(0, prev.index + 1), loc],
|
||||
index: prev.index + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setNav(prev => prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev);
|
||||
}, []);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
setNav(prev => prev.index < prev.history.length - 1 ? { ...prev, index: prev.index + 1 } : prev);
|
||||
}, []);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
const parent = getParentLocation(currentLoc);
|
||||
if (parent) navigateTo(parent);
|
||||
}, [currentLoc, navigateTo]);
|
||||
|
||||
const handlePathSubmit = useCallback(() => {
|
||||
const trimmed = pathInput.trim();
|
||||
const current = locationDisplayPath(currentLoc);
|
||||
if (trimmed && trimmed !== current) {
|
||||
navigateTo(parsePath(trimmed));
|
||||
} else {
|
||||
loadLocation(currentLoc);
|
||||
}
|
||||
}, [pathInput, currentLoc, navigateTo, loadLocation]);
|
||||
|
||||
const handleRowClick = useCallback((entry: Entry) => {
|
||||
setSelectedKey(entry.name);
|
||||
let fullPath: string;
|
||||
if (currentLoc.archiveSubdir !== undefined) {
|
||||
fullPath = `${currentLoc.backendPath}|${currentLoc.archiveSubdir}${entry.name}`;
|
||||
} else {
|
||||
fullPath = joinFsPath(currentLoc.backendPath, entry.name);
|
||||
}
|
||||
onEntrySelect?.(entry, fullPath, currentLoc.options);
|
||||
}, [currentLoc, onEntrySelect]);
|
||||
|
||||
const handleRowDoubleClick = useCallback((entry: Entry) => {
|
||||
if (currentLoc.archiveSubdir !== undefined) {
|
||||
if (entry.entry_type === 'Folder') {
|
||||
navigateTo({ ...currentLoc, archiveSubdir: currentLoc.archiveSubdir + entry.name + '/' });
|
||||
} else if (entry.entry_type === 'Archive') {
|
||||
const innerPath = currentLoc.archiveSubdir + entry.name;
|
||||
const targetLoc: Location = { backendPath: `${currentLoc.backendPath}|${innerPath}`, archiveSubdir: '' };
|
||||
if (entry.msg_tool_type === 'KirikiriXp3') {
|
||||
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
|
||||
setXp3DialogOpen(true);
|
||||
} else {
|
||||
navigateTo(targetLoc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (entry.entry_type === 'Folder') {
|
||||
navigateTo({ backendPath: joinFsPath(currentLoc.backendPath, entry.name) });
|
||||
} else if (entry.entry_type === 'Archive') {
|
||||
const targetLoc: Location = { backendPath: joinFsPath(currentLoc.backendPath, entry.name), archiveSubdir: '' };
|
||||
if (entry.msg_tool_type === 'KirikiriXp3') {
|
||||
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
|
||||
setXp3DialogOpen(true);
|
||||
} else {
|
||||
navigateTo(targetLoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentLoc, navigateTo]);
|
||||
|
||||
const handleXp3Confirm = useCallback((options: FileOptions | null) => {
|
||||
setXp3DialogOpen(false);
|
||||
const pending = pendingNavRef.current;
|
||||
pendingNavRef.current = null;
|
||||
if (!pending) return;
|
||||
|
||||
if (options) {
|
||||
const { target, currentOptions } = pending;
|
||||
// Index of the new archive in the pipe-separated backendPath (0-based)
|
||||
const idx = target.backendPath.split('|').length - 1;
|
||||
const opts: FileOptions[] = [...(currentOptions ?? [])];
|
||||
while (opts.length < idx) opts.push({});
|
||||
opts[idx] = options;
|
||||
navigateTo({ ...target, options: opts });
|
||||
} else {
|
||||
navigateTo(pending.target);
|
||||
}
|
||||
}, [navigateTo]);
|
||||
|
||||
const handleXp3Cancel = useCallback(() => {
|
||||
setXp3DialogOpen(false);
|
||||
pendingNavRef.current = null;
|
||||
}, []);
|
||||
|
||||
const canGoBack = nav.index > 0;
|
||||
const canGoForward = nav.index < nav.history.length - 1;
|
||||
const canGoUp = getParentLocation(currentLoc) !== null;
|
||||
|
||||
const columns: ColumnsType<Entry> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (name: string, record) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{getEntryIcon(record.entry_type)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entry_type',
|
||||
key: 'entry_type',
|
||||
width: 60,
|
||||
render: (type: EntryType) => (
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{getTypeLabel(type)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 75,
|
||||
align: 'right' as const,
|
||||
render: (size?: number) => (
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{formatSize(size)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="file-explorer">
|
||||
<Xp3OptionsDialog
|
||||
open={xp3DialogOpen}
|
||||
onConfirm={handleXp3Confirm}
|
||||
onCancel={handleXp3Cancel}
|
||||
/>
|
||||
<div className="file-explorer__toolbar">
|
||||
<Tooltip title="后退">
|
||||
<Button size="small" type="text" icon={<ArrowLeftOutlined />}
|
||||
disabled={!canGoBack} onClick={goBack} />
|
||||
</Tooltip>
|
||||
<Tooltip title="前进">
|
||||
<Button size="small" type="text" icon={<ArrowRightOutlined />}
|
||||
disabled={!canGoForward} onClick={goForward} />
|
||||
</Tooltip>
|
||||
<Tooltip title="上一级">
|
||||
<Button size="small" type="text" icon={<ArrowUpOutlined />}
|
||||
disabled={!canGoUp} onClick={goUp} />
|
||||
</Tooltip>
|
||||
<Input
|
||||
size="small"
|
||||
value={pathInput}
|
||||
onChange={e => setPathInput(e.target.value)}
|
||||
onPressEnter={handlePathSubmit}
|
||||
onBlur={handlePathSubmit}
|
||||
style={{ flex: 1, fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
<Table<Entry>
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="name"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 80px)' }}
|
||||
rowClassName={record => record.name === selectedKey ? 'file-explorer__row--selected' : ''}
|
||||
onRow={record => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
onDoubleClick: () => handleRowDoubleClick(record),
|
||||
style: { cursor: 'default' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/components/ImagePreviewPanel.tsx
Normal file
308
src/components/ImagePreviewPanel.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Spin, Button, Tooltip, Space } from 'antd';
|
||||
import { CompressOutlined, BorderOuterOutlined } from '@ant-design/icons';
|
||||
import { FileOptions } from '../types';
|
||||
import { previewImage } from '../api';
|
||||
|
||||
interface ImagePreviewPanelProps {
|
||||
path: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
type ScaleMode = 'fit' | '100%' | 'fill' | 'custom';
|
||||
|
||||
export function ImagePreviewPanel({ path, options }: ImagePreviewPanelProps) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [imageSize, setImageSize] = useState<{ w: number, h: number } | null>(null);
|
||||
const [containerSize, setContainerSize] = useState<{ w: number, h: number } | null>(null);
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>('fit');
|
||||
const [customScale, setCustomScale] = useState<number>(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const prevUrlRef = useRef<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const dragStart = useRef({ mouseX: 0, mouseY: 0, imgX: 0, imgY: 0 });
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setObjectUrl(null);
|
||||
setImageSize(null);
|
||||
setScaleMode('fit');
|
||||
setCustomScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
|
||||
previewImage(path, options)
|
||||
.then(bytes => {
|
||||
if (cancelled) return;
|
||||
const blob = new Blob([bytes as any], { type: 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
|
||||
prevUrlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg = (err as { msg?: string })?.msg ?? String(err);
|
||||
setError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [path, options]);
|
||||
|
||||
// Cleanup object URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerSize({
|
||||
w: entry.contentRect.width,
|
||||
h: entry.contentRect.height
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [objectUrl]);
|
||||
|
||||
// Drag logic
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
setPosition({
|
||||
x: dragStart.current.imgX + (e.clientX - dragStart.current.mouseX),
|
||||
y: dragStart.current.imgY + (e.clientY - dragStart.current.mouseY),
|
||||
});
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
setImageSize({ w: target.naturalWidth, h: target.naturalHeight });
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
dragStart.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
imgX: position.x,
|
||||
imgY: position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const getScale = (
|
||||
mode = scaleMode,
|
||||
cScale = customScale,
|
||||
iSize = imageSize,
|
||||
cSize = containerSize
|
||||
) => {
|
||||
if (!iSize || !cSize) return 1;
|
||||
const { w, h } = iSize;
|
||||
const { w: cw, h: ch } = cSize;
|
||||
if (w === 0 || h === 0 || cw === 0 || ch === 0) return 1;
|
||||
|
||||
const rw = cw / w;
|
||||
const rh = ch / h;
|
||||
|
||||
if (mode === 'fit') return Math.min(1, rw, rh); // 只缩小不放大
|
||||
if (mode === 'fill') return Math.max(rw, rh); // 可以放大超过100%
|
||||
if (mode === '100%') return 1;
|
||||
return cScale;
|
||||
};
|
||||
|
||||
const currentScale = getScale();
|
||||
|
||||
const stateRef = useRef({ imageSize, containerSize, scaleMode, customScale, position });
|
||||
stateRef.current = { imageSize, containerSize, scaleMode, customScale, position };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const state = stateRef.current;
|
||||
const scaleStr = getScale(state.scaleMode, state.customScale, state.imageSize, state.containerSize);
|
||||
|
||||
const scaleFactor = 1.15;
|
||||
const delta = e.deltaY < 0 ? 1 : -1;
|
||||
let newScale = scaleStr * (delta > 0 ? scaleFactor : 1 / scaleFactor);
|
||||
newScale = Math.max(0.05, Math.min(newScale, 100)); // 缩放范围限制 5% 到 10000%
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const dx = mouseX - cx;
|
||||
const dy = mouseY - cy;
|
||||
|
||||
const scaleRatio = newScale / scaleStr;
|
||||
|
||||
setPosition({
|
||||
x: dx - (dx - state.position.x) * scaleRatio,
|
||||
y: dy - (dy - state.position.y) * scaleRatio,
|
||||
});
|
||||
setCustomScale(newScale);
|
||||
setScaleMode('custom');
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [objectUrl]);
|
||||
|
||||
const resetPositionAndSetMode = (mode: ScaleMode) => {
|
||||
setScaleMode(mode);
|
||||
setPosition({ x: 0, y: 0 }); // Switch mode resets the drag position to center
|
||||
};
|
||||
|
||||
if (loading && !objectUrl) {
|
||||
return (
|
||||
<div style={centerStyle}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={centerStyle}>
|
||||
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!objectUrl) return null;
|
||||
|
||||
return (
|
||||
<div style={containerStyle} ref={containerRef}>
|
||||
<img
|
||||
src={objectUrl!}
|
||||
alt={path.split('|').pop()?.split(/[/\\]/).pop() ?? ''}
|
||||
style={{
|
||||
...imgStyle,
|
||||
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${currentScale})`,
|
||||
cursor: 'grab',
|
||||
}}
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={handleMouseDown}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={toolbarContainerStyle}>
|
||||
<Space size={8} style={{ padding: '6px 12px', background: 'rgba(255, 255, 255, 0.85)', backdropFilter: 'blur(8px)', borderRadius: 6, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
|
||||
<Tooltip title="适应窗口 (默认只缩小不放大)">
|
||||
<Button
|
||||
type={scaleMode === 'fit' ? 'primary' : 'text'}
|
||||
icon={<CompressOutlined />}
|
||||
onClick={() => resetPositionAndSetMode('fit')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="100% 大小">
|
||||
<Button
|
||||
type={scaleMode === '100%' ? 'primary' : 'text'}
|
||||
icon={<span style={{ fontSize: 12, fontWeight: 'bold' }}>1:1</span>}
|
||||
onClick={() => resetPositionAndSetMode('100%')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="填充窗口">
|
||||
<Button
|
||||
type={scaleMode === 'fill' ? 'primary' : 'text'}
|
||||
icon={<BorderOuterOutlined />}
|
||||
onClick={() => resetPositionAndSetMode('fill')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Info Status */}
|
||||
<div style={infoContainerStyle}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.7)', fontWeight: 'bold', fontFamily: 'monospace' }}>
|
||||
{Math.round(currentScale * 100)}%
|
||||
{imageSize && ` | ${imageSize.w} x ${imageSize.h}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const imgStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center',
|
||||
imageRendering: 'pixelated',
|
||||
borderRadius: 0,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
const toolbarContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
const infoContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
};
|
||||
92
src/components/Xp3OptionsDialog.tsx
Normal file
92
src/components/Xp3OptionsDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Select, Switch, Form } from 'antd';
|
||||
import { GameTitle, FileOptions } from '../types';
|
||||
import { getXp3SupportedGames } from '../api';
|
||||
|
||||
interface Xp3OptionsDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: (options: FileOptions | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function Xp3OptionsDialog({ open, onConfirm, onCancel }: Xp3OptionsDialogProps) {
|
||||
const [games, setGames] = useState<GameTitle[]>([]);
|
||||
const [gamesLoading, setGamesLoading] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [gameTitle, setGameTitle] = useState<string | undefined>(undefined);
|
||||
const [forceDecrypt, setForceDecrypt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEnabled(false);
|
||||
setGameTitle(undefined);
|
||||
setForceDecrypt(false);
|
||||
if (games.length === 0) {
|
||||
setGamesLoading(true);
|
||||
getXp3SupportedGames().then(g => {
|
||||
setGames(g);
|
||||
setGamesLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (!enabled) {
|
||||
onConfirm(null);
|
||||
} else {
|
||||
onConfirm({
|
||||
xp3: {
|
||||
game_title: gameTitle,
|
||||
force_decrypt: forceDecrypt,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const gameOptions = games.map(g => {
|
||||
const label = g.alias ? `${g.name} (${g.alias.join(' / ')})` : g.name;
|
||||
return { value: g.name, label };
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="XP3 解密选项"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={420}
|
||||
>
|
||||
<Form layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item label="启用解密">
|
||||
<Switch checked={enabled} onChange={v => setEnabled(v)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="游戏">
|
||||
<Select
|
||||
disabled={!enabled}
|
||||
value={gameTitle}
|
||||
onChange={setGameTitle}
|
||||
options={gameOptions}
|
||||
placeholder="选择游戏"
|
||||
allowClear
|
||||
showSearch
|
||||
loading={gamesLoading}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="强制解密">
|
||||
<Switch
|
||||
disabled={!enabled}
|
||||
checked={enabled && forceDecrypt}
|
||||
onChange={v => setForceDecrypt(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "antd/dist/reset.css";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
28
src/types.ts
Normal file
28
src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface GameTitle {
|
||||
name: string;
|
||||
alias?: string[];
|
||||
}
|
||||
|
||||
export type EntryType = 'Archive' | 'Text' | 'Image' | 'Audio' | 'Folder' | 'Unknown';
|
||||
|
||||
export interface Entry {
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
entry_type: EntryType;
|
||||
msg_tool_type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FileOptions {
|
||||
xp3?: {
|
||||
game_title?: string;
|
||||
force_decrypt: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ErrorType = 'NotFound' | 'Other';
|
||||
|
||||
export interface ErrorMsg {
|
||||
typ: ErrorType;
|
||||
msg: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user