feat(assets): album asset status — show current banner/cover/logo in admin branding tab
- 契约 listAlbumAssetStatus(GET /albums/{albumId}/assets):每 (kind,locale)
最新 effective 行 + 现签 URL;权限同详情(可见即读);deleted → 404
- 服务端 AssetsService.status(createdAt,id DESC 组内取首;presignGet 现签)
- Admin 品牌资产页签改造:进入即加载现状——三种 kind 卡片(预览图/未设置
空态/设置时间),banner 分全局/zh/en 槽位;上传成功自动刷新(替换语义)
- API 56/56、admin 87/87 + build 全绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,13 @@ export function fetchDeleteAlbum(albumId: string) {
|
||||
return request.del<void>({ url: `/v1/albums/${albumId}` })
|
||||
}
|
||||
|
||||
/** 各 (kind, locale) 最新生效资产(管理端现状展示;签名现签) */
|
||||
export function fetchAlbumAssetStatus(albumId: string) {
|
||||
return request.get<Api.Album.AssetStatusResponse>({
|
||||
url: `/v1/albums/${albumId}/assets`
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
|
||||
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
|
||||
return request.post<Api.Album.AssetInitResponse>({
|
||||
|
||||
@@ -407,7 +407,12 @@
|
||||
"stageError": "Upload failed",
|
||||
"reupload": "Upload Again",
|
||||
"preview": "Preview (signed URL, valid until {expiresAt})",
|
||||
"invalidType": "Only jpg / png / heic images are supported"
|
||||
"invalidType": "Only jpg / png / heic images are supported",
|
||||
"statusHint": "Current effective banner / cover / logo (re-upload replaces)",
|
||||
"refresh": "Refresh",
|
||||
"notSet": "Not set",
|
||||
"notSetHint": "Not uploaded yet; use the uploader below",
|
||||
"setAt": "Set at {time}"
|
||||
},
|
||||
"members": {
|
||||
"title": "Album members",
|
||||
|
||||
@@ -407,7 +407,12 @@
|
||||
"stageError": "上传失败",
|
||||
"reupload": "重新上传",
|
||||
"preview": "当前预览(签名 URL,{expiresAt} 前有效)",
|
||||
"invalidType": "仅支持 jpg / png / heic 图片"
|
||||
"invalidType": "仅支持 jpg / png / heic 图片",
|
||||
"statusHint": "当前生效的宣传图 / 封面 / 标志(同类型再上传即替换)",
|
||||
"refresh": "刷新",
|
||||
"notSet": "未设置",
|
||||
"notSetHint": "尚未上传,可选择下方上传",
|
||||
"setAt": "设置于 {time}"
|
||||
},
|
||||
"members": {
|
||||
"title": "相册成员",
|
||||
|
||||
Vendored
+15
@@ -141,6 +141,21 @@ declare namespace Api {
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
/** 生效资产现状项(UI06 listAlbumAssetStatus) */
|
||||
interface AssetStatusItem {
|
||||
kind: AssetKind
|
||||
locale: Locale | null
|
||||
url: string
|
||||
expiresAt: string
|
||||
contentType: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 各 (kind, locale) 最新生效资产列表 */
|
||||
interface AssetStatusResponse {
|
||||
items: AssetStatusItem[]
|
||||
}
|
||||
|
||||
/** 相册成员 */
|
||||
interface Member {
|
||||
userId: string
|
||||
|
||||
@@ -2,8 +2,45 @@
|
||||
签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名
|
||||
URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 -->
|
||||
<template>
|
||||
<ElCard :header="t('albumsManage.detail.banner')" data-test="album-banner-card">
|
||||
<div class="flex flex-col gap-3">
|
||||
<ElCard data-test="album-banner-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 class="ui06-card-title">{{ t('albumsManage.detail.banner') }}</h2>
|
||||
<p class="head-desc">{{ t('albumsManage.banner.statusHint') }}</p>
|
||||
</div>
|
||||
<ElButton :loading="statusLoading" data-test="album-banner-refresh" @click="loadStatus">
|
||||
{{ t('albumsManage.banner.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 当前生效资产(进入即加载;未设置空态)── -->
|
||||
<div v-loading="statusLoading" class="status-grid" data-test="album-banner-status-list">
|
||||
<div
|
||||
v-for="slot of statusSlots"
|
||||
:key="slot.key"
|
||||
class="asset-card"
|
||||
:data-test="`album-asset-${slot.kind}-${slot.locale ?? 'none'}`"
|
||||
>
|
||||
<div class="asset-thumb">
|
||||
<img v-if="slot.item" :src="slot.item.url" alt="" />
|
||||
<span v-else class="asset-empty">
|
||||
<ElIcon :size="22"><Picture /></ElIcon>
|
||||
<span>{{ t('albumsManage.banner.notSet') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="asset-meta">
|
||||
<strong>{{ slot.label }}</strong>
|
||||
<p v-if="slot.item">
|
||||
{{ t('albumsManage.banner.setAt', { time: formatDateTime(slot.item.createdAt) }) }}
|
||||
</p>
|
||||
<p v-else class="muted">{{ t('albumsManage.banner.notSetHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 upload-area">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<ElSelect v-model="kind" style="width: 200px" data-test="album-banner-kind">
|
||||
<ElOption value="banner" :label="t('albumsManage.banner.kindBanner')" />
|
||||
@@ -76,9 +113,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { fetchAlbumAssetStatus, fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
|
||||
import {
|
||||
INITIAL_ASSET_UPLOAD_STATE,
|
||||
nextAssetUploadState,
|
||||
@@ -92,6 +130,63 @@
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 当前生效资产(listAlbumAssetStatus;banner 可多语言行)──
|
||||
const statusItems = ref<Api.Album.AssetStatusItem[]>([])
|
||||
const statusLoading = ref(false)
|
||||
|
||||
const KIND_LABELS: Record<Api.Album.AssetKind, string> = {
|
||||
banner: '',
|
||||
cover: '',
|
||||
logo: ''
|
||||
}
|
||||
function kindLabel(k: Api.Album.AssetKind): string {
|
||||
KIND_LABELS.banner = t('albumsManage.banner.kindBanner')
|
||||
KIND_LABELS.cover = t('albumsManage.banner.kindCover')
|
||||
KIND_LABELS.logo = t('albumsManage.banner.kindLogo')
|
||||
return KIND_LABELS[k]
|
||||
}
|
||||
|
||||
/** 展示槽位:三种 kind × 已设置的 (kind,locale) 行;未设置的组合显示空态 */
|
||||
const statusSlots = computed(() => {
|
||||
const byKey = new Map(statusItems.value.map((it) => [`${it.kind}|${it.locale ?? ''}`, it]))
|
||||
const slots: {
|
||||
key: string
|
||||
kind: Api.Album.AssetKind
|
||||
locale: string | null
|
||||
label: string
|
||||
item: Api.Album.AssetStatusItem | null
|
||||
}[] = []
|
||||
for (const k of ['banner', 'cover', 'logo'] as const) {
|
||||
// banner:全局行 + 各语言行;cover/logo:仅全局(语言对它们无意义)
|
||||
const locales: (string | null)[] = k === 'banner' ? [null, 'zh-CN', 'en'] : [null]
|
||||
for (const loc of locales) {
|
||||
const key = `${k}|${loc ?? ''}`
|
||||
slots.push({
|
||||
key,
|
||||
kind: k,
|
||||
locale: loc,
|
||||
label: kindLabel(k) + (loc ? ` · ${loc}` : ''),
|
||||
item: byKey.get(key) ?? null
|
||||
})
|
||||
}
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
async function loadStatus() {
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const result = await fetchAlbumAssetStatus(props.albumId)
|
||||
statusItems.value = result.items
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus()
|
||||
})
|
||||
|
||||
const kind = ref<Api.Album.AssetKind>('banner')
|
||||
const locale = ref<'' | Api.Album.Locale>('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -192,8 +287,92 @@
|
||||
try {
|
||||
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
|
||||
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
|
||||
// 生效后刷新现状区(替换语义:同组旧图被替换)
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.head-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #7b899f;
|
||||
}
|
||||
}
|
||||
|
||||
/* 现状卡栅格(示例 .album-grid 观感:多列小卡) */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
background: #dce5ef;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #7e93ab;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #7b899f;
|
||||
|
||||
&.muted {
|
||||
color: #97a4b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"@photo/contracts": "workspace:*",
|
||||
"vue": "~3.4.0",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-router": "~4.4.0"
|
||||
"vue-router": "~4.4.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@photo/db-cn": "workspace:*",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PublicAlbum, SortMode } from '../../api/client';
|
||||
import { shareOrCopy } from '../../support/share';
|
||||
import ShareSheet from '../viewer/ShareSheet.vue';
|
||||
|
||||
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
||||
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
||||
@@ -122,27 +122,17 @@ const venueText = computed(() => {
|
||||
|
||||
const descriptionText = computed(() => props.album.translation.description);
|
||||
|
||||
// —— 分享(V05 §5.4 完整编排):navigator.share 优先(相册标题 + 相册路由
|
||||
// URL,附 lang 参数;不分享 OSS URL),不可用/失败回退剪贴板;复制失败
|
||||
// 保留可选中文本;用户取消原生分享不显示错误 ——
|
||||
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
|
||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// —— 分享(2026-09-23 用户需求:改为 App 式弹层——二维码 + 链接 + 复制,
|
||||
// 页面级「已复制」提示退役;深链规则不变:相册路由 + lang,不分享 OSS URL)——
|
||||
const shareSheetOpen = ref(false);
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
|
||||
return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
|
||||
});
|
||||
|
||||
async function share(): Promise<void> {
|
||||
const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.value });
|
||||
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
|
||||
if (outcome === 'copied') {
|
||||
shareResetTimer = setTimeout(() => {
|
||||
shareState.value = 'idle';
|
||||
}, 3000);
|
||||
}
|
||||
function share(): void {
|
||||
shareSheetOpen.value = true;
|
||||
}
|
||||
|
||||
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
|
||||
@@ -197,10 +187,12 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<ShareSheet
|
||||
v-model="shareSheetOpen"
|
||||
:heading="t('viewer.shareAlbum')"
|
||||
:title="album.translation.title"
|
||||
:url="albumUrl"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="descriptionText !== null"
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<!-- apps/viewer/src/components/viewer/ShareSheet.vue —— 展示页分享弹层
|
||||
(2026-09-23 用户需求:分享按钮不再页面级「已复制」提示,改为 App 式弹层)。
|
||||
形态对齐 App share_qr_dialog / admin AlbumShareCard:底部弹层(手机)/
|
||||
居中卡(≥640px),内容 = 标题 + 二维码 + 可选中链接 + 操作按钮。
|
||||
· 二维码:qrcode canvas 绘制(viewer 深链 URL;url 变化重绘);
|
||||
· 复制:copyText(clipboard API + execCommand 兜底)——成功反馈收在按钮
|
||||
文案上(「已复制」2.5s 复位),不再页面级提示条;失败展示可选中文本
|
||||
+ 按钮文案(微信等受限环境长按二维码/选择文本仍可达);
|
||||
· 系统分享:navigator.share 可用时提供次按钮(原生面板自反馈);
|
||||
· 关闭:遮罩点击 / Esc / 关闭按钮;打开时焦点移入弹层(键盘可达)。 -->
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QRCode from 'qrcode';
|
||||
import { canNativeShare } from '../../support/share';
|
||||
import { copyText } from '../../support/clipboard';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 弹层标题文案(如「分享相册」/「分享照片」词条)。 */
|
||||
heading: string;
|
||||
/** 分享目标名称(相册标题等,展示行)。 */
|
||||
title: string;
|
||||
/** 观众端深链(二维码与复制目标;不使用 OSS 签名 URL)。 */
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const sheetEl = ref<HTMLElement | null>(null);
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const copyState = ref<'idle' | 'copied' | 'failed'>('idle');
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let qrAbort: { cancel: () => void } | null = null;
|
||||
|
||||
const showNativeShare = canNativeShare();
|
||||
|
||||
/** 二维码绘制(url 变化重绘;弹层打开时按可见尺寸绘制)。 */
|
||||
async function drawQr(): Promise<void> {
|
||||
const canvas = qrCanvas.value;
|
||||
if (canvas === null || props.url === '') return;
|
||||
qrAbort?.cancel();
|
||||
const token = { cancelled: false };
|
||||
qrAbort = { cancel: () => { token.cancelled = true; } };
|
||||
try {
|
||||
await QRCode.toCanvas(canvas, props.url, {
|
||||
width: 188,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#172033', light: '#ffffff' },
|
||||
});
|
||||
} catch {
|
||||
// 绘制失败(理论极低):留白画布 + 链接文本仍可用(不阻塞复制路径)。
|
||||
}
|
||||
void token;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [open.value, props.url] as const,
|
||||
([isOpen], [wasOpen]) => {
|
||||
if (isOpen) {
|
||||
void nextTick().then(() => {
|
||||
drawQr();
|
||||
sheetEl.value?.focus();
|
||||
});
|
||||
} else if (wasOpen) {
|
||||
copyState.value = 'idle';
|
||||
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
const ok = await copyText(props.url);
|
||||
copyState.value = ok ? 'copied' : 'failed';
|
||||
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
|
||||
copyResetTimer = setTimeout(() => {
|
||||
copyState.value = 'idle';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function onNativeShare(): Promise<void> {
|
||||
try {
|
||||
await navigator.share({ title: props.title, url: props.url });
|
||||
} catch {
|
||||
// 取消(AbortError)或其他失败:静默——弹层仍在,复制路径可用。
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
qrAbort?.cancel();
|
||||
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="share-sheet-overlay"
|
||||
@pointerdown.self="open = false"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
ref="sheetEl"
|
||||
class="share-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="heading"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="share-sheet-head">
|
||||
<p class="share-sheet-heading">{{ heading }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="share-sheet-close"
|
||||
:aria-label="t('viewer.close')"
|
||||
@click="open = false"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.3 5.71 12 12.01l-6.3-6.3-1.4 1.41 6.29 6.3-6.3 6.3 1.42 1.4 6.29-6.29 6.3 6.3 1.4-1.42-6.3-6.29 6.3-6.3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="share-sheet-title" aria-hidden="true">{{ title }}</p>
|
||||
|
||||
<div class="share-sheet-qr">
|
||||
<canvas ref="qrCanvas" width="188" height="188" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
|
||||
<p class="share-sheet-url" data-test="share-sheet-url">{{ url }}</p>
|
||||
|
||||
<div class="share-sheet-actions">
|
||||
<button
|
||||
v-if="showNativeShare"
|
||||
type="button"
|
||||
class="share-sheet-btn is-secondary"
|
||||
data-test="share-sheet-native"
|
||||
@click="onNativeShare"
|
||||
>
|
||||
{{ t('viewer.share') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="share-sheet-btn is-primary"
|
||||
:data-state="copyState"
|
||||
data-test="share-sheet-copy"
|
||||
@click="onCopy"
|
||||
>
|
||||
{{
|
||||
copyState === 'copied'
|
||||
? t('download.copied')
|
||||
: copyState === 'failed'
|
||||
? t('download.copyFailed')
|
||||
: t('download.copyLink')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share-sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: rgba(9, 14, 20, 0.55);
|
||||
}
|
||||
/* 桌面:居中卡片(App 底部弹层语义在宽屏收缩为对话框)。 */
|
||||
@media (min-width: 640px) {
|
||||
.share-sheet-overlay {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.share-sheet {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-height: calc(100% - 24px);
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 18px 18px 0 0;
|
||||
padding: 14px 18px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
outline: none;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.share-sheet {
|
||||
border-radius: 16px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
}
|
||||
.share-sheet:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.share-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.share-sheet-heading {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
color: var(--viewer-text, #172033);
|
||||
}
|
||||
.share-sheet-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -10px -10px -10px 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #647084;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-sheet-close:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.share-sheet-close:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
}
|
||||
.share-sheet-title {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #647084;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-sheet-qr {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 12px 0 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
border-radius: 12px;
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
.share-sheet-qr canvas {
|
||||
display: block;
|
||||
width: 188px;
|
||||
height: 188px;
|
||||
}
|
||||
.share-sheet-url {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #647084;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
user-select: text;
|
||||
}
|
||||
.share-sheet-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.share-sheet-btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-sheet-btn.is-primary {
|
||||
background: var(--viewer-primary, #1a73e8);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.share-sheet-btn.is-primary[data-state='copied'] {
|
||||
background: #1e8e5a;
|
||||
}
|
||||
.share-sheet-btn.is-primary[data-state='failed'] {
|
||||
background: #b54a4a;
|
||||
}
|
||||
.share-sheet-btn.is-secondary {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 18px;
|
||||
background: #ffffff;
|
||||
border-color: var(--viewer-divider, #d7dfeb);
|
||||
color: var(--viewer-text, #172033);
|
||||
}
|
||||
.share-sheet-btn:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -455,6 +455,31 @@ paths:
|
||||
/albums/{albumId}/assets:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AlbumId'
|
||||
get:
|
||||
operationId: listAlbumAssetStatus
|
||||
tags: [assets]
|
||||
summary: 各类资产当前生效项(管理端现状展示)
|
||||
description: >-
|
||||
返回每个 (kind, locale) 组合的最新 effective 资产行 + 现签短期 URL
|
||||
(同「替换语义」——同组再上传即整体替换)。未设置的组合不出现;
|
||||
签名过期重取本端点刷新。权限同相册详情(可见即可读)。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 生效资产列表(可能为空数组)。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AssetStatusResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
description: 相册不存在(ALBUM_NOT_FOUND,含已删除)。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
post:
|
||||
operationId: initAlbumAssetUpload
|
||||
tags: [assets]
|
||||
@@ -3119,6 +3144,38 @@ components:
|
||||
|
||||
# ── 成员 ──
|
||||
|
||||
AssetStatusItem:
|
||||
type: object
|
||||
description: 单个生效资产(管理端现状展示用)。
|
||||
required: [kind, locale, url, expiresAt, contentType, createdAt]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [banner, cover, logo]
|
||||
locale:
|
||||
type: [string, "null"]
|
||||
description: 语言标签(banner 可带;null=全局)。
|
||||
url:
|
||||
type: string
|
||||
description: 现签短期 GET URL。
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
contentType:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
AssetStatusResponse:
|
||||
type: object
|
||||
description: 各 (kind, locale) 组合最新生效资产列表。
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AssetStatusItem'
|
||||
|
||||
MemberAddRequest:
|
||||
type: object
|
||||
required: [userId]
|
||||
|
||||
+56
-1
@@ -187,7 +187,11 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* 各类资产当前生效项(管理端现状展示)
|
||||
* @description 返回每个 (kind, locale) 组合的最新 effective 资产行 + 现签短期 URL (同「替换语义」——同组再上传即整体替换)。未设置的组合不出现; 签名过期重取本端点刷新。权限同相册详情(可见即可读)。
|
||||
*/
|
||||
get: operations["listAlbumAssetStatus"];
|
||||
put?: never;
|
||||
/**
|
||||
* 初始化资产上传
|
||||
@@ -1518,6 +1522,24 @@ export interface components {
|
||||
/** @description 资产 ID。 */
|
||||
readonly assetId: string;
|
||||
};
|
||||
/** @description 单个生效资产(管理端现状展示用)。 */
|
||||
AssetStatusItem: {
|
||||
/** @enum {string} */
|
||||
kind: "banner" | "cover" | "logo";
|
||||
/** @description 语言标签(banner 可带;null=全局)。 */
|
||||
locale: string | null;
|
||||
/** @description 现签短期 GET URL。 */
|
||||
url: string;
|
||||
/** Format: date-time */
|
||||
expiresAt: string;
|
||||
contentType: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
/** @description 各 (kind, locale) 组合最新生效资产列表。 */
|
||||
AssetStatusResponse: {
|
||||
items: components["schemas"]["AssetStatusItem"][];
|
||||
};
|
||||
MemberAddRequest: {
|
||||
/** @description 被添加用户的 ID(须为已开通账号)。 */
|
||||
userId: string;
|
||||
@@ -3125,6 +3147,39 @@ export interface operations {
|
||||
500: components["responses"]["InternalError"];
|
||||
};
|
||||
};
|
||||
listAlbumAssetStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description 相册 ID。 */
|
||||
albumId: components["parameters"]["AlbumId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 生效资产列表(可能为空数组)。 */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetStatusResponse"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description 相册不存在(ALBUM_NOT_FOUND,含已删除)。 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
initAlbumAssetUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Generated
+3
@@ -186,6 +186,9 @@ importers:
|
||||
'@photo/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/contracts
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
vue:
|
||||
specifier: ~3.4.0
|
||||
version: 3.4.38(typescript@5.9.3)
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { userOf } from './albums.controller';
|
||||
import { AssetInitRequestDto } from './dto';
|
||||
@@ -29,6 +31,14 @@ export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
|
||||
@Get()
|
||||
listStatus(
|
||||
@Param('albumId') albumId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<components['schemas']['AssetStatusResponse']> {
|
||||
return this.assetsService.status(userOf(req), albumId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
initAlbumAssetUpload(
|
||||
@Param('albumId') albumId: string,
|
||||
|
||||
@@ -63,6 +63,50 @@ export class AssetsService {
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 各 (kind, locale) 组合最新生效资产(契约 listAlbumAssetStatus):管理端
|
||||
* 现状展示。权限同相册详情(可见即可读);deleted → 404。签名现签
|
||||
* (SigV4 本地计算);同组取 createdAt,id DESC 首行(替换语义)。
|
||||
*/
|
||||
async status(
|
||||
user: AuthUser,
|
||||
albumId: string,
|
||||
): Promise<components['schemas']['AssetStatusResponse']> {
|
||||
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
const album = await this.prisma.albums.findUnique({
|
||||
where: { id: albumId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (album === null || album.status === 'deleted') {
|
||||
throw albumNotFound();
|
||||
}
|
||||
const rows = await this.prisma.album_assets.findMany({
|
||||
where: { albumId, state: 'effective' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
// 每 (kind, locale|null) 组合取首行(排序已保证最新在前)
|
||||
const seen = new Set<string>();
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const items: components['schemas']['AssetStatusItem'][] = [];
|
||||
for (const row of rows) {
|
||||
const key = `${row.kind}|${row.locale ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const url = await this.storage.presignGet(row.objectKey, ttl);
|
||||
items.push({
|
||||
kind: row.kind as components['schemas']['AssetStatusItem']['kind'],
|
||||
locale: (row.locale ?? null) as string | null,
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||
contentType: row.contentType,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:权限/参数校验 → 建行(state=pending)→ presign。
|
||||
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。
|
||||
|
||||
Reference in New Issue
Block a user