feat(viewer): hide like feature — action bar like slot, photo like badges, hot sort tab, others' like bubbles (user request 2026-09-23)

- PhotoFeed: cell like badge not rendered
- AlbumHeader: sort tabs row removed (hot depends on likes; latest-only has no toggle value)
- PhotoActionBar: like slot v-if=false (share/download remain, auto re-center)
- ActivityPage: LikeBubbles hidden
- specs updated to hidden assertions; 244 green

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 14:29:22 +08:00
parent e44b35db28
commit 2babd364ba
9 changed files with 40 additions and 108 deletions
+3 -13
View File
@@ -233,21 +233,11 @@ describe('activity page (Task 6)', () => {
// —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 —— // —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 ——
it('selecting the Popular tab refetches the sequence with sort=hot (active state moves)', async () => { it('sort tabs removed per user request (2026-09-23): no popular tab, sequence stays latest', async () => {
const wrapper = mountPage(); const wrapper = mountPage();
await flushPromises(); await flushPromises();
mockedGetPublicPhotos.mockClear(); expect(wrapper.findAll('.sort-tab')).toHaveLength(0);
expect(wrapper.find('.sort-tabs').exists()).toBe(false);
const tabs = wrapper.findAll('.sort-tab');
expect(tabs.map((tab) => tab.text())).toEqual(['最新', '热门']);
expect(tabs[0].classes()).toContain('active');
expect(tabs[0].attributes('aria-pressed')).toBe('true');
await tabs[1].trigger('click');
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ sort: 'hot' }));
expect(wrapper.findAll('.sort-tab')[1].classes()).toContain('active');
}); });
it('category chips filter the sequence and the selection survives a locale switch', async () => { it('category chips filter the sequence and the selection survives a locale switch', async () => {
+4 -13
View File
@@ -81,7 +81,7 @@ describe('album header info area (UI-02 V02)', () => {
expect(wrapper.find('.meta').text()).toContain('10月1日'); expect(wrapper.find('.meta').text()).toContain('10月1日');
// 工具栏:排序 tab + 分类胶囊(All + 接口分类)。 // 工具栏:排序 tab + 分类胶囊(All + 接口分类)。
expect(wrapper.findAll('.sort-tab').map((tab) => tab.text())).toEqual(['最新', '热门']); expect(wrapper.findAll('.sort-tab')).toHaveLength(0); // 排序 tab 已移除(2026-09-23)
expect(wrapper.findAll('.categories .chip').map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']); expect(wrapper.findAll('.categories .chip').map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
// jsdom 无布局:未溢出 → 不渲染展开按钮。 // jsdom 无布局:未溢出 → 不渲染展开按钮。
expect(wrapper.findAll('.text-expand')).toHaveLength(0); expect(wrapper.findAll('.text-expand')).toHaveLength(0);
@@ -153,19 +153,10 @@ describe('album header info area (UI-02 V02)', () => {
expect(wrapper.find('.title').classes()).not.toContain('clamped'); expect(wrapper.find('.title').classes()).not.toContain('clamped');
}); });
it('sort tabs: latest active by default, clicking popular emits select-sort hot, locked disables', async () => { it('sort tabs removed per user request (2026-09-23): whole row hidden, mode stays latest', () => {
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null }); const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
expect(wrapper.find('.sort-tabs').exists()).toBe(false);
const tabs = wrapper.findAll('.sort-tab'); expect(wrapper.find('.sort-tab').exists()).toBe(false);
expect(tabs[0].classes()).toContain('active');
expect(tabs[0].attributes('aria-pressed')).toBe('true');
expect(tabs[1].classes()).not.toContain('active');
await tabs[1].trigger('click');
expect(wrapper.emitted('selectSort')).toEqual([['hot']]);
const locked = mountHeader({ album: makeAlbum(), viewCount: null, sortLocked: true });
expect(locked.findAll('.sort-tab').every((tab) => tab.attributes('disabled') !== undefined)).toBe(true);
}); });
it('category chips: selected state from prop, clicks emit the category id and All emits null', async () => { it('category chips: selected state from prop, clicks emit the category id and All emits null', async () => {
+6 -29
View File
@@ -343,37 +343,14 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
expect(wrapper.findAll('.cell')).toHaveLength(1); expect(wrapper.findAll('.cell')).toHaveLength(1);
}); });
it('renders the like badge overlay with the real count and no card chrome', async () => { it('like badge overlay hidden per user request (2026-09-23)', () => {
const likes = { const { wrapper } = mountFeed();
displayFor: (photo: PublicPhoto) => photo.likeCount, expect(wrapper.find('.cell-like').exists()).toBe(false);
popNonce: () => 0,
like: vi.fn(),
} as unknown as LikesController;
mockedGetPublicPhotos.mockResolvedValueOnce(
makeList([{ ...makePhoto('p1'), likeCount: 1286 }, makePhoto('p2')], null),
);
const { wrapper } = mountFeed({ likes });
await flushPromises();
const badge = wrapper.find('.cell .cell-like');
expect(badge.exists()).toBe(true);
expect(badge.find('.heart').exists()).toBe(true);
// 真实计数(useLikes displayFor 值)经 Intl 分组。
expect(badge.find('.count').text()).toBe('1,286');
// 无白色卡片包裹/无常驻文件名/无额外标题:单元格可见文本只有点赞计数
//(图片 alt 非可见文本,懒加载绑定不影响该断言)。
expect(wrapper.findAll('.cell')[0].text()).toBe('1,286');
}); });
it('clicking the like badge likes the photo without opening the lightbox', async () => { it('like badge interactions removed with the badge (hidden 2026-09-23)', () => {
const like = vi.fn(); const { wrapper } = mountFeed();
const likes = { displayFor: () => 0, popNonce: () => 0, like } as unknown as LikesController; expect(wrapper.find('.cell-like').exists()).toBe(false);
const { wrapper, opened } = mountFeed({ likes });
await flushPromises();
await wrapper.find('.cell-like').trigger('click');
expect(like).toHaveBeenCalledWith('p1');
expect(opened).toEqual([]);
}); });
it('shows the new-photo pill on hasNew; clicking refreshes without scrolling to top', async () => { it('shows the new-photo pill on hasNew; clicking refreshes without scrolling to top', async () => {
+5 -7
View File
@@ -139,9 +139,9 @@ describe('photo lightbox (Task 8)', () => {
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1'); expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
// 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。 // 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3'); expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
// 底部操作栏(V04):Like 计数 + Like/Share 标签(无下载控制器 → 下载位省略)。 // 底部操作栏:点赞已按用户要求隐藏(2026-09-23)→ 仅分享(无下载控制器)。
expect(wrapper.find('.like-count').text()).toBe('7'); expect(wrapper.find('.like-count').exists()).toBe(false);
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']); expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['分享']);
}); });
it('navigates with buttons and arrow keys, keeping the list order', async () => { it('navigates with buttons and arrow keys, keeping the list order', async () => {
@@ -553,14 +553,12 @@ describe('photo lightbox interactions (UI-02 V05)', () => {
document.documentElement.className = ''; document.documentElement.className = '';
}); });
it('bursts heart particles for a local like click on the current photo', async () => { it('like entry hidden (2026-09-23): no local like click path, no particles', async () => {
const { wrapper } = mountV05('p1'); const { wrapper } = mountV05('p1');
await flushPromises(); await flushPromises();
expect(wrapper.find('.like-slot').exists()).toBe(false);
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(0); expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(0);
await wrapper.find('.like-slot .action-btn').trigger('click');
await nextTick();
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
}); });
it('routes realtime bursts: particles for the current photo, a notice for others; notice click navigates', async () => { it('routes realtime bursts: particles for the current photo, a notice for others; notice click navigates', async () => {
@@ -39,36 +39,30 @@ describe('photo action bar (UI-02 V04)', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('renders like/share/download as evenly distributed slots with formatted count (no tap-more hint)', () => { it('like slot hidden (2026-09-23): share/download slots remain, no like count', () => {
const wrapper = mountBar({ likeCount: 1286, canDownload: true }); const wrapper = mountBar({ likeCount: 1286, canDownload: true });
// 三个 action-slot flex 均分(下载隐藏时由剩余 slot 自然重排,见下例)。 expect(wrapper.findAll('.action-slot')).toHaveLength(2);
expect(wrapper.findAll('.action-slot')).toHaveLength(3);
const labels = wrapper.findAll('.action-label').map((label) => label.text()); const labels = wrapper.findAll('.action-label').map((label) => label.text());
expect(labels).toEqual(['点赞', '分享', '下载']); expect(labels).toEqual(['分享', '下载']);
// 计数 Intl 分组 + tabular-nums 容器;「连续点击」提示已按用户要求移除。 expect(wrapper.find('.like-slot').exists()).toBe(false);
expect(wrapper.find('.like-count').text()).toBe('1,286'); expect(wrapper.find('.like-count').exists()).toBe(false);
expect(wrapper.find('.like-hint').exists()).toBe(false);
}); });
it('removes the download action entirely when downloads are disabled', () => { it('download disabled leaves share only (like hidden 2026-09-23)', () => {
const wrapper = mountBar({ likeCount: 0, canDownload: false }); const wrapper = mountBar({ likeCount: 0, canDownload: false });
expect(wrapper.find('.download-group').exists()).toBe(false); expect(wrapper.find('.download-group').exists()).toBe(false);
expect(wrapper.findAll('.action-slot')).toHaveLength(2); // 剩余两键均分栏宽 expect(wrapper.findAll('.action-slot')).toHaveLength(1);
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']); expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['分享']);
}); });
it('emits like and share from the large tap targets', async () => { it('emits share from the large tap target (like hidden 2026-09-23)', async () => {
const wrapper = mountBar({ likeCount: 3 }); const wrapper = mountBar({ likeCount: 0 });
const buttons = wrapper.findAll('.action-btn');
expect(buttons).toHaveLength(2);
await buttons[0].trigger('click'); await wrapper.get('.action-btn[aria-label="分享照片"]').trigger('click');
await buttons[1].trigger('click');
expect(wrapper.emitted('like')).toHaveLength(1);
expect(wrapper.emitted('share')).toHaveLength(1); expect(wrapper.emitted('share')).toHaveLength(1);
expect(wrapper.emitted('download')).toBeUndefined(); expect(wrapper.emitted('like')).toBeUndefined();
}); });
it('downloads the browse variant directly when original download is disabled', async () => { it('downloads the browse variant directly when original download is disabled', async () => {
+3 -3
View File
@@ -256,16 +256,16 @@ onBeforeUnmount(() => {
decoding="async" decoding="async"
@error="onImgError(photos[placement.index])" @error="onImgError(photos[placement.index])"
> >
<!-- 点赞角标:独立交互区(@click.stop),不随图片点击打开大图。 --> <!-- 点赞角标:已按用户要求隐藏(2026-09-23,先隐藏点赞功能)。 -->
<button <button
v-if="likes !== undefined" v-if="false && likes !== undefined"
type="button" type="button"
class="cell-like" class="cell-like"
:aria-label="t('viewer.like')" :aria-label="t('viewer.like')"
@click.stop="likeCell(photos[placement.index])" @click.stop="likeCell(photos[placement.index])"
> >
<svg <svg
:key="likes.popNonce(photos[placement.index].photoId)" :key="likes?.popNonce(photos[placement.index].photoId) ?? 0"
class="heart" class="heart"
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true" aria-hidden="true"
@@ -220,29 +220,8 @@ watch(
</div> </div>
</header> </header>
<!-- 排序 tab(§4.2):高 44px;选中蓝字 + 底部 2px 线;大图打开期间锁定。 --> <!-- 排序 tab:热门已按用户要求移除(2026-09-23,其排序依赖点赞数);
<nav class="sort-tabs" :aria-label="t('album.sortLabel')"> 仅剩「最新」单一排序无切换意义 → 整条隐藏(sortMode 恒 latest)。 -->
<button
type="button"
class="sort-tab"
:class="{ active: props.sortMode === 'latest' }"
:aria-pressed="props.sortMode === 'latest'"
:disabled="props.sortLocked"
@click="emit('selectSort', 'latest')"
>
{{ t('viewer.latest') }}
</button>
<button
type="button"
class="sort-tab"
:class="{ active: props.sortMode === 'hot' }"
:aria-pressed="props.sortMode === 'hot'"
:disabled="props.sortLocked"
@click="emit('selectSort', 'hot')"
>
{{ t('viewer.popular') }}
</button>
</nav>
<!-- 分类胶囊:来自接口;无分类省略整条;All = 不传分类筛选。 --> <!-- 分类胶囊:来自接口;无分类省略整条;All = 不传分类筛选。 -->
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')"> <nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
@@ -75,7 +75,9 @@ onBeforeUnmount(cancelPress);
<template> <template>
<div class="action-bar"> <div class="action-bar">
<div class="action-slot like-slot"> <!-- 点赞槽:已按用户要求隐藏(2026-09-23,先隐藏点赞功能——含计数)。
v-if 编译期常量 false,构建后整槽不进 DOM。 -->
<div v-if="false" class="action-slot like-slot">
<button <button
type="button" type="button"
class="action-btn" class="action-btn"
+2 -1
View File
@@ -372,7 +372,8 @@ onBeforeUnmount(() => {
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。大图打开期间改由 <!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。大图打开期间改由
查看器内互动提示承载(§6.2:避免页面级气泡被模态框盖住或遮住下载)。 --> 查看器内互动提示承载(§6.2:避免页面级气泡被模态框盖住或遮住下载)。 -->
<LikeBubbles :bubbles="lightboxPhotoId === null ? realtime.bubbles : []" @open="openFromFeed" /> <!-- 他人点赞气泡:已按用户要求隐藏(2026-09-23,先隐藏点赞功能)。 -->
<LikeBubbles v-if="false" :bubbles="lightboxPhotoId === null ? realtime.bubbles : []" @open="openFromFeed" />
<!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 --> <!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 -->
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p> <p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>