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:
@@ -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>
|
||||
Reference in New Issue
Block a user