feat(web): 监控作品卡片展示描述,观看/点赞/评论/分享全宽 GRID 贴封面下方
douyin-release-gate / verify (push) Failing after 3m20s
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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user