Files
abot/admin/dashboard/templates/friend_circle.html
2026-04-07 12:55:34 +08:00

457 lines
22 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 type="text" @click="toggleLike(post, false)">点赞</el-button>
<el-button 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.media && post.media.length" class="post-media-grid">
<div v-for="(media, index) in post.media" :key="`${post.id}-${index}`" class="post-media-item">
<img :src="media.thumb || media.url" :alt="post.content || '朋友圈图片'" @click="previewImage(media)">
</div>
</div>
<div class="post-feedback">
<div class="feedback-block">
<div class="feedback-title">点赞 {% raw %}{{ post.likes.length }}{% 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="previewImageUrl" :src="previewImageUrl" class="large-preview" alt="朋友圈图片预览">
</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: '',
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;
},
previewImage(media) {
this.previewImageUrl = media.url || media.thumb || '';
this.imagePreviewVisible = true;
},
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-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-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; }
@media (max-width: 1100px) {
.friend-circle-grid { grid-template-columns: 1fr; }
.post-feedback { grid-template-columns: 1fr; }
}
</style>
{% endblock %}