feat(web): 监控作品卡片展示描述,观看/点赞/评论/分享全宽 GRID 贴封面下方
douyin-release-gate / verify (push) Failing after 3m20s

- WorksGallery 卡片封面改走本地缓存 /works/:id/cover(WorkCover 与竞品分析页同链路,
  替换会过期的 douyinpic 签名 URL)
- 卡片新增作品描述(body,两行省略)
- 指标从标题下横排小字改为封面下方全宽四列 GRID(观看/点赞/评论/分享,上值下图标行)
- WorksGallery 为监控账号详情与自有账号详情共用,两处同步生效
- 测试:works-gallery-card 3 例 + 既有 1 例全绿,tsc/antd lint 通过
This commit is contained in:
2026-09-28 11:19:52 +08:00
parent 733b2d4b95
commit 8748f899d4
2 changed files with 118 additions and 28 deletions
+76 -28
View File
@@ -12,9 +12,62 @@ import {
LikeOutlined,
ReloadOutlined,
ShareAltOutlined } from '@ant-design/icons';
import { creatorGet, getList } from '@/services/api';
import { creatorAction, creatorGet, fetchCreatorBinary, getList } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
// 作品封面:从本地缓存接口读取(认证 fetch + blob URL);缓存缺失时展示占位。
// 原始 douyinpic URL 带签名会过期,与竞品分析页共用同一条 /works/:id/cover 链路。
export function WorkCover({ workID, title }: { workID: string; title?: string }) {
const [src, setSrc] = useState<string | null>(null);
const [missing, setMissing] = useState(false);
useEffect(() => {
let objectURL: string | null = null;
setSrc(null);
setMissing(false);
fetchCreatorBinary(`/creator/works/${encodeURIComponent(workID)}/cover`)
.then((blob) => {
objectURL = URL.createObjectURL(blob);
setSrc(objectURL);
})
.catch(() => setMissing(true));
return () => {
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [workID]);
if (src) {
return <img src={src} alt={title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />;
}
return (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{missing ? '封面未缓存' : '封面加载中'}</Typography.Text>
</Flex>
);
}
// 观看/点赞/评论/分享全宽四列 GRID,贴在封面下方(body padding 置 0,本行自带上下边框)。
function WorkStatsGrid({ work }: { work: any }) {
const items = [
{ key: 'play_count', label: '观看', icon: EyeOutlined },
{ key: 'likes', label: '点赞', icon: LikeOutlined },
{ key: 'comments_count', label: '评论', icon: CommentOutlined },
{ key: 'shares', label: '分享', icon: ShareAltOutlined },
];
return (
<Flex style={{ margin: '10px 0 0', padding: '8px 0', borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
{items.map(({ key, label, icon: Icon }) => (
<Flex key={key} vertical align="center" gap={2} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Icon /> {label}
</Typography.Text>
<Typography.Text strong style={{ fontSize: 12 }}>
{work[key] ?? '—'}
</Typography.Text>
</Flex>
))}
</Flex>
);
}
const metricSeriesMeta = [
{ key: 'play_count', label: '播放' },
{ key: 'likes', label: '点赞' },
@@ -174,36 +227,31 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint }: WorksG
<Card
hoverable
style={{ height: '100%' }}
styles={{ body: { padding: '0 0 12px' } }}
onClick={() => openWorkDetail(work)}
cover={
work.cover_url ? (
<img src={work.cover_url} alt={work.title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />
) : (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>无封面</Typography.Text>
</Flex>
)
}
cover={<WorkCover workID={work.id} title={work.title} />}
>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title || '无标题'}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
</Typography.Text>
<Flex gap={12} wrap="wrap">
<Typography.Text style={{ fontSize: 12 }}><EyeOutlined /> {work.play_count ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><LikeOutlined /> {work.likes ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><CommentOutlined /> {work.comments_count ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><ShareAltOutlined /> {work.shares ?? '—'}</Typography.Text>
</Flex>
{work.likes_growth != null && work.likes_growth > 0 ? (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
<FireOutlined /> 24h +{work.likes_growth}
<div style={{ padding: '12px 16px 0' }}>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title || '无标题'}
</Typography.Paragraph>
{work.body ? (
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ fontSize: 12, marginBottom: 0 }}>
{work.body}
</Typography.Paragraph>
) : null}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
</Typography.Text>
) : null}
</Flex>
</Flex>
</div>
<WorkStatsGrid work={work} />
{work.likes_growth != null && work.likes_growth > 0 ? (
<Typography.Text type="warning" style={{ display: 'block', padding: '8px 16px 0', fontSize: 12 }}>
<FireOutlined /> 24h +{work.likes_growth}
</Typography.Text>
) : null}
</Card>
</Col>
))}
+42
View File
@@ -0,0 +1,42 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const ts = require('typescript');
const source = ts.createSourceFile(
'WorksGallery.tsx',
readFileSync(`${__dirname}/../src/components/creator/WorksGallery.tsx`, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
test('作品卡片封面读本地缓存 /works/:id/cover(签名 URL 会过期)', () => {
assert.match(sourceText(), /cover`/);
assert.match(sourceText(), /fetchCreatorBinary/);
assert.doesNotMatch(sourceText(), /cover_url/);
});
test('观看/点赞/评论/分享四项指标全宽 GRID 贴封面下方', () => {
const grid = /function WorkStatsGrid/.exec(sourceText());
assert.ok(grid, '应有 WorkStatsGrid 组件');
const body = sourceText().slice(grid.index, grid.index + 1200);
for (const key of ['play_count', 'likes', 'comments_count', 'shares']) {
assert.match(body, new RegExp(key), `GRID 应包含指标 ${key}`);
}
// 四列均分占满整行:每列 flex:1,容器自带上下边框贴封面。
assert.match(body, /flex: 1/);
assert.match(body, /borderTop/);
assert.match(body, /borderBottom/);
});
test('卡片展示作品描述(body 省略两行)', () => {
const card = /work\.body \? \(/.exec(sourceText());
assert.ok(card, '卡片应有 body 描述分支');
const body = sourceText().slice(card.index - 120, card.index + 200);
assert.match(body, /ellipsis=\{\{ rows: 2 \}\}/);
});
function sourceText() {
return source.getFullText();
}