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}` })
|
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 直传凭证) */
|
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
|
||||||
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
|
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
|
||||||
return request.post<Api.Album.AssetInitResponse>({
|
return request.post<Api.Album.AssetInitResponse>({
|
||||||
|
|||||||
@@ -407,7 +407,12 @@
|
|||||||
"stageError": "Upload failed",
|
"stageError": "Upload failed",
|
||||||
"reupload": "Upload Again",
|
"reupload": "Upload Again",
|
||||||
"preview": "Preview (signed URL, valid until {expiresAt})",
|
"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": {
|
"members": {
|
||||||
"title": "Album members",
|
"title": "Album members",
|
||||||
|
|||||||
@@ -407,7 +407,12 @@
|
|||||||
"stageError": "上传失败",
|
"stageError": "上传失败",
|
||||||
"reupload": "重新上传",
|
"reupload": "重新上传",
|
||||||
"preview": "当前预览(签名 URL,{expiresAt} 前有效)",
|
"preview": "当前预览(签名 URL,{expiresAt} 前有效)",
|
||||||
"invalidType": "仅支持 jpg / png / heic 图片"
|
"invalidType": "仅支持 jpg / png / heic 图片",
|
||||||
|
"statusHint": "当前生效的宣传图 / 封面 / 标志(同类型再上传即替换)",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"notSetHint": "尚未上传,可选择下方上传",
|
||||||
|
"setAt": "设置于 {time}"
|
||||||
},
|
},
|
||||||
"members": {
|
"members": {
|
||||||
"title": "相册成员",
|
"title": "相册成员",
|
||||||
|
|||||||
Vendored
+15
@@ -141,6 +141,21 @@ declare namespace Api {
|
|||||||
expiresAt: string
|
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 {
|
interface Member {
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -2,8 +2,45 @@
|
|||||||
签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名
|
签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名
|
||||||
URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 -->
|
URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 -->
|
||||||
<template>
|
<template>
|
||||||
<ElCard :header="t('albumsManage.detail.banner')" data-test="album-banner-card">
|
<ElCard data-test="album-banner-card">
|
||||||
<div class="flex flex-col gap-3">
|
<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">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<ElSelect v-model="kind" style="width: 200px" data-test="album-banner-kind">
|
<ElSelect v-model="kind" style="width: 200px" data-test="album-banner-kind">
|
||||||
<ElOption value="banner" :label="t('albumsManage.banner.kindBanner')" />
|
<ElOption value="banner" :label="t('albumsManage.banner.kindBanner')" />
|
||||||
@@ -76,9 +113,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 {
|
import {
|
||||||
INITIAL_ASSET_UPLOAD_STATE,
|
INITIAL_ASSET_UPLOAD_STATE,
|
||||||
nextAssetUploadState,
|
nextAssetUploadState,
|
||||||
@@ -92,6 +130,63 @@
|
|||||||
|
|
||||||
const { t } = useI18n()
|
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 kind = ref<Api.Album.AssetKind>('banner')
|
||||||
const locale = ref<'' | Api.Album.Locale>('')
|
const locale = ref<'' | Api.Album.Locale>('')
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -192,8 +287,92 @@
|
|||||||
try {
|
try {
|
||||||
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
|
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
|
||||||
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
|
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
|
||||||
|
// 生效后刷新现状区(替换语义:同组旧图被替换)
|
||||||
|
await loadStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
|
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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:*",
|
"@photo/contracts": "workspace:*",
|
||||||
"vue": "~3.4.0",
|
"vue": "~3.4.0",
|
||||||
"vue-i18n": "^9.14.0",
|
"vue-i18n": "^9.14.0",
|
||||||
"vue-router": "~4.4.0"
|
"vue-router": "~4.4.0",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@photo/db-cn": "workspace:*",
|
"@photo/db-cn": "workspace:*",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { PublicAlbum, SortMode } from '../../api/client';
|
import type { PublicAlbum, SortMode } from '../../api/client';
|
||||||
import { shareOrCopy } from '../../support/share';
|
import ShareSheet from '../viewer/ShareSheet.vue';
|
||||||
|
|
||||||
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
||||||
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
||||||
@@ -122,27 +122,17 @@ const venueText = computed(() => {
|
|||||||
|
|
||||||
const descriptionText = computed(() => props.album.translation.description);
|
const descriptionText = computed(() => props.album.translation.description);
|
||||||
|
|
||||||
// —— 分享(V05 §5.4 完整编排):navigator.share 优先(相册标题 + 相册路由
|
// —— 分享(2026-09-23 用户需求:改为 App 式弹层——二维码 + 链接 + 复制,
|
||||||
// URL,附 lang 参数;不分享 OSS URL),不可用/失败回退剪贴板;复制失败
|
// 页面级「已复制」提示退役;深链规则不变:相册路由 + lang,不分享 OSS URL)——
|
||||||
// 保留可选中文本;用户取消原生分享不显示错误 ——
|
const shareSheetOpen = ref(false);
|
||||||
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
|
|
||||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const albumUrl = computed(() => {
|
const albumUrl = computed(() => {
|
||||||
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
|
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
|
||||||
return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
|
return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function share(): Promise<void> {
|
function share(): void {
|
||||||
const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.value });
|
shareSheetOpen.value = true;
|
||||||
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
|
|
||||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
|
||||||
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
|
|
||||||
if (outcome === 'copied') {
|
|
||||||
shareResetTimer = setTimeout(() => {
|
|
||||||
shareState.value = 'idle';
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
|
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
|
||||||
@@ -197,10 +187,12 @@ onBeforeUnmount(() => {
|
|||||||
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
|
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
|
||||||
{{ titleExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
|
{{ titleExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="shareState !== 'idle'" class="share-status" role="status">
|
<ShareSheet
|
||||||
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
|
v-model="shareSheetOpen"
|
||||||
<span v-if="shareState === 'failed'" class="share-url">{{ albumUrl }}</span>
|
:heading="t('viewer.shareAlbum')"
|
||||||
</span>
|
:title="album.translation.title"
|
||||||
|
:url="albumUrl"
|
||||||
|
/>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="descriptionText !== null"
|
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:
|
/albums/{albumId}/assets:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/AlbumId'
|
- $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:
|
post:
|
||||||
operationId: initAlbumAssetUpload
|
operationId: initAlbumAssetUpload
|
||||||
tags: [assets]
|
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:
|
MemberAddRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [userId]
|
required: [userId]
|
||||||
|
|||||||
+56
-1
@@ -187,7 +187,11 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
/**
|
||||||
|
* 各类资产当前生效项(管理端现状展示)
|
||||||
|
* @description 返回每个 (kind, locale) 组合的最新 effective 资产行 + 现签短期 URL (同「替换语义」——同组再上传即整体替换)。未设置的组合不出现; 签名过期重取本端点刷新。权限同相册详情(可见即可读)。
|
||||||
|
*/
|
||||||
|
get: operations["listAlbumAssetStatus"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* 初始化资产上传
|
* 初始化资产上传
|
||||||
@@ -1518,6 +1522,24 @@ export interface components {
|
|||||||
/** @description 资产 ID。 */
|
/** @description 资产 ID。 */
|
||||||
readonly assetId: string;
|
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: {
|
MemberAddRequest: {
|
||||||
/** @description 被添加用户的 ID(须为已开通账号)。 */
|
/** @description 被添加用户的 ID(须为已开通账号)。 */
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -3125,6 +3147,39 @@ export interface operations {
|
|||||||
500: components["responses"]["InternalError"];
|
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: {
|
initAlbumAssetUpload: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Generated
+3
@@ -186,6 +186,9 @@ importers:
|
|||||||
'@photo/contracts':
|
'@photo/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/contracts
|
version: link:../../packages/contracts
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
vue:
|
vue:
|
||||||
specifier: ~3.4.0
|
specifier: ~3.4.0
|
||||||
version: 3.4.38(typescript@5.9.3)
|
version: 3.4.38(typescript@5.9.3)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
import { userOf } from './albums.controller';
|
import { userOf } from './albums.controller';
|
||||||
import { AssetInitRequestDto } from './dto';
|
import { AssetInitRequestDto } from './dto';
|
||||||
@@ -29,6 +31,14 @@ export class AssetsController {
|
|||||||
constructor(private readonly assetsService: AssetsService) {}
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
|
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
|
||||||
|
@Get()
|
||||||
|
listStatus(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<components['schemas']['AssetStatusResponse']> {
|
||||||
|
return this.assetsService.status(userOf(req), albumId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
initAlbumAssetUpload(
|
initAlbumAssetUpload(
|
||||||
@Param('albumId') albumId: string,
|
@Param('albumId') albumId: string,
|
||||||
|
|||||||
@@ -63,6 +63,50 @@ export class AssetsService {
|
|||||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
@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。
|
* 初始化:权限/参数校验 → 建行(state=pending)→ presign。
|
||||||
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。
|
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。
|
||||||
|
|||||||
Reference in New Issue
Block a user