forked from wangziqi/gongxue-base
feat: enforce asset watermark preview flow
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user