diff --git a/apps/admin/src/api/albums.ts b/apps/admin/src/api/albums.ts index e4b83f8..88a5022 100644 --- a/apps/admin/src/api/albums.ts +++ b/apps/admin/src/api/albums.ts @@ -35,6 +35,13 @@ export function fetchDeleteAlbum(albumId: string) { return request.del({ url: `/v1/albums/${albumId}` }) } +/** 各 (kind, locale) 最新生效资产(管理端现状展示;签名现签) */ +export function fetchAlbumAssetStatus(albumId: string) { + return request.get({ + url: `/v1/albums/${albumId}/assets` + }) +} + /** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */ export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) { return request.post({ diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index a384c07..a48ad6b 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -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", diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 743cc2f..325fdb7 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -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": "相册成员", diff --git a/apps/admin/src/types/api/album.d.ts b/apps/admin/src/types/api/album.d.ts index 74e8a26..21dc9ec 100644 --- a/apps/admin/src/types/api/album.d.ts +++ b/apps/admin/src/types/api/album.d.ts @@ -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 diff --git a/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue b/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue index e21f1b8..9ce83ea 100644 --- a/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue +++ b/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue @@ -2,8 +2,45 @@ 签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名 URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 --> + + diff --git a/apps/viewer/package.json b/apps/viewer/package.json index a10b3d4..8d51510 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -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:*", diff --git a/apps/viewer/src/components/album/AlbumHeader.vue b/apps/viewer/src/components/album/AlbumHeader.vue index e709d0e..117c296 100644 --- a/apps/viewer/src/components/album/AlbumHeader.vue +++ b/apps/viewer/src/components/album/AlbumHeader.vue @@ -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 | 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 { - 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(() => { - +

+ + + + + diff --git a/docs/designs/ui04-implementation/actual/server/banner-natural-ratio.png b/docs/designs/ui04-implementation/actual/server/banner-natural-ratio.png new file mode 100644 index 0000000..35a431a Binary files /dev/null and b/docs/designs/ui04-implementation/actual/server/banner-natural-ratio.png differ diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 049cad2..06817dd 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -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] diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index c075a27..173eeb9 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4c499..8a8dced 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/services/api/src/albums/assets.controller.ts b/services/api/src/albums/assets.controller.ts index 0a99e6e..f37408f 100644 --- a/services/api/src/albums/assets.controller.ts +++ b/services/api/src/albums/assets.controller.ts @@ -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 { + return this.assetsService.status(userOf(req), albumId); + } + @Post() initAlbumAssetUpload( @Param('albumId') albumId: string, diff --git a/services/api/src/albums/assets.service.ts b/services/api/src/albums/assets.service.ts index 83be55e..f372d32 100644 --- a/services/api/src/albums/assets.service.ts +++ b/services/api/src/albums/assets.service.ts @@ -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 { + 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(); + 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 都是新行新键——无幂等裁决,天然无并发冲突锚。