feat(viewer): UI-02 visual foundation, top bar and album info
V01+V02 (Task 2 of the UI-02 viewer redesign plan): - viewer-tokens.css: UI-02 §3.1 design tokens scoped to .viewer-shell (never :root), so embed routes consume none of them - App.vue: normal routes get a sticky white top bar (PhotoLive brand + compact locale switcher + lightweight "..." menu holding the reduce-motion toggle); global 16px content padding removed so banner and photos run edge-to-edge; embed shell branch untouched - LocaleSwitcher: compact "EN/中文 + caret" trigger with a proper menu (current language checked), 44px touch target, Escape/outside close - AlbumHeader (new): banner (2.4:1 mobile, 320px desktop cap, object-fit contain, collapses on missing/failed), 2-line expandable title + share button (clipboard copy with selectable-text fallback), 3-line expandable pre-line description, meta row (Intl-formatted view count with em-dash fallback for unknown, album-timezone dates with full range on title, venue), Latest/Popular 44px tabs and category pills from the API with horizontal scroll + overflow fade - ActivityPage: delegates the info area to AlbumHeader; sequence reset/sort-lock/category-by-id semantics unchanged; desktop content capped at 1120px - i18n: viewer.* strings (brand/share/views/readMore/latest/popular/ allCategories/noPhotos/newPhotos/reduceMotion/settings) in zh-CN+en - tests: viewer-shell (token source + normal/embed isolation) and album-header (8 cases) specs; activity/i18n specs updated for the menu switcher and the em-dash unknown view count; e2e selectors refreshed Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -251,9 +251,10 @@ test('reduced motion suppresses bubbles while counts still calibrate', async ({
|
||||
});
|
||||
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
|
||||
|
||||
// 顶栏开关减少动画(localStorage 偏好持久)。
|
||||
await page.locator('.motion-toggle').click();
|
||||
await expect(page.locator('.motion-toggle')).toHaveAttribute('aria-pressed', 'true');
|
||||
// 顶栏「⋯」菜单内开关减少动画(UI-02 V01;localStorage 偏好持久)。
|
||||
await page.locator('.topbar .icon-btn').click();
|
||||
await page.locator('.prefs-item').click();
|
||||
await expect(page.locator('.prefs-item')).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// 他者点赞:无气泡(动画停用),计数照常被 burst 校准。
|
||||
await apiLike(token, photoIds[0]!, 'e2e-motion-other', 1);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test';
|
||||
// 真实观众旅程(活动页/瀑布流/大图深链)由 Task 6-8/11 补充。
|
||||
test('dev server serves viewer root with top bar and locale switcher', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.brand')).toHaveText(/现场相册|Live Gallery/);
|
||||
await expect(page.locator('button.locale-switcher')).toBeVisible();
|
||||
// UI-02 V01:品牌为英文 PhotoLive(双语一致),语言切换为紧凑控件。
|
||||
await expect(page.locator('.brand-name')).toHaveText('PhotoLive');
|
||||
await expect(page.locator('.locale-trigger')).toBeVisible();
|
||||
});
|
||||
|
||||
+192
-52
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LocaleSwitcher from './components/LocaleSwitcher.vue';
|
||||
import { reducedMotion, toggleReducedMotion } from './ui/prefs';
|
||||
// UI-02 V01 视觉 token:变量全部作用域在 .viewer-shell 类上(非 :root 全局),
|
||||
// embed 分支不挂该类 → 嵌入 iframe 不消费任何 token(隔离红线)。
|
||||
import './styles/viewer-tokens.css';
|
||||
|
||||
const route = useRoute();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -17,36 +20,90 @@ watchEffect(() => {
|
||||
document.title = t('app.title');
|
||||
document.documentElement.setAttribute('lang', locale.value);
|
||||
});
|
||||
|
||||
// —— 顶栏轻量菜单(UI-02 §3.2):减少动画入口收进语言旁「⋯」,可发现但不占整行 ——
|
||||
const prefsOpen = ref(false);
|
||||
const prefsRootEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function closePrefs(): void {
|
||||
prefsOpen.value = false;
|
||||
}
|
||||
|
||||
function onPrefsKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') closePrefs();
|
||||
}
|
||||
|
||||
function onOuterPointerDown(event: PointerEvent): void {
|
||||
if (prefsOpen.value && prefsRootEl.value !== null && !prefsRootEl.value.contains(event.target as Node)) {
|
||||
closePrefs();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onOuterPointerDown, true);
|
||||
document.addEventListener('keydown', onPrefsKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onOuterPointerDown, true);
|
||||
document.removeEventListener('keydown', onPrefsKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isEmbedLayout" class="embed-shell">
|
||||
<RouterView />
|
||||
</div>
|
||||
<div v-else class="app">
|
||||
<div v-else class="viewer-shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">{{ t('app.title') }}</span>
|
||||
<span class="brand">
|
||||
<svg class="brand-logo" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="1" y="1" width="22" height="22" rx="6" fill="var(--viewer-primary)" />
|
||||
<circle cx="12" cy="12" r="5.5" fill="none" stroke="#FFFFFF" stroke-width="2" />
|
||||
<circle cx="18" cy="6" r="1.6" fill="#FFFFFF" />
|
||||
</svg>
|
||||
<span class="brand-name">{{ t('viewer.brand') }}</span>
|
||||
</span>
|
||||
<div class="topbar-actions">
|
||||
<!-- 减少动画(Task 10):隐藏点赞气泡/心跳动画,计数照常更新。 -->
|
||||
<button
|
||||
type="button"
|
||||
class="motion-toggle"
|
||||
:aria-pressed="reducedMotion"
|
||||
@click="toggleReducedMotion"
|
||||
>
|
||||
{{ t('prefs.reduceMotion') }}
|
||||
</button>
|
||||
<LocaleSwitcher />
|
||||
<div ref="prefsRootEl" class="prefs-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:aria-label="t('viewer.settings')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="prefsOpen"
|
||||
@click="prefsOpen = !prefsOpen"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="5" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="prefsOpen" class="prefs-popover" role="menu" :aria-label="t('viewer.settings')">
|
||||
<button
|
||||
type="button"
|
||||
class="prefs-item"
|
||||
role="menuitemcheckbox"
|
||||
:aria-checked="reducedMotion"
|
||||
@click="toggleReducedMotion"
|
||||
>
|
||||
<span class="prefs-check" aria-hidden="true">{{ reducedMotion ? '✓' : '' }}</span>
|
||||
{{ t('viewer.reduceMotion') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<main class="viewer-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 极简基线样式:视觉打磨留待 P4a */
|
||||
/* 基线重置(embed 共存安全:仅盒模型/边距/字体兜底,无视觉决策)。 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -55,46 +112,129 @@ body {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
color: #1f2328;
|
||||
}
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.motion-toggle {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.motion-toggle[aria-pressed='true'] {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
/* 嵌入页外壳:无内边距/最小高度(iframe 内容高度由 resize 上报驱动)。 */
|
||||
.embed-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* —— UI-02 V01 普通观众外壳:所有规则作用域在 .viewer-shell 下(隔离红线) —— */
|
||||
.viewer-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--viewer-font-family);
|
||||
color: var(--viewer-text);
|
||||
background: var(--viewer-surface);
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
padding: 0 var(--viewer-pad-info);
|
||||
border-bottom: 1px solid var(--viewer-divider);
|
||||
background: var(--viewer-surface);
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--viewer-text);
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.prefs-menu {
|
||||
position: relative;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--viewer-touch-target);
|
||||
height: var(--viewer-touch-target);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--viewer-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--viewer-page-bg);
|
||||
}
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.icon-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.prefs-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
right: 0;
|
||||
z-index: 130;
|
||||
min-width: 176px;
|
||||
border: 1px solid var(--viewer-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--viewer-surface);
|
||||
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
|
||||
padding: 4px;
|
||||
display: grid;
|
||||
}
|
||||
.prefs-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--viewer-touch-target);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--viewer-text);
|
||||
padding: 0 12px;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.prefs-item:hover {
|
||||
background: var(--viewer-page-bg);
|
||||
}
|
||||
.prefs-item:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.prefs-check {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
color: var(--viewer-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
/* V01 §3.2:内容页不再有全局 16px 包围——宣传图/照片边到边,内边距由
|
||||
* 信息区(AlbumHeader)与照片区(PhotoFeed,Task 3)各自负责。 */
|
||||
.viewer-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.viewer-shell {
|
||||
background: var(--viewer-page-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import App from '../App.vue';
|
||||
import { ApiError, getPublicAlbum, type PublicAlbum } from '../api/client';
|
||||
import { ApiError, getPublicAlbum, getPublicPhotos, type PublicAlbum } from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import ActivityPage from '../pages/ActivityPage.vue';
|
||||
import { router } from '../router';
|
||||
@@ -32,6 +32,7 @@ vi.mock('../realtime/ws', () => {
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
|
||||
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
||||
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
|
||||
@@ -51,6 +52,14 @@ function mountApp(): VueWrapper {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** UI-02 V01 紧凑语言控件:先展开菜单再点目标语言选项(SUPPORTED_LOCALES 序)。 */
|
||||
async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'): Promise<void> {
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
const target = wrapper.findAll('.locale-option').find((btn) => btn.text() === option);
|
||||
expect(target).toBeDefined();
|
||||
await target!.trigger('click');
|
||||
}
|
||||
|
||||
describe('activity page (Task 6)', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
@@ -83,9 +92,9 @@ describe('activity page (Task 6)', () => {
|
||||
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 未知(null)→ 浏览项显示「—」而非 0(UI-02 §4.1)。
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('.view-count').exists()).toBe(false);
|
||||
expect(wrapper.find('.view-count').text()).toBe('—');
|
||||
expect(document.title).toBe('2026 公司年会');
|
||||
});
|
||||
|
||||
@@ -125,7 +134,7 @@ describe('activity page (Task 6)', () => {
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await pickLanguage(wrapper, 'English');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en');
|
||||
@@ -220,4 +229,48 @@ describe('activity page (Task 6)', () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
|
||||
});
|
||||
|
||||
// —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 ——
|
||||
|
||||
it('selecting the Popular tab refetches the sequence with sort=hot (active state moves)', async () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
await wrapper.findAll('.categories .chip')[1].trigger('click'); // 「签到墙」= cat-sign
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ category: 'cat-sign' }));
|
||||
|
||||
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
|
||||
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
|
||||
);
|
||||
i18n.global.locale.value = 'en';
|
||||
await flushPromises();
|
||||
|
||||
// 语言只换显示与内容请求,分类 id 跨语言保留(§4.2)。
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-activity',
|
||||
expect.objectContaining({ category: 'cat-sign', locale: 'en' }),
|
||||
);
|
||||
const chips = wrapper.findAll('.categories .chip');
|
||||
expect(chips[1].classes()).toContain('active');
|
||||
expect(chips[1].text()).toBe('Check-in Wall');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import AlbumHeader from '../components/album/AlbumHeader.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// UI-02 V02 AlbumHeader 单测:字段渲染/缺失省略、浏览数 Intl 格式与未知回退、
|
||||
// 相册时区日期、标题/说明钳制展开、排序 tab 与分类胶囊事件、分享复制。
|
||||
// (筛选决策与序列重置在 activity.spec.ts 集成覆盖。)
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountHeader(props: {
|
||||
album: ReturnType<typeof makeAlbum>;
|
||||
viewCount: number | null;
|
||||
sortMode?: 'latest' | 'hot';
|
||||
selectedCategory?: string | null;
|
||||
sortLocked?: boolean;
|
||||
}): VueWrapper {
|
||||
const wrapper = mount(AlbumHeader, {
|
||||
props: {
|
||||
sortMode: 'latest',
|
||||
selectedCategory: null,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [router, i18n] },
|
||||
});
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function stubClipboard(writeText: (text: string) => Promise<void>): MockInstance {
|
||||
const fn = vi.fn(writeText);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: fn },
|
||||
configurable: true,
|
||||
});
|
||||
return fn as unknown as MockInstance;
|
||||
}
|
||||
|
||||
describe('album header info area (UI-02 V02)', () => {
|
||||
let scrollSpy: MockInstance | null = null;
|
||||
let clientSpy: MockInstance | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
scrollSpy?.mockRestore();
|
||||
scrollSpy = null;
|
||||
clientSpy?.mockRestore();
|
||||
clientSpy = null;
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
});
|
||||
|
||||
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => {
|
||||
const album = makeAlbum({
|
||||
banner: { url: 'http://127.0.0.1:9000/photo-live/dev/albums/a1/assets/banner.jpg?sig=1', expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
viewCount: 1286,
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: 1286 });
|
||||
|
||||
const img = wrapper.find('img.banner');
|
||||
expect(img.exists()).toBe(true);
|
||||
expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
// pre-line:换行保留;文本插值(非 HTML 注入)。
|
||||
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
|
||||
// Intl.NumberFormat 分组 + 词条拼接(§4.1)。
|
||||
expect(wrapper.find('.view-count').text()).toBe('1,286 次浏览');
|
||||
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
// Asia/Shanghai(夹具时区):UTC 10-01 → 本地 10月1日。
|
||||
expect(wrapper.find('.meta').text()).toContain('10月1日');
|
||||
|
||||
// 工具栏:排序 tab + 分类胶囊(All + 接口分类)。
|
||||
expect(wrapper.findAll('.sort-tab').map((tab) => tab.text())).toEqual(['最新', '热门']);
|
||||
expect(wrapper.findAll('.categories .chip').map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
|
||||
// jsdom 无布局:未溢出 → 不渲染展开按钮。
|
||||
expect(wrapper.findAll('.text-expand')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('omits missing fields naturally: no banner/description/venue, views shows —, no categories row', () => {
|
||||
const album = makeAlbum({
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
categories: [],
|
||||
translation: { locale: 'zh-CN', title: '无字段活动', description: null, venue: null, address: null },
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: null });
|
||||
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('.description').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.meta-item')).toHaveLength(2); // 浏览(—)+ 日期;地点整项省略
|
||||
expect(wrapper.find('.view-count').text()).toBe('—');
|
||||
expect(wrapper.find('.categories').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses the banner on image load failure (info area stays usable)', async () => {
|
||||
const album = makeAlbum({
|
||||
banner: { url: 'http://127.0.0.1:9000/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' },
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: 5 });
|
||||
expect(wrapper.find('img.banner').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('img.banner').trigger('error');
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('h1.title').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('formats the date in the album timezone (not the device timezone) with full range on title', () => {
|
||||
// 测试机时区任意(含 UTC+8):按设备时区会得到 10月1日——断言纽约日界回退。
|
||||
const wrapper = mountHeader({ album: makeAlbum({ timezone: 'America/New_York' }), viewCount: null });
|
||||
|
||||
const dateItem = wrapper.find('.meta-item[title]');
|
||||
expect(dateItem.exists()).toBe(true);
|
||||
// UTC 10-01 02:00/12:00 → New York(EDT)09-30 22:00 / 10-01 08:00。
|
||||
expect(dateItem.text()).toContain('9月30');
|
||||
expect(dateItem.text()).toContain('10月1');
|
||||
const full = dateItem.attributes('title');
|
||||
expect(full).toContain('22:00');
|
||||
expect(full).toContain('08:00');
|
||||
expect(full).toContain('GMT-4');
|
||||
});
|
||||
|
||||
it('expands and collapses the clamped description and title (buttons only when overflowing)', async () => {
|
||||
scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120);
|
||||
clientSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(60);
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
await nextTick(); // 溢出测量在 post watcher 中执行,等微任务落定
|
||||
|
||||
const expands = wrapper.findAll('.text-expand');
|
||||
expect(expands).toHaveLength(2); // 标题 + 说明均溢出
|
||||
expect(wrapper.find('.title').classes()).toContain('clamped');
|
||||
expect(wrapper.find('.description').classes()).toContain('clamped');
|
||||
|
||||
await expands[1].trigger('click'); // 说明「展开」
|
||||
expect(wrapper.find('.description').classes()).not.toContain('clamped');
|
||||
expect(wrapper.find('.title').classes()).toContain('clamped');
|
||||
expect(wrapper.findAll('.text-expand')[1].text()).toBe('收起');
|
||||
|
||||
await wrapper.findAll('.text-expand')[1].trigger('click'); // 说明「收起」
|
||||
expect(wrapper.find('.description').classes()).toContain('clamped');
|
||||
|
||||
await wrapper.findAll('.text-expand')[0].trigger('click'); // 标题「展开」
|
||||
expect(wrapper.find('.title').classes()).not.toContain('clamped');
|
||||
});
|
||||
|
||||
it('sort tabs: latest active by default, clicking popular emits select-sort hot, locked disables', async () => {
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
const tabs = wrapper.findAll('.sort-tab');
|
||||
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 () => {
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null, selectedCategory: 'cat-sign' });
|
||||
|
||||
const chips = wrapper.findAll('.categories .chip');
|
||||
expect(chips[1].classes()).toContain('active');
|
||||
expect(chips[1].attributes('aria-pressed')).toBe('true');
|
||||
expect(chips[0].classes()).not.toContain('active');
|
||||
|
||||
await chips[2].trigger('click');
|
||||
await chips[0].trigger('click');
|
||||
expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]);
|
||||
});
|
||||
|
||||
it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => {
|
||||
const writeText = stubClipboard(() => Promise.resolve());
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
|
||||
expect(wrapper.find('.share-url').exists()).toBe(false);
|
||||
|
||||
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
|
||||
writeText.mockImplementation(() => Promise.reject(new Error('denied')));
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(wrapper.find('.share-status').text()).toContain('复制失败');
|
||||
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
});
|
||||
});
|
||||
@@ -28,15 +28,27 @@ describe('viewer i18n', () => {
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(document.title).toBe('现场相册');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
// UI-02 V01 紧凑语言控件:展开菜单 → 选择 English。
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
await nextTick();
|
||||
const english = wrapper.findAll('.locale-option').find((btn) => btn.text() === 'English');
|
||||
expect(english).toBeDefined();
|
||||
await english!.trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('Open an album link to start viewing');
|
||||
expect(document.title).toBe('Live Gallery');
|
||||
expect(document.documentElement.getAttribute('lang')).toBe('en');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('en');
|
||||
|
||||
// 切回 zh-CN 同样生效
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
// 切回 zh-CN 同样生效(菜单内当前语言 English 为勾选态)。
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
await nextTick();
|
||||
const options = wrapper.findAll('.locale-option');
|
||||
const englishNow = options.find((btn) => btn.text() === 'English');
|
||||
expect(englishNow!.attributes('aria-checked')).toBe('true');
|
||||
const chinese = options.find((btn) => btn.text() === '中文');
|
||||
expect(chinese!.attributes('aria-checked')).toBe('false');
|
||||
await chinese!.trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
|
||||
// UI-02 V01 外壳与视觉 token:
|
||||
// · token 断言直接读 viewer-tokens.css 源文件(vitest 不加载样式表),
|
||||
// 作用域正确性由渲染分支验证——普通路由挂 .viewer-shell(token 生效),
|
||||
// embed 路由走 .embed-shell 裸分支(不挂类 → 不消费任何 token,隔离红线);
|
||||
// · 顶栏:品牌 PhotoLive + 紧凑语言控件 + 「⋯」轻量菜单内的减少动画入口。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
// embed 路由数据:404 → 静默 gone(无 UI、无轮询),外壳分支足以验证。
|
||||
// ApiError 经 actual 命名空间构造(工厂被提升,不能引用顶层 import 绑定)。
|
||||
fetchPublicEmbedSlot: vi.fn().mockRejectedValue(
|
||||
new actual.ApiError('EMBED_SLOT_NOT_FOUND', 'slot gone', 404, 'req-shell'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
let mounted: VueWrapper | null = null;
|
||||
|
||||
function mountApp(): VueWrapper {
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
mounted = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('viewer shell tokens and embed isolation (UI-02 V01)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount();
|
||||
mounted = null;
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('declares all UI-02 §3.1 tokens scoped to .viewer-shell (not :root/global)', () => {
|
||||
const cssPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'styles', 'viewer-tokens.css');
|
||||
const css = readFileSync(cssPath, 'utf8');
|
||||
const expected: Record<string, string> = {
|
||||
'--viewer-primary': '#1A73E8',
|
||||
'--viewer-surface': '#FFFFFF',
|
||||
'--viewer-page-bg': '#F3F6FA',
|
||||
'--viewer-text': '#172033',
|
||||
'--viewer-text-secondary': '#647084',
|
||||
'--viewer-divider': '#E5EAF1',
|
||||
'--viewer-dark-bg': '#0E1218',
|
||||
'--viewer-like-accent': '#FF667D',
|
||||
'--viewer-font-family': "system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif",
|
||||
'--viewer-radius-img': '4px',
|
||||
'--viewer-radius-pill': '20px',
|
||||
'--viewer-pad-info': '16px',
|
||||
'--viewer-pad-photo': '4px',
|
||||
'--viewer-touch-target': '44px',
|
||||
};
|
||||
for (const [name, value] of Object.entries(expected)) {
|
||||
expect(css, `${name} token`).toContain(`${name}: ${value};`);
|
||||
}
|
||||
// 不得把 token 泄漏到全局根(embed iframe 会继承 :root 变量)。
|
||||
expect(css).not.toContain(':root');
|
||||
expect(css).toContain('.viewer-shell');
|
||||
});
|
||||
|
||||
it('normal routes render .viewer-shell with PhotoLive brand top bar and prefs menu', async () => {
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
const app = mountApp();
|
||||
|
||||
expect(app.find('.viewer-shell').exists()).toBe(true);
|
||||
expect(app.find('.embed-shell').exists()).toBe(false);
|
||||
|
||||
const topbar = app.find('header.topbar');
|
||||
expect(topbar.exists()).toBe(true);
|
||||
expect(topbar.find('.brand-name').text()).toBe('PhotoLive');
|
||||
expect(topbar.find('.locale-trigger').exists()).toBe(true);
|
||||
|
||||
// 「⋯」轻量菜单(§3.2):减少动画入口可发现、可切换。
|
||||
const more = topbar.find('.icon-btn');
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu');
|
||||
await more.trigger('click');
|
||||
const item = app.find('.prefs-item');
|
||||
expect(item.text()).toBe('减少动画');
|
||||
const before = item.attributes('aria-checked');
|
||||
await item.trigger('click');
|
||||
expect(app.find('.prefs-item').attributes('aria-checked')).toBe(before === 'true' ? 'false' : 'true');
|
||||
// 还原模块级偏好单例,避免影响后续用例。
|
||||
await app.find('.prefs-item').trigger('click');
|
||||
});
|
||||
|
||||
it('embed routes keep the bare shell: no .viewer-shell, no top bar, no outer padding wrapper', async () => {
|
||||
await router.push('/embed/slot-shell-test');
|
||||
await router.isReady();
|
||||
const app = mountApp();
|
||||
await flushPromises();
|
||||
|
||||
expect(app.find('.embed-shell').exists()).toBe(true);
|
||||
expect(app.find('.viewer-shell').exists()).toBe(false);
|
||||
expect(app.find('header.topbar').exists()).toBe(false);
|
||||
expect(app.find('.viewer-main').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCALE_STORAGE_KEY, type ViewerLocale } from '../i18n';
|
||||
import { LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, type ViewerLocale } from '../i18n';
|
||||
|
||||
// UI-02 V01 紧凑语言控件:顶栏右侧「当前语言代码 ▾」,点击弹出小菜单列出
|
||||
// 全部语言(当前项勾选态)。44px 触控区(§3.1);Esc / 点击外部收起。
|
||||
// 切换仍直接写当前 composer(与注入的 i18n 实例解耦,测试可挂独立实例),
|
||||
// 持久化随切换写入,写失败不阻断。
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const nextLocale = computed<ViewerLocale>(() => (locale.value === 'zh-CN' ? 'en' : 'zh-CN'));
|
||||
// 按钮始终显示目标语言的自称,便于不识当前语言的用户发现
|
||||
const nextLabel = computed(() => (nextLocale.value === 'zh-CN' ? '中文' : 'English'));
|
||||
const open = ref(false);
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
|
||||
// 直接写当前 composer(可写 locale):与注入的 i18n 实例解耦——组件测试可挂载
|
||||
// 独立实例而不被运行时单例牵制。持久化随切换写入,写失败不阻断。
|
||||
// 注意先取 next 再赋值:赋值后 nextLocale 已重算为目标的对侧。
|
||||
function switchLocale(): void {
|
||||
const next = nextLocale.value;
|
||||
const currentLabel = computed(() => (locale.value === 'zh-CN' ? '中文' : 'EN'));
|
||||
|
||||
function switchLocale(next: ViewerLocale): void {
|
||||
locale.value = next;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// 忽略持久化失败
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') open.value = false;
|
||||
}
|
||||
|
||||
function onOuterPointerDown(event: PointerEvent): void {
|
||||
if (open.value && rootEl.value !== null && !rootEl.value.contains(event.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onOuterPointerDown, true);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onOuterPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="locale-switcher" :aria-label="t('lang.switch')" @click="switchLocale">
|
||||
{{ nextLabel }}
|
||||
</button>
|
||||
<div ref="rootEl" class="locale-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="locale-trigger"
|
||||
:aria-label="t('lang.switch')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
@click="open = !open"
|
||||
>
|
||||
{{ currentLabel }}
|
||||
<span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div v-if="open" class="locale-menu" role="menu" :aria-label="t('lang.switch')">
|
||||
<button
|
||||
v-for="lang in SUPPORTED_LOCALES"
|
||||
:key="lang"
|
||||
type="button"
|
||||
class="locale-option"
|
||||
role="menuitemradio"
|
||||
:aria-checked="locale === lang"
|
||||
:lang="lang"
|
||||
@click="switchLocale(lang)"
|
||||
>
|
||||
{{ lang === 'zh-CN' ? '中文' : 'English' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* token 经祖先 .viewer-shell 提供;独立挂载(无 shell)时回退同值。 */
|
||||
.locale-switcher {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
}
|
||||
.locale-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--viewer-touch-target, 44px);
|
||||
min-width: var(--viewer-touch-target, 44px);
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--viewer-radius-pill, 20px);
|
||||
background: transparent;
|
||||
color: var(--viewer-text, #172033);
|
||||
padding: 0 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.locale-trigger:hover {
|
||||
background: var(--viewer-page-bg, #f3f6fa);
|
||||
}
|
||||
.locale-trigger:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.caret {
|
||||
font-size: 0.625rem;
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
}
|
||||
.locale-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 130;
|
||||
min-width: 128px;
|
||||
border: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
border-radius: 12px;
|
||||
background: var(--viewer-surface, #fff);
|
||||
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
|
||||
padding: 4px;
|
||||
display: grid;
|
||||
}
|
||||
.locale-option {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--viewer-text, #172033);
|
||||
min-height: var(--viewer-touch-target, 44px);
|
||||
padding: 0 12px;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.locale-option[aria-checked='true'] {
|
||||
color: var(--viewer-primary, #1a73e8);
|
||||
font-weight: 600;
|
||||
}
|
||||
.locale-option:hover {
|
||||
background: var(--viewer-page-bg, #f3f6fa);
|
||||
}
|
||||
.locale-option:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PublicAlbum, SortMode } from '../../api/client';
|
||||
|
||||
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
||||
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
||||
// (ActivityPage 持有序列):本组件只做展示与事件上抛(select-sort /
|
||||
// select-category),语言切换由父级重取内容,选中分类以 id 跨语言保留。
|
||||
//
|
||||
// 数据诚实(方案 §4.1):
|
||||
// · viewCount 未知(null)显示「—」而非 0;已知值经 Intl.NumberFormat 分组;
|
||||
// · 日期按相册 IANA 时区格式化(紧凑日期;完整起止+时区放 title 提示),
|
||||
// 不悄悄换成设备时区;非法时区名回退本地时区(沿用原策略);
|
||||
// · 说明/场地/地址/宣传图缺失时自然省略节点,不渲染 null/undefined。
|
||||
const props = defineProps<{
|
||||
album: PublicAlbum;
|
||||
/** 浏览数权威值(album 初值 + /visits 与 stats.updated 校准),null=未知。 */
|
||||
viewCount: number | null;
|
||||
sortMode: SortMode;
|
||||
selectedCategory: string | null;
|
||||
/** 大图打开期间锁定排序(父级序列不变序,§4.2)。 */
|
||||
sortLocked?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSort: [mode: SortMode];
|
||||
selectCategory: [categoryId: string | null];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// —— 宣传图:签名 URL 加载失败后收起(布局自适应,活动信息仍可用) ——
|
||||
const bannerFailed = ref(false);
|
||||
|
||||
const bannerUrl = computed(() => (props.album.banner !== null && !bannerFailed.value ? props.album.banner.url : null));
|
||||
|
||||
// —— 标题/说明的行数钳制与展开(§4.1:默认 2/3 行,超出可展开收起) ——
|
||||
const titleEl = ref<HTMLElement | null>(null);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
const titleClamped = ref(false);
|
||||
const descClamped = ref(false);
|
||||
const titleExpanded = ref(false);
|
||||
const descExpanded = ref(false);
|
||||
|
||||
function isClamped(el: HTMLElement | null): boolean {
|
||||
// -webkit-line-clamp 不改 scrollHeight:溢出时 scrollHeight > clientHeight。
|
||||
return el !== null && el.scrollHeight - el.clientHeight > 1;
|
||||
}
|
||||
|
||||
function measureOverflow(): void {
|
||||
// 展开态保持按钮可见(供「收起」);收起态按实际溢出决定是否显示「展开」。
|
||||
titleClamped.value = titleExpanded.value || isClamped(titleEl.value);
|
||||
descClamped.value = descExpanded.value || isClamped(descEl.value);
|
||||
}
|
||||
|
||||
// 相册/语言变化(文案长度随之变化)后:复位宣传图失败与展开态,重测溢出。
|
||||
watch(
|
||||
[() => props.album, locale],
|
||||
() => {
|
||||
bannerFailed.value = false;
|
||||
titleExpanded.value = false;
|
||||
descExpanded.value = false;
|
||||
measureOverflow();
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
// 元素挂载与展开/收起切换后重测(收起后按内容恢复钳制检测)。
|
||||
watch([titleEl, descEl, titleExpanded, descExpanded], () => measureOverflow(), { flush: 'post' });
|
||||
|
||||
// —— 元信息行 ——
|
||||
const viewsText = computed(() => {
|
||||
if (props.viewCount === null) return t('viewer.viewsUnknown');
|
||||
return t('viewer.views', { count: new Intl.NumberFormat(locale.value).format(props.viewCount) });
|
||||
});
|
||||
|
||||
/** 紧凑日期(无时分):首页元信息行用;窄屏也不爆宽。 */
|
||||
const dateText = computed(() => formatDate({ year: 'numeric', month: 'long', day: 'numeric' }));
|
||||
|
||||
/** 完整起止 + 时区:title 提示承载(§4.1「详情可查看完整起止与时区」)。 */
|
||||
const dateFullText = computed(() =>
|
||||
formatDate({
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
function formatDate(extra: Intl.DateTimeFormatOptions): string {
|
||||
const { timezone, startAt, endAt } = props.album;
|
||||
const format = (value: string, withZone: boolean): string => {
|
||||
const formatter = new Intl.DateTimeFormat(
|
||||
locale.value,
|
||||
withZone ? { ...extra, timeZone: timezone } : extra,
|
||||
);
|
||||
return formatter.format(new Date(value));
|
||||
};
|
||||
try {
|
||||
const start = format(startAt, true);
|
||||
return endAt === null ? start : `${start} – ${format(endAt, true)}`;
|
||||
} catch {
|
||||
// 非法时区名(RangeError):去掉 timeZone 按本地时区展示,优于整块失败。
|
||||
const start = format(startAt, false);
|
||||
return endAt === null ? start : `${start} – ${format(endAt, false)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 场地 + 地址拼接(任一缺失时只展示存在的部分;全缺 → 整项省略)。 */
|
||||
const venueText = computed(() => {
|
||||
const { venue, address } = props.album.translation;
|
||||
return [venue, address]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
const descriptionText = computed(() => props.album.translation.description);
|
||||
|
||||
// —— 分享(V02 最小实现:复制相册链接;navigator.share 完整编排属 Task 5) ——
|
||||
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
|
||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
|
||||
return new URL(path, window.location.origin).href;
|
||||
});
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText !== undefined) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 走传统复制兜底
|
||||
}
|
||||
try {
|
||||
const area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.position = 'fixed';
|
||||
area.style.opacity = '0';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
const copied = document.execCommand('copy');
|
||||
area.remove();
|
||||
return copied;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function share(): Promise<void> {
|
||||
const copied = await copyText(albumUrl.value);
|
||||
shareState.value = copied ? 'copied' : 'failed';
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
|
||||
if (copied) {
|
||||
shareResetTimer = setTimeout(() => {
|
||||
shareState.value = 'idle';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
|
||||
const chipsEl = ref<HTMLElement | null>(null);
|
||||
const chipsOverflow = ref(false);
|
||||
|
||||
function updateChipsOverflow(): void {
|
||||
const el = chipsEl.value;
|
||||
if (el === null) {
|
||||
chipsOverflow.value = false;
|
||||
return;
|
||||
}
|
||||
chipsOverflow.value = el.scrollWidth - el.clientWidth > 1 && el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
[chipsEl, () => props.album.categories, () => props.selectedCategory],
|
||||
() => updateChipsOverflow(),
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="album-header">
|
||||
<!-- 宣传图:全宽 contain(不裁海报文字);无图/加载失败整块收起。 -->
|
||||
<div v-if="bannerUrl !== null" class="banner-wrap">
|
||||
<img
|
||||
class="banner"
|
||||
:src="bannerUrl"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<header class="album-info">
|
||||
<div class="title-row">
|
||||
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
|
||||
<button type="button" class="share-btn" @click="share">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 8 5-5 5 5" />
|
||||
<path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
|
||||
</svg>
|
||||
<span>{{ t('viewer.share') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
|
||||
{{ titleExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
|
||||
</button>
|
||||
<span v-if="shareState !== 'idle'" class="share-status" role="status">
|
||||
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
|
||||
<span v-if="shareState === 'failed'" class="share-url">{{ albumUrl }}</span>
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="descriptionText !== null"
|
||||
ref="descEl"
|
||||
class="description"
|
||||
:class="{ clamped: !descExpanded }"
|
||||
>
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
<button v-if="descClamped" type="button" class="text-expand" @click="descExpanded = !descExpanded">
|
||||
{{ descExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
|
||||
</button>
|
||||
|
||||
<div class="meta">
|
||||
<span class="meta-item view-count">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>{{ viewsText }}</span>
|
||||
</span>
|
||||
<span v-if="dateText !== ''" class="meta-item" :title="dateFullText">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M8 3v4M16 3v4M3 10h18" />
|
||||
</svg>
|
||||
<span>{{ dateText }}</span>
|
||||
</span>
|
||||
<span v-if="venueText !== ''" class="meta-item">
|
||||
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 21s-7-5.1-7-11a7 7 0 0 1 14 0c0 5.9-7 11-7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.5" />
|
||||
</svg>
|
||||
<span>{{ venueText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 排序 tab(§4.2):高 44px;选中蓝字 + 底部 2px 线;大图打开期间锁定。 -->
|
||||
<nav class="sort-tabs" :aria-label="t('album.sortLabel')">
|
||||
<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 = 不传分类筛选。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<div ref="chipsEl" class="chips-track" @scroll.passive="updateChipsOverflow">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: props.selectedCategory === null }"
|
||||
:aria-pressed="props.selectedCategory === null"
|
||||
@click="emit('selectCategory', null)"
|
||||
>
|
||||
{{ t('viewer.allCategories') }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in album.categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: props.selectedCategory === category.id }"
|
||||
:aria-pressed="props.selectedCategory === category.id"
|
||||
@click="emit('selectCategory', category.id)"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="chipsOverflow" class="chips-fade" aria-hidden="true" />
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* token 经祖先 .viewer-shell 提供;独立挂载(无 shell)时回退同值。 */
|
||||
.album-header {
|
||||
background: var(--viewer-surface, #fff);
|
||||
}
|
||||
|
||||
/* —— 宣传图:手机约 2.4:1;桌面限高 320px;contain 保海报文字(§3.3) —— */
|
||||
.banner-wrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 2.4 / 1;
|
||||
max-height: 320px;
|
||||
background: var(--viewer-surface, #fff);
|
||||
}
|
||||
.banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* —— 信息区:16px 内边距由本区负责(页面无全局包围,§3.2) —— */
|
||||
.album-info {
|
||||
padding: 12px var(--viewer-pad-info, 16px) 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
color: var(--viewer-text, #172033);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.title.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.share-btn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--viewer-touch-target, 44px);
|
||||
margin: -6px calc(var(--viewer-pad-info, 16px) * -1) -6px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--viewer-primary, #1a73e8);
|
||||
padding: 0 6px;
|
||||
align-self: flex-start;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.share-btn:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.share-status {
|
||||
display: block;
|
||||
margin: 2px 0 0;
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.share-url {
|
||||
user-select: text;
|
||||
color: var(--viewer-primary, #1a73e8);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.text-expand {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--viewer-primary, #1a73e8);
|
||||
padding: 2px 0 0;
|
||||
margin: 2px 0 0;
|
||||
min-height: 32px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-expand:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* —— 说明:≤3 行 + 展开/收起;保留换行(pre-line),文本插值非 HTML 注入 —— */
|
||||
.description {
|
||||
margin: 8px 0 0;
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.description.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— 元信息行:窄屏自然换行(§4.1);数字 tabular-nums —— */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.meta-item span {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.meta-icon {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.view-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* —— 排序 tab —— */
|
||||
.sort-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
}
|
||||
.sort-tab {
|
||||
position: relative;
|
||||
height: var(--viewer-touch-target, 44px);
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 2px;
|
||||
color: var(--viewer-text-secondary, #647084);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sort-tab.active {
|
||||
color: var(--viewer-primary, #1a73e8);
|
||||
}
|
||||
/* 选中底部 2px 线(§4.2) */
|
||||
.sort-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--viewer-primary, #1a73e8);
|
||||
}
|
||||
.sort-tab:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.sort-tab:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* —— 分类胶囊:横向滚动 + 右缘溢出提示 —— */
|
||||
.categories {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chips-track {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 2px var(--viewer-pad-info, 16px) 6px;
|
||||
margin: -2px calc(var(--viewer-pad-info, 16px) * -1) 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chip {
|
||||
flex: none;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
border-radius: var(--viewer-radius-pill, 20px);
|
||||
background: var(--viewer-surface, #fff);
|
||||
color: var(--viewer-text, #172033);
|
||||
padding: 0 16px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: var(--viewer-primary, #1a73e8);
|
||||
background: var(--viewer-primary, #1a73e8);
|
||||
color: var(--viewer-surface, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 右缘渐隐提示仍有未滚出的分类(仅确有溢出且未滚到底时出现) */
|
||||
.chips-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 36px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--viewer-surface, #fff) 78%);
|
||||
}
|
||||
</style>
|
||||
@@ -63,6 +63,22 @@ export default {
|
||||
// 轮播中被移除(ws photo.hidden)的轻提示。
|
||||
photoRemoved: 'This photo has been removed',
|
||||
backToAlbum: 'Back to album',
|
||||
// —— UI-02 (V01/V02) additions ——
|
||||
brand: 'PhotoLive',
|
||||
settings: 'Settings',
|
||||
share: 'Share',
|
||||
shareCopied: 'Link copied',
|
||||
shareCopyFailed: 'Copy failed — please copy the link manually:',
|
||||
views: '{count} views',
|
||||
viewsUnknown: '—',
|
||||
readMore: 'More',
|
||||
readLess: 'Less',
|
||||
latest: 'Latest',
|
||||
popular: 'Popular',
|
||||
allCategories: 'All',
|
||||
noPhotos: 'Photos coming soon',
|
||||
newPhotos: 'New photos',
|
||||
reduceMotion: 'Reduce motion',
|
||||
},
|
||||
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||
errors: {
|
||||
|
||||
@@ -63,6 +63,22 @@ export default {
|
||||
// PHOTO_NOT_PUBLIC 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
|
||||
photoUnavailable: '照片不存在或已下线',
|
||||
backToAlbum: '返回相册',
|
||||
// —— UI-02(V01/V02)新增 ——
|
||||
brand: 'PhotoLive',
|
||||
settings: '设置',
|
||||
share: '分享',
|
||||
shareCopied: '链接已复制',
|
||||
shareCopyFailed: '复制失败,请手动复制链接:',
|
||||
views: '{count} 次浏览',
|
||||
viewsUnknown: '—',
|
||||
readMore: '展开',
|
||||
readLess: '收起',
|
||||
latest: '最新',
|
||||
popular: '热门',
|
||||
allCategories: '全部',
|
||||
noPhotos: '照片即将上线',
|
||||
newPhotos: '有新照片',
|
||||
reduceMotion: '减少动画',
|
||||
},
|
||||
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||
errors: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import { useDownload } from '../download/useDownload';
|
||||
import AlbumHeader from '../components/album/AlbumHeader.vue';
|
||||
import LikeBubbles from '../components/LikeBubbles.vue';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
@@ -53,8 +54,6 @@ const routePhotoId = computed(() => (route.params.photoId === undefined ? null :
|
||||
const album = ref<PublicAlbum | null>(null);
|
||||
const loadErrorKey = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
|
||||
const bannerFailed = ref(false);
|
||||
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
|
||||
const viewCount = ref<number | null>(null);
|
||||
|
||||
@@ -64,7 +63,6 @@ let loadSequence = 0;
|
||||
async function load(): Promise<void> {
|
||||
const sequence = ++loadSequence;
|
||||
loading.value = true;
|
||||
bannerFailed.value = false;
|
||||
try {
|
||||
const data = await getPublicAlbum(token.value, locale.value);
|
||||
if (sequence !== loadSequence) return;
|
||||
@@ -95,43 +93,6 @@ watchEffect(() => {
|
||||
document.title = album.value !== null ? album.value.translation.title : t('app.title');
|
||||
});
|
||||
|
||||
/** 活动时间:按相册 IANA 时区 + 当前展示语言格式化;无 endAt 时单点展示。
|
||||
* 起止两端各自完整格式化(不走 formatRange:CLDR 区间格式在 zh-CN 下会压缩为
|
||||
* 数字日期,丢失「10月1日」式展示且跨环境形态不稳)。 */
|
||||
const timeText = computed(() => {
|
||||
if (album.value === null) return '';
|
||||
const { timezone, startAt, endAt } = album.value;
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
const start = new Date(startAt);
|
||||
const format = (value: Date, withZone: boolean): string => {
|
||||
const formatter = new Intl.DateTimeFormat(locale.value, withZone ? { ...options, timeZone: timezone } : options);
|
||||
return formatter.format(value);
|
||||
};
|
||||
try {
|
||||
const startText = format(start, true);
|
||||
return endAt === null ? startText : `${startText} – ${format(new Date(endAt), true)}`;
|
||||
} catch {
|
||||
// 非法时区名(RangeError):去掉 timeZone 按本地时区展示,优于整页失败。
|
||||
const startText = format(start, false);
|
||||
return endAt === null ? startText : `${startText} – ${format(new Date(endAt), false)}`;
|
||||
}
|
||||
});
|
||||
|
||||
/** 场地 + 地址拼接(任一缺失时只展示存在的部分)。 */
|
||||
const venueText = computed(() => {
|
||||
const translation = album.value?.translation;
|
||||
if (translation === undefined) return '';
|
||||
return [translation.venue, translation.address]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
||||
// 同一 category 值不重置,locale 变化才整表重取)。
|
||||
const selectedCategory = ref<string | null>(null);
|
||||
@@ -356,32 +317,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<template v-else-if="album !== null">
|
||||
<img
|
||||
v-if="album.banner !== null && !bannerFailed"
|
||||
class="banner"
|
||||
:src="album.banner.url"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
>
|
||||
|
||||
<header class="info">
|
||||
<h1 class="title">{{ album.translation.title }}</h1>
|
||||
<p v-if="album.translation.description !== null" class="description">{{ album.translation.description }}</p>
|
||||
<dl class="meta">
|
||||
<div v-if="timeText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.time') }}</dt>
|
||||
<dd>{{ timeText }}</dd>
|
||||
</div>
|
||||
<div v-if="venueText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.venue') }}</dt>
|
||||
<dd>{{ venueText }}</dd>
|
||||
</div>
|
||||
<div v-if="viewCount !== null" class="meta-row">
|
||||
<dt>{{ t('album.views') }}</dt>
|
||||
<dd class="view-count">{{ viewCount }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
<!-- 活动信息区 + 排序/分类工具栏(UI-02 V02:展示提取到 AlbumHeader,
|
||||
筛选决策/序列重置仍在本页——selectSort/selectCategory 原逻辑不变)。 -->
|
||||
<AlbumHeader
|
||||
:album="album"
|
||||
:view-count="viewCount"
|
||||
:sort-mode="sortMode"
|
||||
:selected-category="selectedCategory"
|
||||
:sort-locked="lightboxPhotoId !== null"
|
||||
@select-sort="selectSort"
|
||||
@select-category="selectCategory"
|
||||
/>
|
||||
|
||||
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
|
||||
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
|
||||
@@ -394,54 +340,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: selectedCategory === null }"
|
||||
:aria-pressed="selectedCategory === null"
|
||||
@click="selectCategory(null)"
|
||||
>
|
||||
{{ t('album.all') }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in album.categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: selectedCategory === category.id }"
|
||||
:aria-pressed="selectedCategory === category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 排序切换(Task 10):latest/hot 快照重取;大图打开期间锁定(序列不变序)。 -->
|
||||
<nav class="sort" :aria-label="t('album.sortLabel')">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: sortMode === 'latest' }"
|
||||
:aria-pressed="sortMode === 'latest'"
|
||||
:disabled="lightboxPhotoId !== null"
|
||||
@click="selectSort('latest')"
|
||||
>
|
||||
{{ t('album.sortLatest') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: sortMode === 'hot' }"
|
||||
:aria-pressed="sortMode === 'hot'"
|
||||
:disabled="lightboxPhotoId !== null"
|
||||
@click="selectSort('hot')"
|
||||
>
|
||||
{{ t('album.sortHot') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
|
||||
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
|
||||
</template>
|
||||
@@ -466,10 +364,16 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||
/* UI-02 V01/V02:页面无边到边(无全局 16px 包围);≥960px 主内容列限宽居中
|
||||
* (§3.3,瀑布流列数细化属 Task 3)。信息区/工具栏样式在 AlbumHeader。 */
|
||||
.activity {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.activity {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
.status {
|
||||
color: #6b7280;
|
||||
@@ -485,66 +389,6 @@ onBeforeUnmount(() => {
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.description {
|
||||
margin: 0 0 12px;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 12px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-row dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
.meta-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.sort {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sort .chip:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.deep-link-notice {
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* UI-02 §3.1 统一视觉 token(V01)。
|
||||
* 作用域红线:变量只挂在 .viewer-shell(普通观众路由外壳)上——embed 路由
|
||||
* (App.vue 的 .embed-shell 分支)不挂此类,嵌入 iframe 布局不消费任何
|
||||
* token,亦不受本文件影响。消费方一律 var(--viewer-*),不再散写色值。
|
||||
*/
|
||||
.viewer-shell {
|
||||
--viewer-primary: #1A73E8;
|
||||
--viewer-surface: #FFFFFF;
|
||||
--viewer-page-bg: #F3F6FA;
|
||||
--viewer-text: #172033;
|
||||
--viewer-text-secondary: #647084;
|
||||
--viewer-divider: #E5EAF1;
|
||||
--viewer-dark-bg: #0E1218;
|
||||
--viewer-like-accent: #FF667D;
|
||||
--viewer-font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
--viewer-radius-img: 4px;
|
||||
--viewer-radius-pill: 20px;
|
||||
--viewer-pad-info: 16px;
|
||||
--viewer-pad-photo: 4px;
|
||||
--viewer-touch-target: 44px;
|
||||
}
|
||||
Reference in New Issue
Block a user