Files
model-platform/frontend/app/features/platform/DataResourcesPage.tsx
T
2026-08-13 20:13:37 +08:00

340 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import { useUiStore } from "./state/uiStore";
import {
type ResourceItem,
listResources,
createResourceUpload,
uploadResourceBytes,
bindResourceUpload,
} from "../../services/api";
import "./DataResourcesPage.css";
const ACCEPTED_TYPES = ".csv,.xlsx,.xls,.tsv,.json,.parquet,.txt";
const VISIBILITY_LABELS: Record<ResourceItem["visibility"], string> = {
private: "私有",
workspace: "工作区",
public: "公开",
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, index);
return `${value.toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
}
async function sha256Hex(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export default function DataResourcesPage() {
const { currentWorkspace, user } = useAuth();
const workspaceId = currentWorkspace?.workspace_id ?? "";
const ownerUserId = user?.user_id ?? "";
const pushToast = useUiStore((state) => state.pushToast);
const [items, setItems] = useState<ResourceItem[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [resourceName, setResourceName] = useState("");
const [visibility, setVisibility] = useState<ResourceItem["visibility"]>("workspace");
const [description, setDescription] = useState("");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = async () => {
if (!workspaceId) return;
setLoading(true);
try {
const data = await listResources(workspaceId, { keyword });
// Defensive: apiRequest<T> returns T, but treat non-array payloads
// as empty so a malformed response never crashes the render.
setItems(Array.isArray(data) ? data : []);
} catch (error) {
pushToast({
tone: "error",
message: error instanceof Error ? error.message : "加载数据资源失败",
});
setItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [workspaceId, keyword]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0] ?? null;
setFile(selected);
if (selected && !resourceName.trim()) {
const baseName = selected.name.replace(/\.[^/.]+$/, "");
setResourceName(baseName);
}
};
const resetForm = () => {
setFile(null);
setResourceName("");
setVisibility("workspace");
setDescription("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!file || !workspaceId) return;
const name = resourceName.trim() || file.name;
setUploading(true);
try {
const expectedHash = await sha256Hex(file);
const { upload_id: uploadId } = await createResourceUpload(workspaceId, {
file_name: file.name,
content_type: file.type || "application/octet-stream",
expected_size_bytes: file.size,
expected_hash: expectedHash,
});
await uploadResourceBytes(
workspaceId,
uploadId,
await file.arrayBuffer(),
file.type || "application/octet-stream",
);
await bindResourceUpload(workspaceId, uploadId, {
resource_name: name,
description: description.trim(),
visibility,
});
pushToast({ tone: "success", message: "数据资源上传成功" });
setModalOpen(false);
resetForm();
await load();
} catch (error) {
pushToast({
tone: "error",
message: error instanceof Error ? error.message : "上传失败",
});
} finally {
setUploading(false);
}
};
// 把 {workspace_id}/{user_id}/ 前缀从路径里去掉,方便用户粘贴到 notebook。
// 后端目前返回的 jupyter_accessible_path 偶尔会带这层前缀(legacy 数据 +
// workspace_prefix 配错),所以前端再做一次防御性剥除。
const stripOwnerPrefix = (path: string): string => {
if (!path) return path;
const fullPrefix = `${workspaceId}/${ownerUserId}/`;
if (fullPrefix !== "/" && path.startsWith(fullPrefix)) {
return path.slice(fullPrefix.length);
}
const ownerPrefix = `${ownerUserId}/`;
if (ownerPrefix !== "/" && path.startsWith(ownerPrefix)) {
return path.slice(ownerPrefix.length);
}
return path;
};
const handleCopy = async (path: string) => {
try {
const stripped = stripOwnerPrefix(path);
await navigator.clipboard.writeText(stripped);
pushToast({ tone: "success", message: "已复制 Jupyter 路径" });
} catch {
pushToast({ tone: "error", message: "复制失败" });
}
};
return (
<section className="data-resources-page">
<header className="data-resources-page__header">
<div>
<h1>数据资源</h1>
<p>管理可在 Jupyter 中读取的 CSV/Excel/JSON/Parquet 文件</p>
</div>
<button
className="primary-button"
type="button"
onClick={() => setModalOpen(true)}
>
<Icon name="upload" size={17} />
上传数据资源
</button>
</header>
<div className="data-resources-page__toolbar">
<div className="search-box">
<Icon name="search" size={16} />
<input
type="text"
placeholder="搜索资源名称或文件名"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</div>
</div>
<div className="data-resources-page__table-wrap">
{loading && items.length === 0 ? (
<div className="data-resources-page__empty">加载中…</div>
) : items.length === 0 ? (
<div className="data-resources-page__empty">
暂无数据资源,点击右上角上传。
</div>
) : (
<table className="data-resources-table">
<thead>
<tr>
<th>资源名称</th>
<th>文件名</th>
<th>大小</th>
<th>可见性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.resource_id}>
<td className="data-resources-table__name">
{item.resource_name}
</td>
<td>{item.file.file_name}</td>
<td>{formatBytes(item.file.size_bytes)}</td>
<td>
<span
className={`visibility-badge visibility-badge--${item.visibility}`}
>
{VISIBILITY_LABELS[item.visibility]}
</span>
</td>
<td>
<div className="row-actions">
<button
className="text-button"
type="button"
onClick={() => handleCopy(item.jupyter_accessible_path)}
>
复制
</button>
<button
className="text-button"
type="button"
onClick={() => handleCopy(item.absolute_path)}
disabled={!item.absolute_path}
title={item.absolute_path || "绝对路径不可用"}
>
复制绝对路径
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modalOpen && (
<div
className="modal-backdrop"
onClick={() => {
if (!uploading) setModalOpen(false);
}}
>
<div
className="modal modal--compact"
onClick={(event) => event.stopPropagation()}
>
<div className="modal__header">
<div>
<span className="modal__eyebrow">数据资源</span>
<h2>上传数据文件</h2>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-field">
<label htmlFor="data-resource-file">选择文件</label>
<input
id="data-resource-file"
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileChange}
required
/>
<small>支持 CSVExcelJSONParquetTSVTXT</small>
</div>
<div className="form-field">
<label htmlFor="data-resource-name">资源名称</label>
<input
id="data-resource-name"
type="text"
value={resourceName}
onChange={(event) => setResourceName(event.target.value)}
placeholder="输入资源名称"
required
/>
</div>
<div className="form-field">
<label htmlFor="data-resource-visibility">可见性</label>
<select
id="data-resource-visibility"
value={visibility}
onChange={(event) =>
setVisibility(event.target.value as ResourceItem["visibility"])
}
>
<option value="private">私有</option>
<option value="workspace">工作区</option>
<option value="public">公开</option>
</select>
</div>
<div className="form-field">
<label htmlFor="data-resource-description">描述(可选)</label>
<textarea
id="data-resource-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="输入描述"
rows={3}
/>
</div>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={() => setModalOpen(false)}
disabled={uploading}
>
取消
</button>
<button
className="primary-button"
type="submit"
disabled={!file || uploading}
>
{uploading ? "上传中…" : "上传"}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
}