527 lines
26 KiB
HTML
527 lines
26 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}朋友圈管理{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-shell friend-circle-page">
|
|
<div class="page-hero">
|
|
<div class="page-hero-copy">
|
|
<div class="page-eyebrow">Friend Circle Workspace</div>
|
|
<h1>朋友圈管理</h1>
|
|
<p>在后台直接完成朋友圈查看、发布、点赞和评论,不再依赖聊天指令。</p>
|
|
</div>
|
|
<div class="page-hero-actions">
|
|
<el-button @click="resetFilters">重置视图</el-button>
|
|
<el-button type="primary" @click="loadPosts">刷新列表</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="friend-circle-grid">
|
|
<div class="friend-circle-main">
|
|
<el-card class="workspace-card" shadow="hover">
|
|
<div slot="header" class="workspace-header">
|
|
<div>
|
|
<h3>查看范围</h3>
|
|
<p>支持首页列表和指定好友朋友圈切换查看。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<el-form :inline="true" size="small" class="filter-form">
|
|
<el-form-item label="查看模式">
|
|
<el-radio-group v-model="viewMode" size="small">
|
|
<el-radio-button label="home">首页流</el-radio-button>
|
|
<el-radio-button label="user">指定好友</el-radio-button>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
|
|
<el-form-item v-if="viewMode === 'user'" label="好友">
|
|
<el-select
|
|
v-model="selectedTowxid"
|
|
filterable
|
|
remote
|
|
clearable
|
|
reserve-keyword
|
|
placeholder="输入昵称或wxid搜索"
|
|
:remote-method="searchContacts"
|
|
:loading="contactsLoading"
|
|
style="width: 320px;">
|
|
<el-option
|
|
v-for="item in contactOptions"
|
|
:key="item.wxid"
|
|
:label="`${item.nickname} (${item.wxid})`"
|
|
:value="item.wxid">
|
|
</el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
|
|
<el-form-item v-if="viewMode === 'user'" label="或手动输入">
|
|
<el-input v-model="manualTowxid" placeholder="wxid_xxx"></el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="分页游标">
|
|
<el-input-number v-model="maxId" :min="0" :step="1" controls-position="right"></el-input-number>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button type="primary" @click="loadPosts">查询</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<div v-loading="loading" class="post-list">
|
|
<el-empty v-if="!loading && posts.length === 0" description="暂无朋友圈数据"></el-empty>
|
|
|
|
<el-card v-for="post in posts" :key="post.id" class="post-card" shadow="hover">
|
|
<div class="post-header">
|
|
<div class="post-author">
|
|
<el-avatar :src="post.author_avatar" :size="42">{% raw %}{{ (post.author_name || '?').slice(0, 1) }}{% endraw %}</el-avatar>
|
|
<div>
|
|
<div class="post-author-name">{% raw %}{{ post.author_name || post.author_wxid || '未知用户' }}{% endraw %}</div>
|
|
<div class="post-meta">{% raw %}{{ post.timestamp || '时间未知' }} · ID: {{ post.id }}{% endraw %}</div>
|
|
</div>
|
|
</div>
|
|
<div class="post-actions">
|
|
<el-button type="text" @click="openRawDialog(post)">原始数据</el-button>
|
|
<el-button type="text" @click="openCommentDialog(post)">评论</el-button>
|
|
<el-button
|
|
v-if="!post.is_liked"
|
|
type="text"
|
|
@click="toggleLike(post, false)">
|
|
点赞
|
|
</el-button>
|
|
<el-button
|
|
v-else
|
|
type="text"
|
|
class="danger-text"
|
|
@click="toggleLike(post, true)">
|
|
取消点赞
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="post-content">{% raw %}{{ post.content || '这条朋友圈没有解析出文字内容' }}{% endraw %}</div>
|
|
|
|
<div v-if="post.location && (post.location.poi_name || post.location.poi_address || post.location.city)" class="post-location">
|
|
<i class="el-icon-location-outline"></i>
|
|
<span>
|
|
{% raw %}{{ post.location.poi_name || post.location.poi_address || post.location.city }}{% endraw %}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="post.media && post.media.length" class="post-media-grid">
|
|
<div
|
|
v-for="(media, index) in post.media"
|
|
:key="`${post.id}-${index}`"
|
|
class="post-media-item"
|
|
:class="{ 'is-video': media.is_video }"
|
|
@click="openMediaPreview(media)">
|
|
<img :src="getMediaProxyUrl(media.thumb || media.hd || media.url)" :alt="post.content || '朋友圈媒体'">
|
|
<div v-if="media.is_video" class="video-badge">
|
|
<i class="el-icon-video-play"></i>
|
|
<span>{% raw %}{{ formatVideoDuration(media.video_duration) }}{% endraw %}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="post-feedback">
|
|
<div class="feedback-block">
|
|
<div class="feedback-title">点赞 {% raw %}{{ post.likes.length || post.like_count || 0 }}{% endraw %}</div>
|
|
<div v-if="post.likes.length" class="feedback-list">
|
|
<span v-for="(like, index) in post.likes" :key="`${post.id}-like-${index}`" class="feedback-chip">
|
|
{% raw %}{{ like.author_name || like.author_wxid }}{% endraw %}
|
|
</span>
|
|
</div>
|
|
<div v-else class="feedback-empty">暂无点赞</div>
|
|
</div>
|
|
|
|
<div class="feedback-block">
|
|
<div class="feedback-title">评论 {% raw %}{{ post.comments.length }}{% endraw %}</div>
|
|
<div v-if="post.comments.length" class="comment-list">
|
|
<div v-for="(comment, index) in post.comments" :key="`${post.id}-comment-${index}`" class="comment-item">
|
|
<span class="comment-author">{% raw %}{{ comment.author_name || comment.author_wxid || '匿名' }}{% endraw %}</span>
|
|
<span class="comment-content">{% raw %}{{ comment.content }}{% endraw %}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="feedback-empty">暂无评论</div>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="friend-circle-side">
|
|
<el-card class="workspace-card publish-card" shadow="hover">
|
|
<div slot="header" class="workspace-header">
|
|
<div>
|
|
<h3>发布朋友圈</h3>
|
|
<p>支持纯文本,也支持上传多张图片一起发布。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<el-form label-position="top" size="small">
|
|
<el-form-item label="文案">
|
|
<el-input
|
|
v-model="publishForm.content"
|
|
type="textarea"
|
|
:rows="5"
|
|
placeholder="输入朋友圈文案">
|
|
</el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="可见人白名单">
|
|
<el-input v-model="publishForm.with_user_list" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="不可见人黑名单">
|
|
<el-input v-model="publishForm.blacklist" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="配图">
|
|
<div class="upload-toolbar">
|
|
<label class="upload-trigger">
|
|
<input type="file" accept="image/*" multiple @change="handleImageChange">
|
|
<span>选择图片</span>
|
|
</label>
|
|
<el-button type="text" @click="clearImages" v-if="publishImages.length">清空</el-button>
|
|
</div>
|
|
<div v-if="publishImages.length" class="upload-preview-grid">
|
|
<div v-for="(image, index) in publishImages" :key="`upload-${index}`" class="upload-preview-item">
|
|
<img :src="image" alt="预览图">
|
|
</div>
|
|
</div>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button type="primary" :loading="publishing" @click="submitPublish">发布</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
</div>
|
|
</div>
|
|
|
|
<el-dialog title="发表评论" :visible.sync="commentDialogVisible" width="420px">
|
|
<el-form label-position="top" size="small">
|
|
<el-form-item label="朋友圈ID">
|
|
<el-input :value="selectedPost ? selectedPost.id : ''" disabled></el-input>
|
|
</el-form-item>
|
|
<el-form-item label="回复评论ID">
|
|
<el-input-number v-model="commentForm.reply_comment_id" :min="0" :step="1" controls-position="right"></el-input-number>
|
|
</el-form-item>
|
|
<el-form-item label="评论内容">
|
|
<el-input v-model="commentForm.content" type="textarea" :rows="4" placeholder="输入评论内容"></el-input>
|
|
</el-form-item>
|
|
</el-form>
|
|
<span slot="footer">
|
|
<el-button @click="commentDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="commentSubmitting" @click="submitComment">提交评论</el-button>
|
|
</span>
|
|
</el-dialog>
|
|
|
|
<el-dialog title="原始返回" :visible.sync="rawDialogVisible" width="70%">
|
|
<pre class="raw-json">{% raw %}{{ rawDialogContent }}{% endraw %}</pre>
|
|
</el-dialog>
|
|
|
|
<el-dialog :visible.sync="imagePreviewVisible" width="70%" class="image-preview-dialog">
|
|
<img v-if="previewMedia && !previewMedia.is_video && previewImageUrl" :src="previewImageUrl" class="large-preview" alt="朋友圈图片预览">
|
|
<video
|
|
v-if="previewMedia && previewMedia.is_video"
|
|
:src="getMediaProxyUrl(previewMedia.url)"
|
|
:poster="getMediaProxyUrl(previewMedia.thumb || previewMedia.hd || previewMedia.url)"
|
|
class="large-video"
|
|
controls
|
|
playsinline
|
|
webkit-playsinline>
|
|
</video>
|
|
</el-dialog>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
new Vue({
|
|
el: '#app',
|
|
mixins: [baseApp],
|
|
data() {
|
|
return {
|
|
currentView: 'friend_circle',
|
|
showTimeRangeSelector: false,
|
|
loading: false,
|
|
publishing: false,
|
|
commentSubmitting: false,
|
|
contactsLoading: false,
|
|
viewMode: 'home',
|
|
selectedTowxid: '',
|
|
manualTowxid: '',
|
|
maxId: 0,
|
|
posts: [],
|
|
contactOptions: [],
|
|
publishImages: [],
|
|
publishForm: {
|
|
content: '',
|
|
blacklist: '',
|
|
with_user_list: ''
|
|
},
|
|
commentDialogVisible: false,
|
|
rawDialogVisible: false,
|
|
imagePreviewVisible: false,
|
|
previewImageUrl: '',
|
|
previewMedia: null,
|
|
rawDialogContent: '',
|
|
selectedPost: null,
|
|
commentForm: {
|
|
content: '',
|
|
reply_comment_id: 0
|
|
}
|
|
}
|
|
},
|
|
mounted() {
|
|
this.loadPosts();
|
|
},
|
|
methods: {
|
|
async loadPosts() {
|
|
this.loading = true;
|
|
try {
|
|
const params = {};
|
|
const targetWxid = this.manualTowxid.trim() || this.selectedTowxid;
|
|
if (this.viewMode === 'user' && targetWxid) {
|
|
params.towxid = targetWxid;
|
|
}
|
|
if (this.maxId) {
|
|
params.max_id = this.maxId;
|
|
}
|
|
const response = await axios.get('/api/friend_circle/list', { params });
|
|
if (response.data.success) {
|
|
this.posts = response.data.data || [];
|
|
} else {
|
|
this.$message.error(response.data.message || '加载失败');
|
|
}
|
|
} catch (error) {
|
|
this.$message.error(error.response?.data?.message || '加载朋友圈失败');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
async searchContacts(keyword) {
|
|
this.contactsLoading = true;
|
|
try {
|
|
const response = await axios.get('/api/friend_circle/contacts', {
|
|
params: { keyword }
|
|
});
|
|
this.contactOptions = response.data.data || [];
|
|
} catch (error) {
|
|
this.contactOptions = [];
|
|
} finally {
|
|
this.contactsLoading = false;
|
|
}
|
|
},
|
|
handleImageChange(event) {
|
|
const files = Array.from(event.target.files || []);
|
|
if (!files.length) return;
|
|
Promise.all(files.map(file => this.fileToBase64(file))).then(images => {
|
|
this.publishImages = this.publishImages.concat(images);
|
|
});
|
|
event.target.value = '';
|
|
},
|
|
fileToBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
},
|
|
clearImages() {
|
|
this.publishImages = [];
|
|
},
|
|
async submitPublish() {
|
|
if (!this.publishForm.content.trim() && this.publishImages.length === 0) {
|
|
this.$message.warning('请填写文案或选择图片');
|
|
return;
|
|
}
|
|
this.publishing = true;
|
|
try {
|
|
const response = await axios.post('/api/friend_circle/publish', {
|
|
content: this.publishForm.content,
|
|
blacklist: this.publishForm.blacklist,
|
|
with_user_list: this.publishForm.with_user_list,
|
|
images: this.publishImages
|
|
});
|
|
if (response.data.success) {
|
|
this.$message.success('朋友圈发布成功');
|
|
this.publishForm = { content: '', blacklist: '', with_user_list: '' };
|
|
this.publishImages = [];
|
|
this.loadPosts();
|
|
} else {
|
|
this.$message.error(response.data.message || '发布失败');
|
|
}
|
|
} catch (error) {
|
|
this.$message.error(error.response?.data?.message || '发布朋友圈失败');
|
|
} finally {
|
|
this.publishing = false;
|
|
}
|
|
},
|
|
openCommentDialog(post) {
|
|
this.selectedPost = post;
|
|
this.commentForm = { content: '', reply_comment_id: 0 };
|
|
this.commentDialogVisible = true;
|
|
},
|
|
async submitComment() {
|
|
if (!this.selectedPost || !this.commentForm.content.trim()) {
|
|
this.$message.warning('请输入评论内容');
|
|
return;
|
|
}
|
|
this.commentSubmitting = true;
|
|
try {
|
|
const response = await axios.post('/api/friend_circle/comment', {
|
|
id: this.selectedPost.id,
|
|
content: this.commentForm.content,
|
|
reply_comment_id: this.commentForm.reply_comment_id
|
|
});
|
|
if (response.data.success) {
|
|
this.$message.success('评论成功');
|
|
this.commentDialogVisible = false;
|
|
this.loadPosts();
|
|
} else {
|
|
this.$message.error(response.data.message || '评论失败');
|
|
}
|
|
} catch (error) {
|
|
this.$message.error(error.response?.data?.message || '评论失败');
|
|
} finally {
|
|
this.commentSubmitting = false;
|
|
}
|
|
},
|
|
async toggleLike(post, cancel) {
|
|
try {
|
|
const response = await axios.post('/api/friend_circle/like', {
|
|
id: post.id,
|
|
cancel: cancel
|
|
});
|
|
if (response.data.success) {
|
|
this.$message.success(cancel ? '已取消点赞' : '点赞成功');
|
|
this.loadPosts();
|
|
} else {
|
|
this.$message.error(response.data.message || '操作失败');
|
|
}
|
|
} catch (error) {
|
|
this.$message.error(error.response?.data?.message || '点赞操作失败');
|
|
}
|
|
},
|
|
openRawDialog(post) {
|
|
this.rawDialogContent = JSON.stringify(post.raw || post, null, 2);
|
|
this.rawDialogVisible = true;
|
|
},
|
|
openMediaPreview(media) {
|
|
this.previewMedia = media;
|
|
this.previewImageUrl = this.getMediaProxyUrl(media.hd || media.url || media.thumb || '');
|
|
this.imagePreviewVisible = true;
|
|
},
|
|
getMediaProxyUrl(url) {
|
|
if (!url) return '';
|
|
if (url.startsWith('/api/friend_circle/media_proxy')) {
|
|
return url;
|
|
}
|
|
return `/api/friend_circle/media_proxy?url=${encodeURIComponent(url)}`;
|
|
},
|
|
formatVideoDuration(value) {
|
|
const seconds = Math.round(parseFloat(value || 0));
|
|
if (!seconds || Number.isNaN(seconds)) return '视频';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
|
},
|
|
resetFilters() {
|
|
this.viewMode = 'home';
|
|
this.selectedTowxid = '';
|
|
this.manualTowxid = '';
|
|
this.maxId = 0;
|
|
this.loadPosts();
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.page-shell { display: flex; flex-direction: column; gap: 16px; }
|
|
.page-hero {
|
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 24px 26px; border-radius: 24px;
|
|
background: linear-gradient(135deg, rgba(34,197,94,0.10), rgba(56,189,248,0.08), rgba(255,255,255,0.92));
|
|
border: 1px solid rgba(148, 163, 184, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
|
}
|
|
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #16a34a; font-weight: 700; margin-bottom: 8px; }
|
|
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
|
|
.page-hero-copy p { color: #64748b; font-size: 14px; }
|
|
.page-hero-actions { display: flex; align-items: center; gap: 12px; }
|
|
.friend-circle-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr); gap: 16px; align-items: start; }
|
|
.friend-circle-main, .friend-circle-side { display: flex; flex-direction: column; gap: 16px; }
|
|
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
|
.workspace-header p { font-size: 13px; color: #64748b; }
|
|
.filter-form { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; }
|
|
.post-list { display: flex; flex-direction: column; gap: 16px; }
|
|
.post-card { overflow: hidden; }
|
|
.post-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
.post-author { display: flex; align-items: center; gap: 12px; }
|
|
.post-author-name { font-size: 16px; font-weight: 700; color: #0f172a; }
|
|
.post-meta { font-size: 12px; color: #64748b; margin-top: 3px; }
|
|
.post-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
.post-content { color: #0f172a; line-height: 1.8; margin-bottom: 14px; white-space: pre-wrap; word-break: break-word; }
|
|
.post-location {
|
|
display: inline-flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 8px 12px;
|
|
border-radius: 999px; background: rgba(14,165,233,0.08); color: #0369a1; font-size: 13px; font-weight: 600;
|
|
border: 1px solid rgba(14,165,233,0.12);
|
|
}
|
|
.post-media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
|
.post-media-item img, .upload-preview-item img {
|
|
width: 100%; height: 108px; object-fit: cover; border-radius: 16px; cursor: pointer;
|
|
border: 1px solid rgba(148,163,184,0.14); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
|
}
|
|
.post-media-item { position: relative; }
|
|
.post-media-item.is-video::after {
|
|
content: ''; position: absolute; inset: 0; border-radius: 16px;
|
|
background: linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0.3));
|
|
pointer-events: none;
|
|
}
|
|
.video-badge {
|
|
position: absolute; left: 10px; bottom: 10px; display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 6px 10px; border-radius: 999px; background: rgba(15,23,42,0.72); color: #fff;
|
|
font-size: 12px; font-weight: 600; z-index: 2;
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.18);
|
|
}
|
|
.post-feedback { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.feedback-block {
|
|
padding: 14px; border-radius: 18px; background: rgba(248,250,252,0.88);
|
|
border: 1px solid rgba(148,163,184,0.12);
|
|
}
|
|
.feedback-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
|
|
.feedback-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.feedback-chip {
|
|
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; font-size: 12px;
|
|
color: #334155; background: rgba(255,255,255,0.95); border: 1px solid rgba(148,163,184,0.12);
|
|
}
|
|
.feedback-empty { color: #94a3b8; font-size: 12px; }
|
|
.comment-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.comment-item { font-size: 13px; color: #334155; line-height: 1.6; }
|
|
.comment-author { font-weight: 700; color: #0f172a; margin-right: 8px; }
|
|
.danger-text { color: #ef4444 !important; }
|
|
.upload-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
.upload-trigger {
|
|
display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px;
|
|
border-radius: 12px; background: rgba(255,255,255,0.92); border: 1px solid rgba(148,163,184,0.2);
|
|
color: #0f172a; cursor: pointer; font-size: 13px; font-weight: 600;
|
|
}
|
|
.upload-trigger input { display: none; }
|
|
.upload-preview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 12px; }
|
|
.raw-json {
|
|
margin: 0; max-height: 520px; overflow: auto; white-space: pre-wrap; word-break: break-all;
|
|
padding: 14px; border-radius: 16px; background: rgba(248,250,252,0.9); color: #334155;
|
|
border: 1px solid rgba(148,163,184,0.12);
|
|
}
|
|
.large-preview { width: 100%; border-radius: 18px; }
|
|
.large-video { width: 100%; max-height: 75vh; border-radius: 18px; background: #000; }
|
|
@media (max-width: 1100px) {
|
|
.friend-circle-grid { grid-template-columns: 1fr; }
|
|
.post-feedback { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|