chore: prune legacy node backend and mix workflow models
This commit is contained in:
+195
-47
@@ -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',
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user