feat(assets): 素材列表加载骨架屏 + 诊断按钮 loading 态
Deploy / Staging E2E Tests (push) Failing after 108h12m17s
Deploy / Deploy Staging (push) Failing after 108h22m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 108h25m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 108h25m23s
Deploy / Production Browser E2E (push) Failing after 1766h43m11s
Deploy / Build Production Runtime Images (push) Failing after 1766h50m8s
Deploy / Deploy Production (push) Failing after 1767h14m39s

- 素材列表加载时显示 8 张骨架屏卡片(脉冲动画),替代空白 loading 提示
- 诊断按钮点击后显示 loading 状态(LoadingOutlined + 禁用),防止重复点击
- 新增 SkeletonCard 组件和骨架屏 CSS 动画样式

Closes: 代码审计 P3-素材库缺 loading 态
This commit is contained in:
灵应
2026-07-05 00:29:04 +08:00
parent 8c2d9947e2
commit 4b4e1834ac
2 changed files with 66 additions and 8 deletions
+33 -8
View File
@@ -16,6 +16,7 @@ import {
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -185,16 +186,31 @@ const StatusPill: React.FC<{ status: StatusType; label: string }> = ({
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
);
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "100%", height: 28, marginTop: 8, borderRadius: "var(--radius-xs)" }} />
</div>
</div>
);
/* ============================================================
* AssetCard 组件
* ============================================================ */
const AssetCard: React.FC<{
asset: AssetItem;
selected: boolean;
diagnosing?: boolean;
onToggle: () => void;
onDiagnose: () => void;
onPlay: () => void;
}> = ({ asset, selected, onToggle, onDiagnose, onPlay }) => (
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
<div
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
onClick={onToggle}
@@ -243,14 +259,15 @@ const AssetCard: React.FC<{
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className="xx-asset-diagnose-btn"
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing}
onClick={(e) => {
e.stopPropagation();
onDiagnose();
}}
>
<ExperimentOutlined />
诊断
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
@@ -338,6 +355,9 @@ const AssetLibrary: React.FC = () => {
/* 视频播放 */
const [playingAsset, setPlayingAsset] = useState<AssetItem | null>(null);
/* 诊断中状态 — 记录正在诊断的素材 ID */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
/* 派生数据 */
const filteredAssets = useMemo(() => {
let list = assets;
@@ -449,8 +469,9 @@ const AssetLibrary: React.FC = () => {
}
};
/* 诊断 — 调用真实 API */
/* 诊断 — 调用真实 API,带 loading 状态 */
const handleDiagnose = async (asset: AssetItem) => {
setDiagnosingId(asset.id);
try {
const result = await getAssetDiagnosis(asset.id);
const score = result.readiness_score ?? "-";
@@ -458,6 +479,8 @@ const AssetLibrary: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ["assets"] });
} catch {
message.error(`"${asset.name}" 诊断失败`);
} finally {
setDiagnosingId(null);
}
};
@@ -647,9 +670,10 @@ const AssetLibrary: React.FC = () => {
{/* 素材网格 */}
{assetsLoading ? (
<div className="xx-assets-empty">
<div className="xx-assets-empty-icon">⏳</div>
<p>加载素材中...</p>
<div className="xx-asset-grid">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
) : assetsError ? (
<div className="xx-assets-empty">
@@ -670,6 +694,7 @@ const AssetLibrary: React.FC = () => {
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id}
onToggle={() => toggleSelect(asset.id)}
onDiagnose={() => handleDiagnose(asset)}
onPlay={() => setPlayingAsset(asset)}
+33
View File
@@ -357,6 +357,39 @@
color: var(--info-color);
}
/* ============================================================
骨架屏(素材列表加载占位)
============================================================ */
@keyframes xx-skeleton-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.xx-asset-skeleton {
pointer-events: none;
}
.xx-asset-skeleton .xx-asset-thumb {
background: var(--bg-tertiary, #e5e7eb);
}
.xx-skeleton-pulse {
animation: xx-skeleton-pulse 1.5s ease-in-out infinite;
}
.xx-skeleton-line {
height: 14px;
border-radius: var(--radius-xs);
background: var(--bg-tertiary, #e5e7eb);
}
/* 诊断按钮 loading 态 */
.xx-asset-diagnose-btn-loading {
opacity: 0.7;
cursor: not-allowed;
}
/* ============================================================
空状态
============================================================ */