Files
abot/admin/dashboard/templates/friend_circle.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 %}