feat: datasource

This commit is contained in:
tao.chen
2026-08-13 20:13:37 +08:00
parent 0b5b50edb9
commit 462617b66d
9 changed files with 601 additions and 14 deletions
@@ -0,0 +1,147 @@
.data-resources-page {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 22px;
overflow-y: auto;
}
.data-resources-page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.data-resources-page__header h1 {
margin: 0 0 5px;
color: #1c2d42;
font-size: 20px;
}
.data-resources-page__header p {
margin: 0;
color: #7a8999;
font-size: 12px;
}
.data-resources-page__toolbar {
display: flex;
margin-bottom: 16px;
}
.data-resources-page__toolbar .search-box {
width: 320px;
margin: 0;
}
.data-resources-page__table-wrap {
flex: 1;
min-height: 0;
border: 1px solid #dce4eb;
border-radius: 8px;
background: #fff;
overflow: auto;
}
.data-resources-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-resources-table th,
.data-resources-table td {
padding: 12px 14px;
border-bottom: 1px solid #e8edf2;
text-align: left;
vertical-align: middle;
}
.data-resources-table th {
position: sticky;
top: 0;
color: #5c6d7e;
background: #f7f9fb;
font-size: 11px;
font-weight: 650;
}
.data-resources-table tbody tr:hover {
background: #f4f8fc;
}
.data-resources-table td {
color: #33465a;
}
.data-resources-table__name {
font-weight: 600;
}
.data-resources-table code.jupyter-path {
display: inline-block;
max-width: 260px;
overflow: hidden;
padding: 3px 7px;
border-radius: 4px;
background: #f1f5f9;
color: #2a4d6b;
font-family: Consolas, "SFMono-Regular", monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.visibility-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
.visibility-badge--private {
color: #7a6a3f;
background: #f9f5e6;
}
.visibility-badge--workspace {
color: #1a5e9c;
background: #eaf4fd;
}
.visibility-badge--public {
color: #1f7a54;
background: #eaf9f3;
}
.data-resources-page__empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #8b99a8;
font-size: 13px;
}
.data-resources-page .form-field input[type="file"] {
height: auto;
padding: 8px 11px;
font-size: 12px;
}
.data-resources-table td .row-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.data-resources-table td .text-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
@@ -0,0 +1,339 @@
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>
);
}
@@ -6,7 +6,7 @@ import {
useScriptWorkspaceStore,
} from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
@@ -68,4 +68,4 @@ export function useEditSessionLifecycle({
bindScriptWorkspaceApi(null);
};
}, []);
}
}