diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py
index d2dabfe..cc76daf 100644
--- a/backend/app/api/v1/endpoints/platform.py
+++ b/backend/app/api/v1/endpoints/platform.py
@@ -1,7 +1,7 @@
from __future__ import annotations
-import uuid
import json
+import uuid
from pathlib import Path
from typing import Any
@@ -426,6 +426,14 @@ async def dataset_preview(file_id: str) -> dict[str, Any]:
raise fail(404, "dataset file not found")
+@router.get("/dataset-manage/records/{file_id}/sources")
+async def dataset_record_sources(file_id: str) -> dict[str, Any]:
+ try:
+ return ok({"items": get_platform_store().dataset_file_record_sources(file_id)})
+ except KeyError:
+ raise fail(404, "dataset file not found")
+
+
@router.get("/dataset-manage/versions/{file_id}")
async def dataset_versions(file_id: str) -> dict[str, Any]:
try:
diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py
index d640ae0..c011839 100644
--- a/backend/app/db/platform_store.py
+++ b/backend/app/db/platform_store.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-import json
import hashlib
import hmac
+import json
import math
import re
import secrets
@@ -17,7 +17,6 @@ import psycopg
from app.core.config import get_settings
-
ALL_PERMISSIONS = [
"dashboard",
"fine-tune",
@@ -61,6 +60,71 @@ def safe_float(value: Any, default: float = 0) -> float:
return default
+_SIZE_UNIT_BYTES = {
+ "B": 1,
+ "KB": 1024,
+ "MB": 1024**2,
+ "GB": 1024**3,
+ "TB": 1024**4,
+}
+
+
+def parse_size_bytes(value: Any) -> int:
+ """把历史字符串大小统一换算为字节,供接口返回稳定的数值字段。"""
+ if isinstance(value, bool):
+ return 0
+ if isinstance(value, (int, float)):
+ return max(0, int(value))
+ match = re.fullmatch(
+ r"\s*([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)?\s*",
+ str(value or ""),
+ flags=re.IGNORECASE,
+ )
+ if not match:
+ return 0
+ amount = float(match.group(1))
+ unit = (match.group(2) or "B").upper()
+ return max(0, round(amount * _SIZE_UNIT_BYTES[unit]))
+
+
+def version_number(value: Any, default: int = 0) -> int:
+ try:
+ number = int(value)
+ except (TypeError, ValueError):
+ return default
+ return number if number > 0 else default
+
+
+def dataset_file_version_summary(file_row: PgRow) -> dict[str, Any]:
+ versions = json_loads(file_row.get("versions"), [])
+ versions = versions if isinstance(versions, list) else []
+ active_version_id = str(
+ file_row.get("active_version_id")
+ or file_row.get("current_version_id")
+ or ""
+ )
+ active_version = next(
+ (
+ item
+ for item in versions
+ if isinstance(item, dict) and str(item.get("id") or "") == active_version_id
+ ),
+ None,
+ )
+ current_version_no = version_number(
+ (active_version or {}).get("version_no")
+ or (active_version or {}).get("version")
+ or file_row.get("version_no"),
+ default=1 if active_version_id or versions else 0,
+ )
+ return {
+ "active_version_id": active_version_id or None,
+ "current_version_id": active_version_id or None,
+ "current_version_no": current_version_no or None,
+ "version_count": len(versions),
+ }
+
+
def parse_training_metric_line(line: str) -> dict[str, float] | None:
if "loss" not in line and "learning_rate" not in line:
return None
@@ -1099,11 +1163,30 @@ class PlatformStore:
def _dataset(self, conn: PgConnection, row: PgRow) -> dict[str, Any]:
files = conn.execute(
- """SELECT id, name, size, active_version_id, create_time,
+ """SELECT id, name, size, size_bytes, active_version_id,
+ current_version_id, version_no, versions, create_time,
record_count, metadata
FROM dataset_files WHERE dataset_id=? ORDER BY create_time, id""",
(row["id"],),
).fetchall()
+ decoded_files: list[dict[str, Any]] = []
+ for file_row in files:
+ metadata = json_loads(file_row.get("metadata"), {})
+ file_size_bytes = int(file_row.get("size_bytes") or 0)
+ if file_size_bytes <= 0:
+ file_size_bytes = parse_size_bytes(file_row.get("size"))
+ decoded_files.append(
+ {
+ "id": file_row["id"],
+ "name": file_row["name"],
+ "size": file_row["size"],
+ "size_bytes": file_size_bytes,
+ **dataset_file_version_summary(file_row),
+ "create_time": file_row["create_time"],
+ "record_count": int(file_row.get("record_count") or 0),
+ "split": metadata.get("file_split"),
+ }
+ )
dataset_metadata = json_loads(row.get("metadata"), {})
split_counts = dict(dataset_metadata.get("split_counts") or {})
if row.get("source") == "task" and not split_counts:
@@ -1113,26 +1196,33 @@ class PlatformStore:
(row["id"],),
).fetchall()
split_counts = {str(item["split"]): int(item["count"]) for item in split_rows}
+ total_size_bytes = sum(item["size_bytes"] for item in decoded_files)
+ if not decoded_files:
+ total_size_bytes = int(row.get("size_bytes") or 0)
+ if total_size_bytes <= 0:
+ total_size_bytes = parse_size_bytes(row.get("size"))
+ current_version_nos = sorted(
+ {
+ int(item["current_version_no"])
+ for item in decoded_files
+ if item.get("current_version_no")
+ }
+ )
return {
**dict(row),
+ "size_bytes": total_size_bytes,
+ "current_version_no": (
+ current_version_nos[0] if len(current_version_nos) == 1 else None
+ ),
+ "current_version_nos": current_version_nos,
+ "version_count": sum(int(item["version_count"]) for item in decoded_files),
"metadata": dataset_metadata,
"split_counts": {
"train": int(split_counts.get("train", 0) or 0),
"validation": int(split_counts.get("validation", 0) or 0),
"test": int(split_counts.get("test", 0) or 0),
},
- "files": [
- {
- "id": f["id"],
- "name": f["name"],
- "size": f["size"],
- "active_version_id": f["active_version_id"],
- "create_time": f["create_time"],
- "record_count": int(f.get("record_count") or 0),
- "split": json_loads(f.get("metadata"), {}).get("file_split"),
- }
- for f in files
- ],
+ "files": decoded_files,
}
def create_dataset(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -1192,12 +1282,24 @@ class PlatformStore:
now = utcnow()
file_id = new_id("file")
version_id = f"{file_id}_v1"
- size = f"{max(1, len(content.encode('utf-8')) // 1024)} KB"
+ size_bytes = len(content.encode("utf-8"))
+ size = f"{size_bytes} B"
+ record_count = len([line for line in content.splitlines() if line.strip()])
+ version = {
+ "id": version_id,
+ "version": 1,
+ "version_no": 1,
+ "create_time": now,
+ "description": "uploaded",
+ "size_bytes": size_bytes,
+ "record_count": record_count,
+ }
conn.execute(
"""
INSERT INTO dataset_files
- (id, dataset_id, name, size, content, active_version_id, versions, create_time)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ (id, dataset_id, name, size, content, active_version_id, versions, create_time,
+ current_version_id, size_bytes, record_count, version_no)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
""",
(
file_id,
@@ -1206,16 +1308,69 @@ class PlatformStore:
size,
content,
version_id,
- json_dumps([{"id": version_id, "version": 1, "create_time": now, "description": "uploaded"}]),
+ json_dumps([version]),
now,
+ version_id,
+ size_bytes,
+ record_count,
),
)
- count = len([line for line in content.splitlines() if line.strip()])
conn.execute(
- "UPDATE datasets SET count=count+?, size=? WHERE id=?",
- (count, size, dataset_id),
+ """UPDATE datasets
+ SET count=count+?, record_count=record_count+?,
+ size_bytes=size_bytes+?, size=((size_bytes+?)::text || ' B')
+ WHERE id=?""",
+ (record_count, record_count, size_bytes, size_bytes, dataset_id),
)
- return {"id": file_id, "name": name, "size": size}
+ return {
+ "id": file_id,
+ "name": name,
+ "size": size,
+ "size_bytes": size_bytes,
+ "current_version_no": 1,
+ "version_count": 1,
+ }
+
+ def dataset_file_record_sources(self, file_id: str) -> list[dict[str, Any]]:
+ """返回发布样本关联的真实原文,供数据集详情核对生成内容。"""
+ with self.connect() as conn:
+ file_row = conn.execute(
+ "SELECT id FROM dataset_files WHERE id=?", (file_id,)
+ ).fetchone()
+ if not file_row:
+ raise KeyError(file_id)
+ rows = conn.execute(
+ """
+ SELECT records.line_no, records.instruction, records.input, records.output,
+ COALESCE(
+ NULLIF(preview.edited_content, ''),
+ preview.original_content,
+ ''
+ ) AS source_text,
+ CASE
+ WHEN preview.edited_content IS NOT NULL
+ AND preview.edited_content <> ''
+ THEN TRUE ELSE FALSE
+ END AS preprocessed
+ FROM dataset_records AS records
+ LEFT JOIN data_process_preview_items AS preview
+ ON preview.id=records.preview_item_id
+ WHERE records.dataset_file_id=?
+ ORDER BY records.line_no NULLS LAST, records.created_at, records.id
+ """,
+ (file_id,),
+ ).fetchall()
+ return [
+ {
+ "line_no": int(item.get("line_no") or index + 1),
+ "instruction": str(item.get("instruction") or ""),
+ "input": str(item.get("input") or ""),
+ "output": str(item.get("output") or ""),
+ "source_text": str(item.get("source_text") or ""),
+ "preprocessed": bool(item.get("preprocessed")),
+ }
+ for index, item in enumerate(rows)
+ ]
def dataset_file(self, file_id: str) -> PgRow:
with self.connect() as conn:
diff --git a/backend/tests/test_platform_dataset_metadata.py b/backend/tests/test_platform_dataset_metadata.py
new file mode 100644
index 0000000..11a9ef2
--- /dev/null
+++ b/backend/tests/test_platform_dataset_metadata.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from app.db.platform_store import dataset_file_version_summary, parse_size_bytes
+
+
+def test_parse_size_bytes_supports_legacy_units() -> None:
+ assert parse_size_bytes("21563 B") == 21563
+ assert parse_size_bytes("1.5 KB") == 1536
+ assert parse_size_bytes("2 MB") == 2 * 1024**2
+ assert parse_size_bytes(4096) == 4096
+ assert parse_size_bytes("unknown") == 0
+
+
+def test_dataset_file_version_summary_uses_active_version_metadata() -> None:
+ summary = dataset_file_version_summary(
+ {
+ "active_version_id": "file-1-v3",
+ "current_version_id": "file-1-v1",
+ "version_no": 1,
+ "versions": (
+ '[{"id":"file-1-v1","version":1},'
+ '{"id":"file-1-v3","version_no":3}]'
+ ),
+ }
+ )
+
+ assert summary == {
+ "active_version_id": "file-1-v3",
+ "current_version_id": "file-1-v3",
+ "current_version_no": 3,
+ "version_count": 2,
+ }
+
+
+def test_dataset_file_version_summary_uses_normalized_version_number_as_fallback() -> None:
+ summary = dataset_file_version_summary(
+ {
+ "active_version_id": "",
+ "current_version_id": None,
+ "version_no": 1,
+ "versions": "[]",
+ }
+ )
+
+ assert summary["current_version_no"] == 1
+ assert summary["version_count"] == 0
diff --git a/frontend/scripts/regression-dataset-task-tab.mjs b/frontend/scripts/regression-dataset-task-tab.mjs
index 851d601..c3c47a4 100644
--- a/frontend/scripts/regression-dataset-task-tab.mjs
+++ b/frontend/scripts/regression-dataset-task-tab.mjs
@@ -42,7 +42,11 @@ assert.match(
)
assert.match(viewSource, /return dataList\.value\.filter\(\(item\) => item\.source !== 'task'\)/)
assert.doesNotMatch(viewSource, /数据任务产生的数据集[\s\S]*?return \[\]/)
-assert.match(viewSource, /:search-fields="\['task_id', 'name', 'description'\]"/, '数据任务搜索应支持任务 ID')
+assert.match(viewSource, /:search-fields="\['task_id', 'name'\]"/, '数据任务搜索应支持任务 ID')
+assert.match(viewSource, /formatMegabytes\(row\.size_bytes, row\.size\)/, '数据集大小应统一转换为 MB')
+assert.match(viewSource, /[\s\S]*?批量删除[\s\S]*?v-if="selectedCount > 0"[\s\S]*?删除(\{\{ selectedCount \}\})/, '批量删除按钮没有按选择状态切换')
diff --git a/frontend/src/api/modules/dataset.ts b/frontend/src/api/modules/dataset.ts
index 546a4cf..92a7e3a 100644
--- a/frontend/src/api/modules/dataset.ts
+++ b/frontend/src/api/modules/dataset.ts
@@ -1,5 +1,10 @@
import { get, post, put, del } from '../request'
-import type { DatasetItem, DatasetVersion, DatasetVersionList } from '@/types'
+import type {
+ DatasetItem,
+ DatasetRecordSource,
+ DatasetVersion,
+ DatasetVersionList,
+} from '@/types'
/** 数据集列表 */
export const getDatasetList = () => get('/dataset-manage')
@@ -31,6 +36,12 @@ export const uploadDatasetFiles = (datasetId: string | number, files: File[]) =>
export const previewDatasetFile = (fileId: string | number) =>
get<{ content: string }>(`/dataset-manage/preview/${encodeURIComponent(fileId)}`)
+/** 获取发布样本所关联的原文参照 */
+export const getDatasetFileRecordSources = (fileId: string | number) =>
+ get<{ items: DatasetRecordSource[] }>(
+ `/dataset-manage/records/${encodeURIComponent(fileId)}/sources`,
+ )
+
/** 数据集文件版本列表 */
export const getDatasetFileVersions = (fileId: string | number) =>
get(`/dataset-manage/versions/${encodeURIComponent(fileId)}`)
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index e64cc66..5ab7c8c 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -63,6 +63,11 @@ export interface DatasetFile {
id?: string
name: string
size?: string | number
+ size_bytes?: number
+ active_version_id?: string | null
+ current_version_id?: string | null
+ current_version_no?: number | null
+ version_count?: number
record_count?: number
split?: 'train' | 'validation' | 'test' | null
[k: string]: any
@@ -76,13 +81,26 @@ export interface DatasetItem {
source?: DatasetSource
task_id?: string | number
size?: string | number
+ size_bytes?: number
count?: number
description?: string
create_time?: string
files?: DatasetFile[]
+ current_version_no?: number | null
+ current_version_nos?: number[]
+ version_count?: number
split_counts?: { train: number; validation: number; test: number }
}
+export interface DatasetRecordSource {
+ line_no: number
+ instruction: string
+ input: string
+ output: string
+ source_text: string
+ preprocessed: boolean
+}
+
export interface DatasetVersion {
id: string
version: number
diff --git a/frontend/src/utils/fileSize.ts b/frontend/src/utils/fileSize.ts
new file mode 100644
index 0000000..5f3fbcb
--- /dev/null
+++ b/frontend/src/utils/fileSize.ts
@@ -0,0 +1,26 @@
+export function parseSizeBytes(value?: string | number | null) {
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, value) : 0
+ const match = String(value || '').trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i)
+ if (!match) return 0
+ const units: Record = {
+ B: 1,
+ KB: 1024,
+ MB: 1024 ** 2,
+ GB: 1024 ** 3,
+ TB: 1024 ** 4,
+ }
+ return Number(match[1]) * units[(match[2] || 'B').toUpperCase()]
+}
+
+/** 列表统一使用 MB,非零的小文件至少展示为 0.01 MB,避免误显示为零。 */
+export function formatMegabytes(
+ sizeBytes?: number | null,
+ legacySize?: string | number | null,
+) {
+ const numericSize = Number(sizeBytes)
+ const bytes = Number.isFinite(numericSize) && numericSize > 0
+ ? numericSize
+ : parseSizeBytes(legacySize)
+ if (bytes <= 0) return '0.00 MB'
+ return `${Math.max(bytes / 1024 ** 2, 0.01).toFixed(2)} MB`
+}
diff --git a/frontend/src/views/dataset/DatasetListView.vue b/frontend/src/views/dataset/DatasetListView.vue
index 8951055..5aa125b 100644
--- a/frontend/src/views/dataset/DatasetListView.vue
+++ b/frontend/src/views/dataset/DatasetListView.vue
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getDatasetList, deleteDataset, downloadDatasetUrl } from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
+import { formatMegabytes } from '@/utils/fileSize'
import type { DatasetItem, DatasetSource } from '@/types'
const router = useRouter()
@@ -107,6 +108,36 @@ function handleDownload(row: any) {
window.open(downloadDatasetUrl(row.id), '_blank')
}
+function formatSizeMb(row: DatasetItem) {
+ return formatMegabytes(row.size_bytes, row.size)
+}
+
+function currentVersionNumbers(row: DatasetItem) {
+ const values = row.current_version_nos?.length
+ ? row.current_version_nos
+ : [
+ row.current_version_no,
+ ...(row.files || []).map((file) => file.current_version_no),
+ ]
+ return [...new Set(
+ values
+ .map((value) => Number(value))
+ .filter((value) => Number.isInteger(value) && value > 0),
+ )].sort((a, b) => a - b)
+}
+
+function versionLabel(row: DatasetItem) {
+ const versions = currentVersionNumbers(row)
+ return versions.length ? versions.map((value) => `V${value}`).join(' / ') : '-'
+}
+
+function versionDescription(row: DatasetItem) {
+ const fileCount = row.files?.length || 0
+ const historyCount = Number(row.version_count || 0)
+ if (!fileCount) return '当前数据集没有文件版本'
+ return `${fileCount} 个文件,共 ${historyCount || fileCount} 个版本记录`
+}
+
watch(activeTab, exitBatchMode)
onMounted(loadData)
@@ -118,7 +149,7 @@ onMounted(loadData)
:data="filteredDataList"
:loading="loading"
searchable
- :search-fields="['task_id', 'name', 'description']"
+ :search-fields="['task_id', 'name']"
:multi-select="activeTab === 'task' && batchMode"
:show-batch-bar="false"
:create-text="activeTab === 'upload' ? '上传数据集' : ''"
@@ -181,16 +212,26 @@ onMounted(loadData)
-
+
- {{ row.size && row.size !== '0 B' && row.size !== '0' ? row.size : '-' }}
+ {{ formatSizeMb(row as DatasetItem) }}
{{ row.count || 0 }}
-
- {{ row.description || '-' }}
+
+
+
+
+ {{ versionLabel(row as DatasetItem) }}
+
+
+
@@ -257,4 +298,9 @@ onMounted(loadData)
}
}
+.numeric-cell {
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+