chore: prune legacy node backend and mix workflow models

This commit is contained in:
boos
2026-05-26 15:18:33 +08:00
parent 6869504119
commit 0f9fe4a81d
32 changed files with 525 additions and 5019 deletions
+195 -47
View File
@@ -1,9 +1,13 @@
import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
const PAGE_SIZE = 30;
const AUTO_PREVIEW_LIMIT_BYTES = 8 * 1024 * 1024;
const AUTO_PREVIEW_COUNT = 12;
const SOURCE_OPTIONS = [
{ value: 'all', label: '全部来源' },
{ value: 'picture-agent', label: '生图助手' },
@@ -51,6 +55,23 @@ function assetCreatedAtLabel(asset: WorkspaceAsset) {
return formatDateTimeLocal(asset.createdAt);
}
function fileEndpoint(audience: SessionAudience, publicId: string) {
return `${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(publicId)}/file`;
}
function canAutoPreview(asset: WorkspaceAsset) {
return Boolean(
asset.hasLocalFile
&& asset.kind === 'image'
&& Number(asset.sizeBytes || 0) > 0
&& Number(asset.sizeBytes || 0) <= AUTO_PREVIEW_LIMIT_BYTES,
);
}
function canRenderPreview(asset: WorkspaceAsset) {
return asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'audio';
}
export default function Workspace() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
@@ -62,7 +83,11 @@ export default function Workspace() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
const [selectedId, setSelectedId] = useState('');
const [page, setPage] = useState(1);
const objectUrlsRef = useRef<Set<string>>(new Set());
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
const sourceStats = useMemo(() => {
@@ -78,9 +103,13 @@ export default function Workspace() {
source: source === 'all' ? undefined : source,
kind: kind === 'all' ? undefined : kind,
q: query.trim() || undefined,
page: 1,
pageSize: 60,
}), [kind, query, source]);
page,
pageSize: PAGE_SIZE,
}), [kind, page, query, source]);
useEffect(() => {
setPage(1);
}, [kind, query, source]);
const load = useCallback(async () => {
setLoading(true);
@@ -89,11 +118,13 @@ export default function Workspace() {
const response = audience === 'user'
? await userApi.getWorkspaceAssets(params)
: await api.getWorkspaceAssets(params);
setItems(Array.isArray(response.items) ? response.items : []);
const nextItems = Array.isArray(response.items) ? response.items : [];
setItems((current) => (params.page && params.page > 1 ? [...current, ...nextItems] : nextItems));
setTotal(Number(response.total || 0));
setSelectedId((current) => {
if (current && response.items?.some((item) => item.publicId === current)) return current;
return response.items?.[0]?.publicId || '';
if (params.page && params.page > 1 && current) return current;
if (current && nextItems.some((item) => item.publicId === current)) return current;
return nextItems[0]?.publicId || '';
});
} catch (err: any) {
setError(err?.message || '工作空间加载失败');
@@ -106,38 +137,76 @@ export default function Workspace() {
void load();
}, [load]);
useEffect(() => {
return () => {
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
objectUrlsRef.current.clear();
};
}, []);
useEffect(() => {
let cancelled = false;
const objectUrls: string[] = [];
async function hydratePreviews() {
const candidates = items
.filter((item) => item.hasLocalFile && (item.kind === 'image' || item.kind === 'video' || item.kind === 'audio'))
.slice(0, 24);
.slice(0, AUTO_PREVIEW_COUNT)
.filter((item) => canAutoPreview(item) && !previewUrls[item.publicId])
.slice(0, AUTO_PREVIEW_COUNT);
const next: Record<string, string> = {};
await Promise.all(candidates.map(async (asset) => {
for (const asset of candidates) {
if (cancelled) break;
try {
const response = await fetchSessionResponse(
audience,
`${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`,
{ timeoutMs: 30_000 },
fileEndpoint(audience, asset.publicId),
{ timeoutMs: 15_000 },
);
if (!response.ok) return;
if (!response.ok) continue;
const blob = await response.blob();
if (cancelled) break;
const url = URL.createObjectURL(blob);
objectUrls.push(url);
objectUrlsRef.current.add(url);
next[asset.publicId] = url;
} catch {
// Preview hydration is best-effort; the asset row still remains usable.
}
}));
if (!cancelled) setPreviewUrls(next);
}
if (!cancelled && Object.keys(next).length > 0) {
setPreviewUrls((current) => ({ ...current, ...next }));
setLoadedLocalPreviews((current) => new Set([...current, ...Object.keys(next)]));
}
}
void hydratePreviews();
return () => {
cancelled = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [audience, items]);
}, [audience, items, previewUrls]);
const loadPreview = useCallback(async (asset: WorkspaceAsset) => {
if (!asset.hasLocalFile || !canRenderPreview(asset)) return;
setPreviewLoading((current) => ({ ...current, [asset.publicId]: true }));
try {
const response = await fetchSessionResponse(
audience,
fileEndpoint(audience, asset.publicId),
{ timeoutMs: 120_000 },
);
if (!response.ok) throw new Error('预览加载失败');
const blob = await response.blob();
const oldUrl = previewUrls[asset.publicId];
const url = URL.createObjectURL(blob);
objectUrlsRef.current.add(url);
setPreviewUrls((current) => ({ ...current, [asset.publicId]: url }));
setLoadedLocalPreviews((current) => new Set(current).add(asset.publicId));
if (oldUrl?.startsWith('blob:')) {
URL.revokeObjectURL(oldUrl);
objectUrlsRef.current.delete(oldUrl);
}
} catch (err: any) {
setError(err?.message || '预览加载失败');
} finally {
setPreviewLoading((current) => ({ ...current, [asset.publicId]: false }));
}
}, [audience, previewUrls]);
const handleDelete = async (asset: WorkspaceAsset) => {
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
@@ -154,18 +223,60 @@ export default function Workspace() {
};
const handleDownload = async (asset: WorkspaceAsset) => {
const previewUrl = previewUrls[asset.publicId];
const href = previewUrl || asset.remoteUrl || '';
if (!href) return;
const link = document.createElement('a');
link.href = href;
link.download = asset.fileName || `${asset.publicId}`;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
try {
let href = asset.remoteUrl || '';
if (asset.hasLocalFile) {
const response = await fetchSessionResponse(
audience,
fileEndpoint(audience, asset.publicId),
{ timeoutMs: 180_000 },
);
if (!response.ok) throw new Error('下载失败');
const blob = await response.blob();
href = URL.createObjectURL(blob);
}
if (!href) return;
const link = document.createElement('a');
link.href = href;
link.download = asset.fileName || `${asset.publicId}`;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
if (href.startsWith('blob:')) {
window.setTimeout(() => URL.revokeObjectURL(href), 30_000);
}
} catch (err: any) {
setError(err?.message || '下载失败');
}
};
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
if (asset.kind === 'image' && preview) {
return <img src={preview} alt="" style={mediaStyle} loading="lazy" />;
}
if (asset.kind === 'video' && preview) {
return <video src={preview} style={mediaStyle} muted controls={!compact} preload="metadata" />;
}
if (asset.kind === 'audio' && preview) {
return compact ? <div style={placeholderStyle}>音频</div> : <audio src={preview} controls style={{ width: '100%' }} />;
}
return (
<div style={placeholderStyle}>
<span>{kindLabel(asset.kind)}</span>
{asset.hasLocalFile && canRenderPreview(asset) ? (
<span style={placeholderHintStyle}>
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
</span>
) : null}
</div>
);
};
const hasMore = items.length < total;
return (
<div>
<div className="page-header">
@@ -257,16 +368,11 @@ export default function Workspace() {
onClick={() => setSelectedId(asset.publicId)}
>
<div style={thumbStyle}>
{asset.kind === 'image' && preview ? (
<img src={preview} alt="" style={mediaStyle} />
) : asset.kind === 'video' && preview ? (
<video src={preview} style={mediaStyle} muted />
) : asset.kind === 'audio' ? (
<div style={placeholderStyle}>音频</div>
) : (
<div style={placeholderStyle}>{kindLabel(asset.kind)}</div>
)}
{renderPreview(asset, true)}
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
<span style={previewBadgeStyle}>{formatBytes(asset.sizeBytes)}</span>
)}
</div>
<div style={cardBodyStyle}>
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
@@ -280,6 +386,18 @@ export default function Workspace() {
);
})}
</div>
{hasMore && (
<div style={loadMoreStyle}>
<button
type="button"
className="btn btn-ghost"
onClick={() => setPage((current) => current + 1)}
disabled={loading}
>
{loading ? '加载中...' : `加载更多(${items.length}/${total})`}
</button>
</div>
)}
</section>
<aside style={detailStyle}>
@@ -293,16 +411,19 @@ export default function Workspace() {
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
</div>
<div style={detailPreviewStyle}>
{selected.kind === 'image' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
<img src={previewUrls[selected.publicId] || selected.remoteUrl || ''} alt="" style={mediaStyle} />
) : selected.kind === 'video' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
<video src={previewUrls[selected.publicId] || selected.remoteUrl || ''} controls style={mediaStyle} />
) : selected.kind === 'audio' && previewUrls[selected.publicId] ? (
<audio src={previewUrls[selected.publicId]} controls style={{ width: '100%' }} />
) : (
<div style={placeholderStyle}>{kindLabel(selected.kind)}</div>
)}
{renderPreview(selected)}
</div>
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
<button
type="button"
className="btn btn-ghost"
style={{ width: '100%', marginTop: 10 }}
onClick={() => void loadPreview(selected)}
disabled={Boolean(previewLoading[selected.publicId])}
>
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
</button>
)}
<div style={detailMetaStyle}>
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
@@ -402,13 +523,22 @@ const placeholderStyle: CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
color: 'var(--color-text-muted)',
fontWeight: 700,
textAlign: 'center',
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
};
const placeholderHintStyle: CSSProperties = {
fontSize: 12,
fontWeight: 500,
color: 'var(--color-text-muted)',
};
const cardBodyStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
@@ -535,6 +665,24 @@ const typeBadgeStyle: CSSProperties = {
fontWeight: 700,
};
const previewBadgeStyle: CSSProperties = {
position: 'absolute',
right: 10,
bottom: 10,
borderRadius: 999,
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#fff',
fontSize: 12,
fontWeight: 700,
};
const loadMoreStyle: CSSProperties = {
display: 'flex',
justifyContent: 'center',
marginTop: 18,
};
const cardFooterStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
+1 -2
View File
@@ -159,8 +159,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | '
};
}
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
// src/server/services/siteApiEndpointService.ts.
// Keep this in sync with the Rust site endpoint normalization in admin account routes.
function normalizeSiteApiEndpointUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';