ai-clone/frontend-ai/pages/plaza/plaza.vue

582 lines
12 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<view class="plaza-container">
<view class="header">
<text class="title">作品广场</text>
<text class="subtitle">发现大家发布的作品</text>
</view>
<scroll-view scroll-y class="works-list" @scrolltolower="loadMore">
<view v-if="loading && works.length === 0" class="works-grid skeleton-grid">
<view v-for="n in 6" :key="n" class="work-card skeleton-card">
<view class="work-cover skeleton-cover"></view>
<view class="work-info">
<view class="skeleton-line skeleton-title"></view>
<view class="skeleton-line skeleton-sub"></view>
<view class="skeleton-line skeleton-sub short"></view>
</view>
</view>
</view>
<view v-else-if="!loading && works.length === 0" class="empty-box">
<text class="empty-icon">🖼</text>
<text class="empty-text">暂无作品</text>
<text class="empty-hint">快去发布你的第一个作品吧</text>
</view>
<view v-else class="works-grid">
<view
v-for="work in works"
:key="work.id"
class="work-card"
@click="viewWork(work)"
>
<view class="work-cover">
<image v-if="work.coverUrl" :src="work.coverUrl" mode="aspectFill" class="cover-img" />
<view v-else class="cover-placeholder">
<text class="placeholder-icon">🎬</text>
</view>
<view class="work-type">{{ work.workTypeLabel }}</view>
<!-- AI生成提示标签 -->
<view class="ai-tag">
<text class="ai-tag-text">AI生成</text>
</view>
2026-03-05 14:29:21 +08:00
</view>
<view class="work-info">
<text class="work-title">{{ work.title }}</text>
<text class="work-author">{{ work.userNickname || '匿名' }}</text>
<text class="work-time">{{ formatTime(work.publishedAt || work.createdAt) }}</text>
<view class="work-actions" @click.stop>
<view class="like-btn" :class="{ liked: work.liked }" @click.stop="toggleLike(work)">
<text class="like-icon">{{ work.liked ? '❤️' : '🤍' }}</text>
<text class="like-count">{{ work.likeCount || 0 }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="loading && works.length > 0" class="loading-more">
<text class="loading-more-text"> 加载更多...</text>
</view>
<view v-if="!hasMore && works.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</template>
<script>
import { API_BASE, API_ENDPOINTS } from '@/config/api.js';
export default {
data() {
return {
API_BASE,
works: [],
page: 0,
size: 10,
loading: false,
hasMore: true,
loadedOnce: false,
hasCachedWorks: false,
enableVideoPreload: false,
preloadQueue: [],
activePreloads: 0,
maxConcurrentPreloads: 2,
preloadingKeys: {}
};
},
onLoad() {
try {
const cached = uni.getStorageSync('plaza_works_cache_v1');
if (Array.isArray(cached) && cached.length > 0) {
this.works = cached;
this.hasCachedWorks = true;
this.loadedOnce = true;
}
} catch (e) {
this.hasCachedWorks = false;
}
this.loadWorks(true);
},
onShow() {
if (!this.loadedOnce || this.works.length === 0) {
this.loadWorks(true);
}
},
methods: {
normalizeMediaUrl(url) {
if (!url) return url;
if (url.startsWith('/')) {
return `${this.API_BASE}${url}`;
}
if (url.startsWith('http://115.190.167.176:20002')) {
return url.replace('http://115.190.167.176:20002', this.API_BASE);
}
return url;
},
generateCacheKey(url) {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'video_' + Math.abs(hash);
},
enqueuePreload(url) {
if (!this.enableVideoPreload) return;
if (!url) return;
const cacheKey = this.generateCacheKey(url);
if (this.preloadingKeys[cacheKey]) return;
const cachedPath = uni.getStorageSync(cacheKey);
if (cachedPath) {
return;
}
this.preloadingKeys[cacheKey] = true;
this.preloadQueue.push({ url, cacheKey });
this.processPreloadQueue();
},
processPreloadQueue() {
while (this.activePreloads < this.maxConcurrentPreloads && this.preloadQueue.length > 0) {
const item = this.preloadQueue.shift();
this.activePreloads += 1;
this.preloadVideoToCache(item.url, item.cacheKey)
.finally(() => {
this.activePreloads = Math.max(0, this.activePreloads - 1);
this.processPreloadQueue();
});
}
},
preloadVideoToCache(url, cacheKey) {
return new Promise((resolve) => {
uni.downloadFile({
url,
timeout: 60000,
success: (res) => {
if (res.statusCode !== 200) {
resolve();
return;
}
uni.saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
const savedPath = saveRes.savedFilePath;
uni.setStorageSync(cacheKey, savedPath);
const cacheInfo = uni.getStorageSync('video_cache_info') || {};
cacheInfo[cacheKey] = {
path: savedPath,
url,
time: Date.now()
};
uni.setStorageSync('video_cache_info', cacheInfo);
resolve();
},
fail: () => {
resolve();
}
});
},
fail: () => {
resolve();
},
complete: () => {
if (this.preloadingKeys && cacheKey) {
this.preloadingKeys[cacheKey] = false;
}
}
});
});
},
preloadWorks(works) {
if (!Array.isArray(works) || works.length === 0) return;
uni.getNetworkType({
success: (res) => {
if (!res || res.networkType !== 'wifi') {
return;
}
works.forEach((w) => {
if (w && w.contentUrl) {
this.enqueuePreload(w.contentUrl);
}
});
},
fail: () => {
return;
}
});
},
getWorkTypeLabel(type) {
const map = {
VOICE_CLONE: '声音克隆',
PHOTO_REVIVAL: '照片复活',
VIDEO_CALL: '视频对话'
};
return map[type] || '作品';
},
toggleLike(work) {
const token = uni.getStorageSync('token') || '';
const userId = uni.getStorageSync('userId') || '';
if (!userId) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
uni.request({
url: `${this.API_BASE}/api/works/${work.id}/like`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.success && res.data.data) {
work.liked = !!res.data.data.liked;
work.likeCount = res.data.data.likeCount || 0;
return;
}
uni.showToast({ title: res.data?.message || '操作失败', icon: 'none' });
},
fail: () => {
uni.showToast({ title: '操作失败', icon: 'none' });
}
});
},
formatTime(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const pad = (n) => (n < 10 ? `0${n}` : `${n}`);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
loadMore() {
if (this.loading || !this.hasMore) return;
this.loadWorks(false);
},
loadWorks(reset) {
if (this.loading) return;
this.loading = true;
const token = uni.getStorageSync('token') || '';
const userId = uni.getStorageSync('userId') || '';
if (reset) {
this.page = 0;
this.hasMore = true;
if (!this.hasCachedWorks) {
this.works = [];
}
}
uni.request({
url: `${this.API_BASE}${API_ENDPOINTS.works.plaza}?page=${this.page}&size=${this.size}`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.statusCode === 200 && res.data) {
const content = res.data.content || [];
const mapped = content.map((w) => ({
...w,
coverUrl: this.normalizeMediaUrl(w.coverUrl),
contentUrl: this.normalizeMediaUrl(w.contentUrl),
audioUrl: this.normalizeMediaUrl(w.audioUrl),
workTypeLabel: this.getWorkTypeLabel(w.workType),
likeCount: w.likeCount || 0,
liked: !!w.liked
}));
if (reset) {
this.works = mapped;
this.hasCachedWorks = false;
try {
uni.setStorageSync('plaza_works_cache_v1', mapped);
} catch (e) {}
} else {
this.works = this.works.concat(mapped);
}
this.page += 1;
this.hasMore = !res.data.last;
this.loadedOnce = true;
return;
}
uni.showToast({
title: `加载失败(${res.statusCode})`,
icon: 'none'
});
},
fail: () => {
uni.showToast({
title: '加载失败',
icon: 'none'
});
},
complete: () => {
this.loading = false;
}
});
},
viewWork(work) {
if (!work || (!work.contentUrl && !work.editedVideoUrl && !work.edited_video_url)) {
uni.showToast({ title: '作品不可用', icon: 'none' });
return;
}
const videoUrl = work.editedVideoUrl || work.edited_video_url || work.contentUrl;
let url = `/pages/video-player/video-player?audioInVideo=1&url=${encodeURIComponent(videoUrl)}&title=${encodeURIComponent(work.title || '作品')}`;
uni.navigateTo({ url });
}
}
};
</script>
<style lang="scss" scoped>
.plaza-container {
min-height: 100vh;
background: #FDF8F2;
display: flex;
flex-direction: column;
}
.header {
padding: 32upx 32upx 20upx 40upx;
.title {
font-size: 44upx;
font-weight: 700;
color: #8B7355;
display: block;
margin-bottom: 8upx;
}
.subtitle {
font-size: 24upx;
color: #999;
display: block;
}
}
.works-list {
flex: 1;
padding: 0 24upx 40upx;
box-sizing: border-box;
}
.loading-box,
.empty-box {
padding: 200upx 40upx;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.loading-text {
font-size: 28upx;
color: #999;
}
.empty-icon {
font-size: 120upx;
margin-bottom: 30upx;
opacity: 0.5;
}
.empty-text {
font-size: 32upx;
color: #666;
margin-bottom: 16upx;
}
.empty-hint {
font-size: 26upx;
color: #999;
}
.works-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16upx;
}
.skeleton-grid {
padding-top: 16upx;
}
.skeleton-card {
background: white;
}
.skeleton-cover {
position: relative;
width: 100%;
padding-top: 150%;
background: #f1f1f1;
}
.skeleton-line {
height: 22upx;
background: #f1f1f1;
border-radius: 10upx;
margin-top: 12upx;
}
.skeleton-title {
height: 26upx;
margin-top: 0;
}
.skeleton-sub {
height: 20upx;
}
.skeleton-sub.short {
width: 60%;
}
.work-card {
background: white;
border-radius: 16upx;
overflow: hidden;
box-shadow: 0 4upx 12upx rgba(0, 0, 0, 0.06);
transition: all 0.3s;
&:active {
transform: translateY(-4upx);
box-shadow: 0 8upx 20upx rgba(0, 0, 0, 0.12);
}
}
.work-cover {
position: relative;
width: 100%;
padding-top: 150%;
overflow: hidden;
background: #F5F5F5;
.cover-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-icon {
font-size: 60upx;
color: #ccc;
}
.work-type {
position: absolute;
top: 12upx;
right: 12upx;
padding: 6upx 16upx;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 20upx;
border-radius: 20upx;
}
/* AI生成提示标签 */
.ai-tag {
position: absolute;
top: 12upx;
left: 12upx;
padding: 6upx 16upx;
background: rgba(255, 165, 0, 0.85);
color: white;
font-size: 20upx;
border-radius: 20upx;
font-weight: 500;
}
2026-03-05 14:29:21 +08:00
}
.work-info {
padding: 20upx;
.work-title {
display: block;
font-size: 28upx;
font-weight: bold;
color: #333;
margin-bottom: 12upx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-author {
display: block;
font-size: 22upx;
color: #666;
margin-bottom: 10upx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-time {
display: block;
font-size: 22upx;
color: #999;
}
}
.work-actions {
margin-top: 14upx;
display: flex;
justify-content: flex-end;
}
.like-btn {
display: flex;
align-items: center;
gap: 10upx;
padding: 10upx 16upx;
border-radius: 999upx;
background: rgba(139, 115, 85, 0.08);
}
.like-btn.liked {
background: rgba(220, 60, 90, 0.10);
}
.like-icon {
font-size: 26upx;
}
.like-count {
font-size: 22upx;
color: #666;
}
.loading-more-text,
.no-more-text {
font-size: 24upx;
color: #999;
}
</style>