feat(ui06): B2 shared components + B3a core pages — album cards, photo grid, viewer, summary header
- 类型补齐契约字段:Summary.cover / AdminPhoto.preview(SignedUrl;服务端已返回,B0 核对) - AlbumCoverCard:封面卡(公开/状态/上传 pill,无封面中性占位,不显示 owner 名) - 相册列表:默认封面卡片网格(3列/1700+4列)+ 表格切换 + 已加载内搜索 + 公开状态筛选 + 摘要行 - AlbumSummaryHeader:300×144 封面 + 两列紧凑资料 + 高级折叠(ownerId/统计时间收敛) - 详情页壳:页头(编辑/分享固定主操作)替换 ElDescriptions 大区;banner 区 B3c 收纳 - PhotoGrid + PhotoPreviewDialog:照片网格默认(preview.thumb 签名 URL)+ 深色大图(browse URL、 行内隐藏/恢复/软删除复用表格状态机);网格勾选与表格 selection 合并批量目标 - e2e 适配默认网格视图(albums/governance 断言前切表格);batchHide fixture 补 preview - typecheck/lint/vitest 87 项全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<!-- 相册封面卡片(UI06:admin-preview 示例 02 .album 结构)
|
||||
封面 16:9 区域 + 状态 pill + 标题/日期地点 + 底部状态行。
|
||||
契约约束:不显示 owner 显示名(无投影、避免 N+1);无封面用中性占位。 -->
|
||||
<template>
|
||||
<article class="ui06-album-card" :data-test="'album-card-' + album.id">
|
||||
<button
|
||||
type="button"
|
||||
class="album-cover"
|
||||
:data-test="'album-cover-' + album.id"
|
||||
:aria-label="titleOf(album)"
|
||||
@click="$emit('open', album)"
|
||||
>
|
||||
<img v-if="album.cover?.url" :src="album.cover.url" alt="" loading="lazy" />
|
||||
<span v-else class="cover-placeholder">
|
||||
<ElIcon :size="26"><Picture /></ElIcon>
|
||||
</span>
|
||||
<span
|
||||
class="pill"
|
||||
:class="album.publicEnabled ? 'green' : 'gray'"
|
||||
data-test="album-pill-public"
|
||||
>
|
||||
{{ album.publicEnabled ? t('albumsManage.card.public') : t('albumsManage.card.private') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="album-info">
|
||||
<h2>
|
||||
<button type="button" class="title-btn" @click="$emit('open', album)">
|
||||
{{ titleOf(album) }}
|
||||
</button>
|
||||
</h2>
|
||||
<p class="meta">{{ metaLine(album) }}</p>
|
||||
<div class="album-footer">
|
||||
<span
|
||||
class="pill"
|
||||
:class="album.status === 'active' ? 'green' : 'gray'"
|
||||
:data-test="'album-pill-status-' + album.id"
|
||||
>
|
||||
{{
|
||||
album.status === 'active'
|
||||
? t('albumsManage.card.active')
|
||||
: t('albumsManage.card.offline')
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="pill"
|
||||
:class="album.uploadEnabled ? '' : 'gray'"
|
||||
:data-test="'album-pill-upload-' + album.id"
|
||||
>
|
||||
{{
|
||||
album.uploadEnabled
|
||||
? t('albumsManage.card.uploadsOn')
|
||||
: t('albumsManage.card.uploadsOff')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineOptions({ name: 'AlbumCoverCard' })
|
||||
|
||||
const props = defineProps<{
|
||||
album: Api.Album.Summary
|
||||
/** 标题解析(defaultLocale 优先,回退首条翻译) */
|
||||
titleOf: (album: Api.Album.Summary) => string
|
||||
/** 日期 · 地点行 */
|
||||
metaOf?: (album: Api.Album.Summary) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open', album: Api.Album.Summary): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function metaLine(album: Api.Album.Summary): string {
|
||||
if (props.metaOf) return props.metaOf(album)
|
||||
const venue = album.translations.find((x) => x.locale === album.defaultLocale)?.venue
|
||||
const date = album.startAt ? album.startAt.slice(0, 10) : ''
|
||||
return [date, venue].filter(Boolean).join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 对齐示例 .album / .album-cover / .album-info / .album-footer */
|
||||
.ui06-album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 12px;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 10px 25px #243e6710;
|
||||
}
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 188px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #dce5ef; /* 示例 .art 占位底 */
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #9fb0c4;
|
||||
}
|
||||
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: #5676a5;
|
||||
letter-spacing: 0.2px;
|
||||
background: #edf3fd;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.green {
|
||||
color: #188761;
|
||||
background: #e9f7f1;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
color: #8b96a7;
|
||||
background: #f0f2f6;
|
||||
}
|
||||
}
|
||||
|
||||
.album-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
|
||||
.title-btn {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 7px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 11px;
|
||||
color: #7b899f;
|
||||
}
|
||||
}
|
||||
|
||||
.album-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 13px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #edf1f5;
|
||||
|
||||
.pill {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!-- 相册详情摘要头(UI06:示例 03 .album-banner——封面 + 两列紧凑资料 + 固定主操作)
|
||||
契约约束:不显示 owner 显示名;内部 ID / 统计时间等收进「高级信息」折叠。 -->
|
||||
<template>
|
||||
<div class="ui06-summary card">
|
||||
<!-- 封面 300×144(无封面中性占位) -->
|
||||
<div class="cover" data-test="album-summary-cover">
|
||||
<img v-if="album.cover?.url" :src="album.cover.url" alt="" />
|
||||
<span v-else class="cover-placeholder">
|
||||
<ElIcon :size="26"><Picture /></ElIcon>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 两列紧凑资料(示例 .info-columns .meta-item) -->
|
||||
<div class="info-columns">
|
||||
<div class="meta-item">
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
<div>
|
||||
<strong>{{ dateRange }}</strong>
|
||||
<p>{{ $t('albumsManage.summary.eventTimezone', { tz: album.timezone }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<ElIcon><Location /></ElIcon>
|
||||
<div>
|
||||
<strong>{{ venue || $t('albumsManage.summary.venueUnset') }}</strong>
|
||||
<p>{{ $t('albumsManage.summary.venueLabel') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<ElIcon><Promotion /></ElIcon>
|
||||
<div>
|
||||
<strong>
|
||||
{{
|
||||
album.publicEnabled
|
||||
? $t('albumsManage.summary.publicOn')
|
||||
: $t('albumsManage.summary.publicOff')
|
||||
}}
|
||||
</strong>
|
||||
<p>
|
||||
{{
|
||||
album.uploadEnabled
|
||||
? $t('albumsManage.summary.uploadOn')
|
||||
: $t('albumsManage.summary.uploadOff')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<ElIcon><Link /></ElIcon>
|
||||
<div>
|
||||
<strong>
|
||||
<span class="token" data-test="album-detail-token">{{ album.publicToken }}</span>
|
||||
<ElButton link type="primary" size="small" @click="copyToken">
|
||||
<ElIcon><CopyDocument /></ElIcon>
|
||||
</ElButton>
|
||||
</strong>
|
||||
<p>
|
||||
<ElTag size="small" :type="album.status === 'active' ? 'success' : 'info'">
|
||||
{{
|
||||
album.status === 'active'
|
||||
? $t('albumsManage.card.active')
|
||||
: $t('albumsManage.card.offline')
|
||||
}}
|
||||
</ElTag>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级信息折叠(内部 ID / 同步统计;默认收起) -->
|
||||
<ElCollapse class="ui06-advanced">
|
||||
<ElCollapseItem :title="$t('albumsManage.summary.advanced')" name="advanced">
|
||||
<div class="advanced-grid">
|
||||
<span
|
||||
>{{ $t('albumsManage.detail.fieldOwnerId') }}:<span data-test="album-detail-owner-id">{{
|
||||
album.ownerId
|
||||
}}</span></span
|
||||
>
|
||||
<span>
|
||||
{{ $t('albumsManage.detail.fieldStatsSyncedAt') }}:
|
||||
<span data-test="album-detail-stats-synced-at">{{
|
||||
album.statsSyncedAt
|
||||
? formatDateTime(album.statsSyncedAt)
|
||||
: $t('albumsManage.detail.statsNotSynced')
|
||||
}}</span>
|
||||
</span>
|
||||
<span
|
||||
>{{ $t('albumsManage.detail.fieldCreatedAt') }}:{{
|
||||
formatDateTime(album.createdAt)
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
>{{ $t('albumsManage.detail.fieldUpdatedAt') }}:{{
|
||||
formatDateTime(album.updatedAt)
|
||||
}}</span
|
||||
>
|
||||
<span>
|
||||
{{ $t('albumsManage.detail.fieldSwitches') }}:
|
||||
<ElTag
|
||||
v-for="sw in switchTags"
|
||||
:key="sw.key"
|
||||
size="small"
|
||||
:type="sw.on ? 'success' : 'info'"
|
||||
:effect="sw.on ? 'light' : 'plain'"
|
||||
>
|
||||
{{ sw.label }} {{ sw.on ? onLabel : offLabel }}
|
||||
</ElTag>
|
||||
</span>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Calendar,
|
||||
Location,
|
||||
Promotion,
|
||||
Link,
|
||||
CopyDocument,
|
||||
Picture
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDateTime } from '@/utils/albums/format'
|
||||
|
||||
defineOptions({ name: 'AlbumSummaryHeader' })
|
||||
|
||||
const props = defineProps<{
|
||||
album: Api.Album.Summary
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dateRange = computed(() => {
|
||||
const start = props.album.startAt?.slice(0, 10) ?? ''
|
||||
const end = props.album.endAt?.slice(0, 10)
|
||||
return end ? `${start} – ${end}` : start
|
||||
})
|
||||
|
||||
const venue = computed(
|
||||
() => props.album.translations.find((x) => x.locale === props.album.defaultLocale)?.venue ?? ''
|
||||
)
|
||||
|
||||
const onLabel = computed(() => t('albumsManage.list.on'))
|
||||
const offLabel = computed(() => t('albumsManage.list.off'))
|
||||
|
||||
const switchTags = computed(() => [
|
||||
{ key: 'upload', on: props.album.uploadEnabled, label: t('albumsManage.list.switchUpload') },
|
||||
{ key: 'public', on: props.album.publicEnabled, label: t('albumsManage.list.switchPublic') },
|
||||
{
|
||||
key: 'download',
|
||||
on: props.album.downloadEnabled,
|
||||
label: t('albumsManage.list.switchDownload')
|
||||
},
|
||||
{
|
||||
key: 'original',
|
||||
on: props.album.originalDownloadEnabled,
|
||||
label: t('albumsManage.list.switchOriginal')
|
||||
}
|
||||
])
|
||||
|
||||
async function copyToken() {
|
||||
await navigator.clipboard.writeText(props.album.publicToken)
|
||||
ElMessage.success(t('albumsManage.list.copied'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
/* 对齐示例 .album-banner / .info-columns / .meta-item */
|
||||
.ui06-summary {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 300px;
|
||||
height: 144px;
|
||||
overflow: hidden;
|
||||
background: #dce5ef;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #9fb0c4;
|
||||
}
|
||||
|
||||
.info-columns {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px 20px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
align-items: flex-start;
|
||||
color: #7e8fa7;
|
||||
|
||||
.el-icon {
|
||||
margin-top: 2px;
|
||||
color: #9fb0c4;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
color: #35455e;
|
||||
|
||||
.token {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.ui06-advanced {
|
||||
margin-top: 14px;
|
||||
border: 0;
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
font-size: 12px;
|
||||
color: #7b899f;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__wrap) {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.advanced-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #52617a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ui06-summary {
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 180px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.info-columns {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ui06-summary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-columns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<!-- 照片网格(UI06:示例 03 .photo-grid——四列、缩略图、选择框、状态角标、说明行)
|
||||
契约:缩略图用 AdminPhoto.preview.thumb 签名 URL(可空=无预览占位);
|
||||
仅 published 可勾选(批量隐藏前置);无原文件名字段,回退显示 photoId 短码。 -->
|
||||
<template>
|
||||
<div class="ui06-photo-grid" data-test="photo-grid">
|
||||
<article v-for="photo in photos" :key="photo.photoId" class="photo-item">
|
||||
<div class="photo-image" :class="{ selected: selectedIds.has(photo.photoId) }">
|
||||
<button
|
||||
type="button"
|
||||
class="photo-open"
|
||||
:aria-label="t('albumsManage.photosGrid.openAria')"
|
||||
@click="$emit('open', photo)"
|
||||
>
|
||||
<img
|
||||
v-if="photo.preview?.thumb?.url"
|
||||
:src="photo.preview.thumb.url"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="thumb-placeholder">
|
||||
<ElIcon :size="22"><Picture /></ElIcon>
|
||||
<span>{{ noPreviewLabel(photo) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
class="photo-check"
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(photo.photoId)"
|
||||
:disabled="photo.visibility !== 'published'"
|
||||
:aria-label="t('albumsManage.photosGrid.selectAria')"
|
||||
:data-test="`photo-check-${photo.photoId}`"
|
||||
@change="$emit('toggle', photo.photoId)"
|
||||
/>
|
||||
<span class="pill" :class="pillClass(photo.visibility)">
|
||||
{{ t(`albumsManage.photos.visibility${pascal(photo.visibility)}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="photo-caption">
|
||||
<div class="caption-text">
|
||||
<strong class="mono">{{ photo.photoId.slice(-8) }}</strong>
|
||||
<p>{{ uploaderOf(photo) }} · {{ formatDate(photo.uploadedAt) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="caption-more"
|
||||
:aria-label="t('albumsManage.photosGrid.openAria')"
|
||||
@click="$emit('open', photo)"
|
||||
>
|
||||
<ElIcon><MoreFilled /></ElIcon>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Picture, MoreFilled } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'PhotoGrid' })
|
||||
|
||||
defineProps<{
|
||||
photos: Api.Album.AdminPhoto[]
|
||||
selectedIds: Set<string>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open', photo: Api.Album.AdminPhoto): void
|
||||
(e: 'toggle', photoId: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function pascal(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function pillClass(visibility: Api.Album.PhotoVisibility): string {
|
||||
switch (visibility) {
|
||||
case 'published':
|
||||
return 'green'
|
||||
case 'hidden':
|
||||
return 'amber'
|
||||
case 'deleted':
|
||||
return 'red'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function uploaderOf(photo: Api.Album.AdminPhoto): string {
|
||||
return photo.uploader.displayName || photo.uploader.account
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function noPreviewLabel(photo: Api.Album.AdminPhoto): string {
|
||||
if (photo.processingState === 'processing' || photo.processingState === 'pending_upload') {
|
||||
return t('albumsManage.photos.stateProcessing')
|
||||
}
|
||||
if (photo.processingState === 'processing_failed') {
|
||||
return t('albumsManage.photos.stateProcessingFailed')
|
||||
}
|
||||
return t('albumsManage.photosGrid.noPreview')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 对齐示例 .photo-grid / .photo-item / .photo-image / .photo-caption */
|
||||
.ui06-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
position: relative;
|
||||
aspect-ratio: 1.65;
|
||||
overflow: hidden;
|
||||
background: #dce6ef;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.photo-open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #7e93ab;
|
||||
}
|
||||
|
||||
.photo-image.selected {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.photo-check {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
z-index: 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--el-color-primary);
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
padding: 3px 7px;
|
||||
font-size: 10px;
|
||||
color: #5676a5;
|
||||
background: #edf3fdcc;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
&.green {
|
||||
color: #188761;
|
||||
background: #e9f7f1dd;
|
||||
}
|
||||
|
||||
&.amber {
|
||||
color: #ae7c27;
|
||||
background: #fff5e5dd;
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: #d75168;
|
||||
background: #fceef1dd;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
color: #8b96a7;
|
||||
background: #f0f2f6dd;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 9px 2px 0;
|
||||
|
||||
.caption-text {
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: #8a97ac;
|
||||
}
|
||||
}
|
||||
|
||||
.caption-more {
|
||||
flex: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #8a97ac;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: #edf3fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.ui06-photo-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ui06-photo-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 8px;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
aspect-ratio: 1.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<!-- 管理照片大图(UI06:示例 16 .viewer-dialog——深色舞台 + 右侧信息栏 + 行内动作)
|
||||
契约:browse 签名 URL 可空(无预览/处理中/无权限真实空态);动作语义与表格一致。 -->
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="visible"
|
||||
:show-close="false"
|
||||
width="1050px"
|
||||
class="ui06-viewer-dialog"
|
||||
data-test="photo-viewer-dialog"
|
||||
@update:model-value="$emit('close')"
|
||||
>
|
||||
<div v-if="photo" class="viewer-body">
|
||||
<!-- 深色舞台 -->
|
||||
<div class="viewer-main">
|
||||
<img v-if="photo.preview?.browse?.url" :src="photo.preview.browse.url" alt="" />
|
||||
<div v-else class="viewer-empty">
|
||||
<ElIcon :size="34"><Picture /></ElIcon>
|
||||
<span>{{ emptyLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-close"
|
||||
:aria-label="t('common.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<ElIcon :size="20"><Close /></ElIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 信息栏 -->
|
||||
<div class="viewer-info">
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photos.colVisibility') }}</span>
|
||||
<ElTag size="small" :type="visibilityTagType" effect="light">
|
||||
{{ t(`albumsManage.photos.visibility${pascal(photo.visibility)}`) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photos.colState') }}</span>
|
||||
<ElTag size="small" :type="stateTagType" effect="light">
|
||||
{{ t(`albumsManage.photos.state${pascal(photo.processingState)}`) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photosGrid.uploader') }}</span>
|
||||
<span>{{ photo.uploader.displayName || photo.uploader.account }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photos.colUploadedAt') }}</span>
|
||||
<span>{{ formatDateTime(photo.uploadedAt) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photos.colSize') }}</span>
|
||||
<span>
|
||||
{{ photo.width && photo.height ? `${photo.width}×${photo.height}` : '—' }}
|
||||
· {{ formatBytes(photo.bytes) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{{ t('albumsManage.photos.colLikeCount') }}</span>
|
||||
<span>{{ photo.likeCount }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">ID</span>
|
||||
<span class="mono break-all">{{ photo.photoId }}</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<ElButton
|
||||
v-if="photo.visibility === 'published'"
|
||||
class="action-btn"
|
||||
data-test="viewer-hide-btn"
|
||||
@click="$emit('hide', photo)"
|
||||
>
|
||||
{{ t('albumsManage.photos.hide') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="photo.visibility === 'hidden'"
|
||||
class="action-btn"
|
||||
data-test="viewer-restore-btn"
|
||||
@click="$emit('restore', photo)"
|
||||
>
|
||||
{{ t('albumsManage.photos.restore') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="photo.visibility !== 'deleted'"
|
||||
class="action-btn danger"
|
||||
data-test="viewer-delete-btn"
|
||||
@click="$emit('remove', photo)"
|
||||
>
|
||||
{{ t('albumsManage.photos.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Picture, Close } from '@element-plus/icons-vue'
|
||||
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
||||
|
||||
defineOptions({ name: 'PhotoPreviewDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
photo: Api.Album.AdminPhoto | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'hide', photo: Api.Album.AdminPhoto): void
|
||||
(e: 'restore', photo: Api.Album.AdminPhoto): void
|
||||
(e: 'remove', photo: Api.Album.AdminPhoto): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function pascal(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const visibilityTagType = computed(() => {
|
||||
switch (props.photo?.visibility) {
|
||||
case 'published':
|
||||
return 'success'
|
||||
case 'hidden':
|
||||
return 'warning'
|
||||
case 'deleted':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const stateTagType = computed(() => {
|
||||
switch (props.photo?.processingState) {
|
||||
case 'ready':
|
||||
return 'success'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'processing_failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const emptyLabel = computed(() => {
|
||||
if (!props.photo) return ''
|
||||
if (
|
||||
props.photo.processingState === 'processing' ||
|
||||
props.photo.processingState === 'pending_upload'
|
||||
) {
|
||||
return t('albumsManage.photos.stateProcessing')
|
||||
}
|
||||
if (props.photo.processingState === 'processing_failed') {
|
||||
return t('albumsManage.photos.stateProcessingFailed')
|
||||
}
|
||||
return t('albumsManage.photosGrid.noPreview')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 对齐示例 .viewer-dialog / .viewer-main / .viewer-info */
|
||||
.viewer-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: #111925;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.viewer-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 520px;
|
||||
|
||||
img {
|
||||
width: 85%;
|
||||
max-height: 640px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.viewer-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #5c6c80;
|
||||
}
|
||||
|
||||
.viewer-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 3;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
background: #ffffff1a;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background: #ffffff33;
|
||||
}
|
||||
}
|
||||
|
||||
.viewer-info {
|
||||
flex: none;
|
||||
width: 270px;
|
||||
padding: 25px;
|
||||
background: #17212f;
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 13px;
|
||||
font-size: 12px;
|
||||
color: #d5dcea;
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
color: #a2afc1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #263243;
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
color: #d5dcea;
|
||||
background: transparent;
|
||||
border: 1px solid #3a4a61;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: #223145;
|
||||
border-color: #4a5d7a;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff9aa5;
|
||||
|
||||
&:hover {
|
||||
color: #ffb3bb;
|
||||
background: #3a2230;
|
||||
border-color: #7a3a4a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.viewer-body {
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.viewer-main {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.viewer-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user