feat:脚本文件预览

This commit is contained in:
xiaozhu
2026-08-31 18:48:19 +08:00
parent 4ff0f344af
commit 690de643ad
12 changed files with 1138 additions and 65 deletions
+3
View File
@@ -9,3 +9,6 @@
# monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时
# 从 node_modules/monaco-editor/min/vs 复制生成,不要提交)
/public/monaco/vs/
# @file-viewer/vite-plugin copyAssets 生成的 office 预览 vendor,不要提交
/public/file-viewer/
+4
View File
@@ -238,6 +238,10 @@ export function useApi(): WorkspaceBoundApi {
rawApi.bindResourceUpload(workspaceId, uploadId, body),
deleteResource: (resourceId) =>
rawApi.deleteResource(workspaceId, resourceId),
fetchResourceContentFile: (resourceId, fileName, signal) =>
rawApi.fetchResourceContentFile(workspaceId, resourceId, fileName, signal),
fetchResourcePreview: (resourceId, input, signal) =>
rawApi.fetchResourcePreview(workspaceId, resourceId, input, signal),
updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
@@ -0,0 +1,327 @@
import { useEffect, useState } from "react";
import FileViewer from "@file-viewer/react";
import officePreset from "@file-viewer/preset-office";
import Editor from "@monaco-editor/react";
import { X } from "lucide-react";
import { ApiRequestError, type ResourcePreviewPayload } from "~/services/api";
import { useApi, useAuth } from "~/context/AuthContext";
import {
EXCEL_PREVIEW_MAX_BYTES,
TEXT_PREVIEW_MAX_BYTES,
monacoLanguageFromFileName,
resourceDisplayName,
type DataResourcePreviewTarget,
} from "./dataResourcePreview";
const EYEBROW: Record<DataResourcePreviewTarget["kind"], string> = {
excel: "EXCEL PREVIEW",
text: "TEXT PREVIEW",
table: "TABLE PREVIEW",
};
export function DataResourcePreviewDialog({
target,
onClose,
}: {
target: DataResourcePreviewTarget | null;
onClose: () => void;
}) {
const title = target
? resourceDisplayName(target.resourceName, target.fileExtension)
: "";
if (!target) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/35 p-4"
role="dialog"
aria-modal="true"
aria-label={`预览 ${title}`}
onClick={onClose}
>
<div
className="flex h-[min(90vh,880px)] w-[min(96vw,1100px)] flex-col overflow-hidden rounded-[11px] border border-[#dce4eb] bg-white shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-between border-b border-[#edf1f5] px-4 py-3">
<div className="min-w-0">
<div className="text-[9px] font-extrabold tracking-[0.12em] text-[#2d82d4]">
{EYEBROW[target.kind]}
</div>
<h3 className="mt-0.5 truncate text-[16px] font-medium text-[#1c2d42]">
{title}
</h3>
</div>
<button
type="button"
className="icon-button grid size-8 place-items-center rounded-md text-[#66788a] hover:bg-[#f3f6f9]"
onClick={onClose}
aria-label="关闭预览"
>
<X size={18} />
</button>
</div>
<div className="relative min-h-0 flex-1 bg-[#f7f9fb]">
{target.kind === "excel" && (
<ExcelPreviewBody target={target} title={title} />
)}
{target.kind === "text" && (
<TextPreviewBody target={target} title={title} />
)}
{target.kind === "table" && <TablePreviewBody target={target} />}
</div>
</div>
</div>
);
}
function StatusOverlay({
loading,
error,
}: {
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<p className="absolute inset-0 grid place-items-center text-[13px] text-[#7a8b9c]">
</p>
);
}
if (error) {
return (
<p className="absolute inset-0 grid place-items-center px-6 text-center text-[13px] text-[#c74848]">
{error}
</p>
);
}
return null;
}
function ExcelPreviewBody({
target,
title,
}: {
target: DataResourcePreviewTarget;
title: string;
}) {
const api = useApi();
const { currentWorkspace } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!currentWorkspace?.workspace_id) return;
if (target.sizeBytes > EXCEL_PREVIEW_MAX_BYTES) {
setFile(null);
setError(
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持在线预览`,
);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
setFile(null);
void api
.fetchResourceContentFile(target.resourceId, title, controller.signal)
.then((loaded) => {
if (controller.signal.aborted) return;
setFile(loaded);
setLoading(false);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setFile(null);
setLoading(false);
setError(errorMessage(err));
});
return () => controller.abort();
}, [api, currentWorkspace?.workspace_id, target, title]);
return (
<>
<StatusOverlay loading={loading} error={error} />
{!loading && !error && file && (
<FileViewer
className="h-full w-full"
file={file}
filename={title}
options={{
preset: officePreset,
theme: "light",
toolbar: { position: "bottom-right" },
}}
/>
)}
</>
);
}
function TextPreviewBody({
target,
title,
}: {
target: DataResourcePreviewTarget;
title: string;
}) {
const api = useApi();
const { currentWorkspace } = useAuth();
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!currentWorkspace?.workspace_id) return;
if (target.sizeBytes > TEXT_PREVIEW_MAX_BYTES) {
setContent(null);
setError(
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持文本预览`,
);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
setContent(null);
void api
.fetchResourceContentFile(target.resourceId, title, controller.signal)
.then(async (loaded) => {
if (controller.signal.aborted) return;
const text = await loaded.text();
if (controller.signal.aborted) return;
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setContent(null);
setLoading(false);
setError(errorMessage(err));
});
return () => controller.abort();
}, [api, currentWorkspace?.workspace_id, target, title]);
return (
<>
<StatusOverlay loading={loading} error={error} />
{!loading && !error && content !== null && (
<Editor
height="100%"
language={monacoLanguageFromFileName(title)}
theme="vs"
value={content}
options={{
readOnly: true,
minimap: { enabled: content.length > 5000 },
wordWrap: "on",
fontSize: 13,
automaticLayout: true,
renderLineHighlight: "gutter",
contextmenu: false,
scrollBeyondLastLine: false,
}}
/>
)}
</>
);
}
function TablePreviewBody({ target }: { target: DataResourcePreviewTarget }) {
const api = useApi();
const { currentWorkspace } = useAuth();
const [payload, setPayload] = useState<ResourcePreviewPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!currentWorkspace?.workspace_id) return;
const controller = new AbortController();
setLoading(true);
setError(null);
setPayload(null);
void api
.fetchResourcePreview(target.resourceId, { limit: 100 }, controller.signal)
.then((data) => {
if (controller.signal.aborted) return;
setPayload(data);
setLoading(false);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setPayload(null);
setLoading(false);
setError(errorMessage(err));
});
return () => controller.abort();
}, [api, currentWorkspace?.workspace_id, target.resourceId]);
return (
<>
<StatusOverlay loading={loading} error={error} />
{!loading && !error && payload && (
<div className="flex h-full min-h-0 flex-col">
<p className="shrink-0 border-b border-[#edf1f5] bg-white px-4 py-2 text-[11px] text-[#7a8b9c]">
{payload.truncated
? `仅预览前 ${payload.row_count} 行(已截断)`
: `${payload.row_count}`}
{payload.delimiter === "\t" ? " · TSV" : " · CSV"}
</p>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-max min-w-full border-collapse text-left text-[12px] text-[#34475d]">
<thead className="sticky top-0 bg-[#f5f8fb]">
<tr>
<th className="border-b border-[#edf1f5] px-3 py-2 font-semibold text-[#8a9aab]">
#
</th>
{payload.columns.map((column) => (
<th
key={column}
className="border-b border-[#edf1f5] px-3 py-2 font-semibold whitespace-nowrap"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{payload.rows.map((row, index) => (
<tr key={index} className="odd:bg-white even:bg-[#fbfcfd]">
<td className="border-b border-[#f0f3f6] px-3 py-1.5 text-[#9ba8b7]">
{index + 1}
</td>
{payload.columns.map((_, colIndex) => (
<td
key={colIndex}
className="border-b border-[#f0f3f6] px-3 py-1.5 whitespace-nowrap"
>
{row[colIndex] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
{payload.rows.length === 0 && (
<p className="px-4 py-8 text-center text-[13px] text-[#8a9aab]">
</p>
)}
</div>
</div>
)}
</>
);
}
function errorMessage(err: unknown): string {
if (err instanceof ApiRequestError) return err.message;
if (err instanceof Error) return err.message;
return "加载预览失败";
}
@@ -29,6 +29,7 @@ type ScriptExplorerProps = {
) => void;
onSelect: (scriptId: string) => void;
onCopyResourcePath: (jupyterPath: string) => void;
onPreviewResource: (script: ScriptItem) => void;
uploadInputRef: RefObject<HTMLInputElement | null>;
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
};
@@ -53,6 +54,7 @@ export function ScriptExplorer({
onContextMenu,
onSelect,
onCopyResourcePath,
onPreviewResource,
uploadInputRef,
onHandleUpload,
}: ScriptExplorerProps) {
@@ -251,6 +253,7 @@ export function ScriptExplorer({
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
onPreviewResource={onPreviewResource}
/>
);
})}
+30 -1
View File
@@ -1,9 +1,15 @@
import { type FormEvent, useEffect, useMemo, useRef } from "react";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../context/AuthContext";
import type { ScriptItem } from "../../services/api";
import { CreateFolderModal } from "./CreateFolderModal";
import { CreateScriptModal } from "./CreateScriptModal";
import { DataResourcePreviewDialog } from "./DataResourcePreviewDialog";
import { DataResourceUploadModal } from "./DataResourceUploadModal";
import {
previewKindFromFileName,
type DataResourcePreviewTarget,
} from "./dataResourcePreview";
import { WelcomePanel } from "./WelcomePanel";
import { PublishModal } from "./PublishModal";
import { ScriptExplorer } from "./ScriptExplorer";
@@ -19,6 +25,8 @@ import { copyToClipboard } from "../../lib/clipboard";
export default function ScriptsPage() {
const { currentWorkspace, user } = useAuth();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [resourcePreview, setResourcePreview] =
useState<DataResourcePreviewTarget | null>(null);
// store state
const scripts = useScriptWorkspaceStore((s) => s.scripts);
@@ -276,6 +284,20 @@ export default function ScriptsPage() {
}
};
const openResourcePreview = (script: ScriptItem): void => {
const kind = previewKindFromFileName(script.script_name);
if (!kind) return;
const resourceId = script.script_id.replace(/^data:/, "");
const resource = dataResources.find((item) => item.resource_id === resourceId);
setResourcePreview({
resourceId,
resourceName: resource?.resource_name ?? script.script_name,
fileExtension: resource?.file.file_extension ?? null,
sizeBytes: resource?.file.size_bytes ?? script.size_bytes,
kind,
});
};
const handleCreateSubmit = (event: FormEvent) => {
event.preventDefault();
void createScript(createDialog.form);
@@ -319,6 +341,7 @@ export default function ScriptsPage() {
onContextMenu={showContextMenu}
onSelect={openTab}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
onPreviewResource={openResourcePreview}
uploadInputRef={uploadInputRef}
onHandleUpload={handleUpload}
/>
@@ -416,10 +439,16 @@ export default function ScriptsPage() {
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
onRemoveDirectory={(p) => void deleteDirectory(p)}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
onPreviewResource={openResourcePreview}
onRemoveResource={(id) => void deleteDataResource(id)}
onClose={closeContextMenu}
/>
<DataResourcePreviewDialog
target={resourcePreview}
onClose={() => setResourcePreview(null)}
/>
<PublishModal
publishTarget={publish.target}
releaseNote={publish.releaseNote}
@@ -1,6 +1,7 @@
import {
BookOpen,
Database,
Eye,
FileCode,
FileText,
Folder,
@@ -10,6 +11,7 @@ import {
X,
} from "lucide-react";
import type { ScriptItem, ScriptType } from "../../services/api";
import { canPreviewDataResource } from "./dataResourcePreview";
type ContextMenuState = {
x: number;
@@ -29,6 +31,7 @@ type TreeContextMenuProps = {
onChooseUpload: (parentPath: string) => void;
onRemoveDirectory: (path: string) => void;
onCopyResourcePath?: (jupyterPath: string) => void;
onPreviewResource?: (script: ScriptItem) => void;
onRemoveResource?: (resourceId: string) => void;
onClose: () => void;
};
@@ -49,12 +52,15 @@ export function TreeContextMenu({
onChooseUpload,
onRemoveDirectory,
onCopyResourcePath,
onPreviewResource,
onRemoveResource,
onClose,
}: TreeContextMenuProps) {
if (!contextMenu) return null;
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
const canPreview =
isDataResource && canPreviewDataResource(contextMenu.script?.script_name);
const LockToggleIcon = contextMenu.script?.is_locked ? Unlock : Lock;
return (
@@ -71,6 +77,20 @@ export function TreeContextMenu({
<>
{isDataResource ? (
<>
{canPreview && onPreviewResource && (
<button
type="button"
role="menuitem"
className={menuItemClass}
onClick={() => {
onPreviewResource(contextMenu.script!);
onClose();
}}
>
<Eye size={16} />
</button>
)}
<button
type="button"
role="menuitem"
@@ -19,6 +19,7 @@ import type {
WorkspaceDirectory,
ResourceItem,
} from "~/services/api";
import { canPreviewDataResource } from "./dataResourcePreview";
export type WorkspaceTreeTarget = {
kind: "root" | "directory" | "file";
@@ -39,6 +40,7 @@ type WorkspaceTreeProps = {
readOnly?: boolean;
dataResources?: ResourceItem[];
onCopyResourcePath?: (jupyterPath: string) => void;
onPreviewResource?: (script: ScriptItem) => void;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string;
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
@@ -129,6 +131,7 @@ export function WorkspaceTreeGroup({
readOnly = false,
dataResources,
onCopyResourcePath,
onPreviewResource,
groupKey,
ownerUserId,
expandedPaths,
@@ -242,6 +245,7 @@ export function WorkspaceTreeGroup({
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
onPreviewResource={onPreviewResource}
/>
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
@@ -265,6 +269,7 @@ function WorkspaceTreeItems({
onSelect,
onContextMenu,
onCopyResourcePath,
onPreviewResource,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -293,6 +298,7 @@ function WorkspaceTreeItems({
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
onPreviewResource={onPreviewResource}
/>
))}
{childScripts.map((item) => {
@@ -313,8 +319,12 @@ function WorkspaceTreeItems({
key={item.script_id}
type="button"
onClick={() => {
if (isData && onCopyResourcePath) {
onCopyResourcePath(item.relative_path);
if (isData) {
if (canPreviewDataResource(item.script_name) && onPreviewResource) {
onPreviewResource(item);
} else if (onCopyResourcePath) {
onCopyResourcePath(item.relative_path);
}
} else {
onSelect(item.script_id);
}
@@ -370,6 +380,7 @@ function DirectoryBranch({
onSelect,
onContextMenu,
onCopyResourcePath,
onPreviewResource,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -428,6 +439,7 @@ function DirectoryBranch({
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
onPreviewResource={onPreviewResource}
/>
)}
</div>
@@ -0,0 +1,77 @@
/** 数据资源预览:扩展名分流与展示名。 */
export type DataResourcePreviewKind = "excel" | "text" | "table";
export const EXCEL_EXTENSIONS = [".xlsx", ".xls"] as const;
export const TEXT_EXTENSIONS = [".txt", ".json"] as const;
export const TABLE_EXTENSIONS = [".csv", ".tsv"] as const;
/** Excel 整文件拉取上限。 */
export const EXCEL_PREVIEW_MAX_BYTES = 80 * 1024 * 1024;
/** 文本预览拉取上限。 */
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
export type DataResourcePreviewTarget = {
resourceId: string;
resourceName: string;
fileExtension: string | null;
sizeBytes: number;
kind: DataResourcePreviewKind;
};
function lowerName(name: string | null | undefined): string {
return (name ?? "").toLowerCase();
}
function matchesExt(name: string, exts: readonly string[]): boolean {
return exts.some((ext) => name.endsWith(ext));
}
export function isExcelFileName(name: string | null | undefined): boolean {
return matchesExt(lowerName(name), EXCEL_EXTENSIONS);
}
export function isTextPreviewFileName(name: string | null | undefined): boolean {
return matchesExt(lowerName(name), TEXT_EXTENSIONS);
}
export function isTablePreviewFileName(name: string | null | undefined): boolean {
return matchesExt(lowerName(name), TABLE_EXTENSIONS);
}
export function previewKindFromFileName(
name: string | null | undefined,
): DataResourcePreviewKind | null {
const lower = lowerName(name);
if (matchesExt(lower, EXCEL_EXTENSIONS)) return "excel";
if (matchesExt(lower, TEXT_EXTENSIONS)) return "text";
if (matchesExt(lower, TABLE_EXTENSIONS)) return "table";
return null;
}
export function canPreviewDataResource(name: string | null | undefined): boolean {
return previewKindFromFileName(name) !== null;
}
export function resourceDisplayName(
resourceName: string,
fileExtension: string | null | undefined,
): string {
if (!fileExtension) return resourceName;
const ext = fileExtension.startsWith(".")
? fileExtension
: `.${fileExtension}`;
if (resourceName.toLowerCase().endsWith(ext.toLowerCase())) {
return resourceName;
}
return `${resourceName}${ext}`;
}
/** @deprecated use resourceDisplayName */
export const excelDisplayName = resourceDisplayName;
export function monacoLanguageFromFileName(name: string): string {
const lower = lowerName(name);
if (lower.endsWith(".json")) return "json";
return "plaintext";
}
+117 -4
View File
@@ -556,6 +556,109 @@ export async function deleteResource(
);
}
/** 同源流式下载数据资源字节,供 Excel 等预览器使用。 */
export async function fetchResourceContentFile(
workspaceId: string,
resourceId: string,
fileName: string,
signal?: AbortSignal,
): Promise<File> {
const response = await fetch(
`/api/v1/data-resources/${encodeURIComponent(resourceId)}/content?workspace_id=${encodeURIComponent(workspaceId)}`,
{
credentials: "same-origin",
signal,
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
},
},
);
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
if (!response.ok) {
let message = `请求失败(HTTP ${response.status}`;
try {
const payload = (await response.json()) as ApiErrorEnvelope;
const detailMessage =
typeof payload.detail === "string"
? payload.detail
: payload.detail?.message;
if (detailMessage) message = detailMessage;
} catch {
/* ignore non-JSON error bodies */
}
throw new ApiRequestError(message, response.status);
}
const blob = await response.blob();
return new File([blob], fileName, {
type: blob.type || "application/octet-stream",
});
}
export type ResourcePreviewPayload = {
kind: "table";
columns: string[];
rows: string[][];
row_count: number;
truncated: boolean;
delimiter: string;
};
/** 表格类数据资源抽样预览(csv / tsv)。 */
export async function fetchResourcePreview(
workspaceId: string,
resourceId: string,
input: { limit?: number } = {},
signal?: AbortSignal,
): Promise<ResourcePreviewPayload> {
const parameters = new URLSearchParams();
parameters.set("workspace_id", workspaceId);
if (input.limit != null) parameters.set("limit", String(input.limit));
const response = await fetch(
`/api/v1/data-resources/${encodeURIComponent(resourceId)}/preview?${parameters.toString()}`,
{
credentials: "same-origin",
signal,
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
},
},
);
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = await response.json();
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage =
typeof error.detail === "string" ? error.detail : error.detail?.message;
throw new ApiRequestError(
detailMessage ?? `请求失败(HTTP ${response.status}`,
response.status,
);
}
const data = (payload as { data: ResourcePreviewPayload }).data;
if (!data || data.kind !== "table" || !Array.isArray(data.columns)) {
throw new ApiRequestError("响应数据格式错误", response.status);
}
return data;
}
export async function createResourceUpload(
workspaceId: string,
body: {
@@ -1667,10 +1770,20 @@ export type WorkspaceBoundApi = {
uploadId: string,
body: Parameters<typeof bindResourceUpload>[2],
) => Promise<ResourceItem>;
deleteResource: (
resourceId: string,
) => Promise<{ resource_id: string; status: string }>;
updateScript: (
deleteResource: (
resourceId: string,
) => Promise<{ resource_id: string; status: string }>;
fetchResourceContentFile: (
resourceId: string,
fileName: string,
signal?: AbortSignal,
) => Promise<File>;
fetchResourcePreview: (
resourceId: string,
input?: { limit?: number },
signal?: AbortSignal,
) => Promise<ResourcePreviewPayload>;
updateScript: (
scriptId: string,
input: Parameters<typeof updateScript>[2],
) => Promise<ScriptItem>;
+3
View File
@@ -13,6 +13,8 @@
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@file-viewer/preset-office": "^3.0.0",
"@file-viewer/react": "^3.0.0",
"@fontsource-variable/inter": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@react-router/node": "^8",
@@ -34,6 +36,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@file-viewer/vite-plugin": "^3.0.0",
"@react-router/dev": "^8",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22",
+531 -57
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,11 +1,19 @@
import { reactRouter } from "@react-router/dev/vite";
import { fileViewerRenderers } from "@file-viewer/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
base: '/',
plugins: [reactRouter(), tailwindcss()],
plugins: [
reactRouter(),
tailwindcss(),
fileViewerRenderers({
preset: "office",
copyAssets: { mode: "both", baseDir: "file-viewer" },
}),
],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),