feat: enforce asset watermark preview flow

This commit is contained in:
Codex
2026-06-30 00:16:57 +08:00
parent 3d10497ea8
commit 3905e3cb77
9 changed files with 232 additions and 21 deletions

View File

@@ -1,20 +1,62 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { loadContentAssets, signAssetDownload, signAssetPreview, type ContentAsset } from '@/services/catalog';
import { Button, Text, View, WebView } from '@tarojs/components';
import {
loadContentAssets,
signAssetDownload,
signAssetPreview,
type AssetWatermarkContext,
type ContentAsset,
type SignedAssetLink,
} from '@/services/catalog';
import '../student.css';
function openUrl(url?: string) {
type PreviewState = {
kind: 'preview' | 'download';
asset: ContentAsset;
link: SignedAssetLink;
watermark?: AssetWatermarkContext;
};
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
if (!url) return;
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
Taro.setClipboardData({ data: url });
Taro.showToast({ title: copiedText, icon: 'none' });
}
function isImageAsset(asset: ContentAsset) {
const mime = asset.mimeType || '';
const type = asset.assetType || '';
const name = asset.fileName || '';
return mime.startsWith('image/') || type === 'image' || /\.(png|jpe?g|webp|gif)$/i.test(name);
}
function formatExpires(link?: SignedAssetLink) {
if (link?.expiresAt) return `有效期至 ${new Date(link.expiresAt).toLocaleString()}`;
if (link?.expiresInSec) return `${link.expiresInSec} 秒后过期`;
return '短期签名,离开页面后请重新申请';
}
function watermarkStyle(watermark?: AssetWatermarkContext) {
return {
opacity: String(watermark?.opacity ?? 0.16),
};
}
function canOpenOutside(state: PreviewState | null) {
if (!state?.link.url) return false;
if (state.kind === 'download') return true;
return state.watermark?.required !== true;
}
export default function StudentAssetsPage() {
const [assets, setAssets] = useState<ContentAsset[]>([]);
const [previewState, setPreviewState] = useState<PreviewState | null>(null);
const [loadingAssetId, setLoadingAssetId] = useState('');
const [error, setError] = useState('');
useEffect(() => {
@@ -25,24 +67,44 @@ export default function StudentAssetsPage() {
async function preview(asset: ContentAsset) {
try {
setLoadingAssetId(asset.id);
setError('');
const payload = await signAssetPreview(asset.id);
openUrl(payload.preview?.url);
if (!payload.preview?.url) throw new Error('后端未返回可预览签名');
setPreviewState({ kind: 'preview', asset: payload.item || asset, link: payload.preview, watermark: payload.watermark });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '预览失败');
} finally {
setLoadingAssetId('');
}
}
async function download(asset: ContentAsset) {
try {
setLoadingAssetId(asset.id);
setError('');
const payload = await signAssetDownload(asset.id);
openUrl(payload.download?.url);
if (!payload.download?.url) throw new Error('后端未返回可下载签名');
const watermark = payload.watermark;
if (watermark?.required) {
setPreviewState({ kind: 'download', asset: payload.item || asset, link: payload.download, watermark });
Taro.showToast({ title: '请确认水印追踪码后下载', icon: 'none' });
return;
}
openUrl(payload.download?.url, '下载签名已复制,请及时使用');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '下载失败');
} finally {
setLoadingAssetId('');
}
}
function openSignedLink() {
openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开');
}
return (
<View className='student-page'>
<View className='student-page assets-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>Assets</Text>
@@ -50,15 +112,64 @@ export default function StudentAssetsPage() {
<Text className='student-subtitle'></Text>
</View>
</View>
{previewState ? (
<View className='asset-preview-panel section-block'>
<View className='amount-row'>
<View>
<Text className='section-heading'>{previewState.kind === 'download' ? '确认下载' : '资料预览'} · {previewState.asset.title || previewState.asset.fileName || '未命名资料'}</Text>
<Text className='row-meta'>{formatExpires(previewState.link)} · {previewState.link.signatureMode || 'signed'}</Text>
</View>
<Button className='secondary-button' onClick={() => setPreviewState(null)}></Button>
</View>
<View className='asset-preview-frame'>
{previewState.kind === 'download' ? (
<View className='asset-download-confirm'>
<Text className='row-main'></Text>
<Text className='row-meta'> URL </Text>
</View>
) : null}
{previewState.kind === 'preview' && process.env.TARO_ENV === 'h5' ? (
<WebView className='asset-preview-iframe' src={previewState.link.url || ''} />
) : null}
{previewState.kind === 'preview' && process.env.TARO_ENV !== 'h5' ? (
<View className='asset-download-confirm'>
<Text className='row-main'></Text>
<Text className='row-meta'>使</Text>
</View>
) : null}
{previewState.watermark?.mode === 'visible_overlay' && previewState.watermark.required ? (
<View className={`asset-watermark ${previewState.watermark.repeat ? 'repeat' : ''} ${previewState.watermark.position || 'diagonal'}`} style={watermarkStyle(previewState.watermark)}>
<Text className='asset-watermark-text'>{previewState.watermark.text || previewState.watermark.traceId}</Text>
</View>
) : null}
</View>
<View className='asset-security-strip'>
<Text className='row-meta'>{previewState.watermark?.traceId || '无'}访</Text>
<View className='toolbar wrap checkout-actions'>
{isImageAsset(previewState.asset) && previewState.watermark?.required !== true ? (
<Button className='secondary-button' onClick={() => Taro.previewImage({ current: previewState.link.url || '', urls: [previewState.link.url || ''] })}></Button>
) : null}
{canOpenOutside(previewState) ? (
<Button className={previewState.kind === 'download' ? 'primary-button' : 'secondary-button'} onClick={openSignedLink}>{previewState.kind === 'download' ? '确认下载' : '外部打开'}</Button>
) : null}
</View>
{previewState.kind === 'preview' && previewState.watermark?.required ? (
<Text className='row-meta'>使 H5 </Text>
) : null}
</View>
</View>
) : null}
<View className='list-stack'>
{assets.map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.title || item.fileName || '未命名资料'}</Text>
<Text className='row-meta'>{item.assetType || 'asset'} · {item.visibility || 'public'}</Text>
<Text className='row-meta'>{item.assetType || 'asset'} · {item.visibility || 'public'}{item.previewStatus ? ` · ${item.previewStatus}` : ''}</Text>
{item.description ? <Text className='row-meta'>{item.description}</Text> : null}
<View className='toolbar'>
<Button className='secondary-button' onClick={() => preview(item)}></Button>
<Button className='primary-button' onClick={() => download(item)}></Button>
<Button className='secondary-button' onClick={() => preview(item)}>{loadingAssetId === item.id ? '申请中' : '预览'}</Button>
<Button className='primary-button' onClick={() => download(item)}>{loadingAssetId === item.id ? '申请中' : '下载'}</Button>
</View>
</View>
))}

View File

@@ -516,3 +516,94 @@
color: #94a3b8;
font-size: 20px;
}
.assets-page .list-stack {
margin-top: 20px;
}
.asset-preview-panel {
padding: 24px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #fff;
}
.asset-preview-frame {
position: relative;
min-height: 720px;
overflow: hidden;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #f8fafc;
}
.asset-preview-iframe {
width: 100%;
height: 720px;
border: 0;
background: #fff;
}
.asset-download-confirm {
padding: 60px 36px;
}
.asset-watermark {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
color: #0f172a;
}
.asset-watermark.repeat {
align-items: stretch;
justify-content: stretch;
background-image:
repeating-linear-gradient(
-28deg,
transparent 0,
transparent 92px,
rgba(15, 23, 42, 0.08) 92px,
rgba(15, 23, 42, 0.08) 94px,
transparent 94px,
transparent 168px
);
}
.asset-watermark.diagonal .asset-watermark-text,
.asset-watermark.repeat .asset-watermark-text {
transform: rotate(-24deg);
}
.asset-watermark.bottom-right {
align-items: flex-end;
justify-content: flex-end;
padding: 24px;
}
.asset-watermark.center {
align-items: center;
justify-content: center;
}
.asset-watermark-text {
max-width: 86%;
color: #0f172a;
font-size: 32px;
font-weight: 850;
line-height: 1.35;
text-align: center;
word-break: break-word;
}
.asset-security-strip {
margin-top: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}

View File

@@ -137,6 +137,13 @@ export interface AssetWatermarkContext {
renderHint: string;
}
export interface SignedAssetLink {
url?: string;
expiresAt?: string | null;
expiresInSec?: number | null;
signatureMode?: string | null;
}
export async function loadRegions() {
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions');
}
@@ -204,9 +211,9 @@ export async function loadContentAssets(query: { entryId?: string; contentNodeId
}
export async function signAssetPreview(assetId: string) {
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/preview', { query: { assetId } });
return apiRequest<{ preview?: SignedAssetLink; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/preview', { query: { assetId } });
}
export async function signAssetDownload(assetId: string) {
return apiRequest<{ download?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/download', { query: { assetId } });
return apiRequest<{ download?: SignedAssetLink; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/download', { query: { assetId } });
}