feat: add dashboard friend circle management

This commit is contained in:
liuwei
2026-04-07 12:50:50 +08:00
parent d507cdf88d
commit e8ed0d4799
7 changed files with 1073 additions and 63 deletions

View File

@@ -0,0 +1,390 @@
import asyncio
import xml.etree.ElementTree as ET
from datetime import datetime
from flask import Blueprint, current_app, jsonify, render_template, request
from loguru import logger
from .auth import login_required
friend_circle_bp = Blueprint("friend_circle", __name__)
def _run_client_coro(server, coro, timeout=90):
future = asyncio.run_coroutine_threadsafe(coro, server.robot.ipad_loop)
return future.result(timeout=timeout)
def _safe_text(value, default=""):
if value is None:
return default
if isinstance(value, dict):
if "string" in value:
return _safe_text(value.get("string"), default)
if "text" in value:
return _safe_text(value.get("text"), default)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
return default
def _walk_nodes(payload):
if isinstance(payload, dict):
yield payload
for value in payload.values():
yield from _walk_nodes(value)
elif isinstance(payload, list):
for item in payload:
yield from _walk_nodes(item)
def _extract_list(values):
if isinstance(values, list):
return values
if isinstance(values, dict):
for key in ("list", "List", "items", "Items"):
if isinstance(values.get(key), list):
return values.get(key)
return []
def _parse_media_from_xml(root):
media_items = []
for media in root.findall(".//mediaList/media"):
media_items.append({
"url": _safe_text(media.findtext("url")),
"thumb": _safe_text(media.findtext("thumb")),
"id": _safe_text(media.findtext("id"))
})
return [item for item in media_items if item.get("url") or item.get("thumb")]
def _parse_timeline_xml(xml_text):
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return {}
create_time = _safe_text(root.findtext("createTime"))
timestamp = ""
if create_time.isdigit():
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
return {
"id": _safe_text(root.findtext("id")),
"author_wxid": _safe_text(root.findtext("username")),
"content": _safe_text(root.findtext("contentDesc")),
"timestamp": timestamp,
"media": _parse_media_from_xml(root)
}
def _normalize_comment(comment):
comment_id = (
_safe_text(comment.get("CommnetId"))
or _safe_text(comment.get("CommentId"))
or _safe_text(comment.get("id"))
)
return {
"comment_id": comment_id,
"author_wxid": (
_safe_text(comment.get("UserName"))
or _safe_text(comment.get("username"))
or _safe_text(comment.get("Wxid"))
),
"author_name": (
_safe_text(comment.get("NickName"))
or _safe_text(comment.get("nickname"))
or _safe_text(comment.get("DisplayName"))
),
"content": (
_safe_text(comment.get("Content"))
or _safe_text(comment.get("content"))
or _safe_text(comment.get("Text"))
)
}
def _normalize_like(like):
return {
"author_wxid": (
_safe_text(like.get("UserName"))
or _safe_text(like.get("username"))
or _safe_text(like.get("Wxid"))
),
"author_name": (
_safe_text(like.get("NickName"))
or _safe_text(like.get("nickname"))
or _safe_text(like.get("DisplayName"))
)
}
def _normalize_post(node, server):
xml_payload = ""
for key in ("Content", "Xml", "ObjectDesc", "TimelineObject"):
value = node.get(key)
if isinstance(value, str) and "<TimelineObject" in value:
xml_payload = value
break
xml_data = _parse_timeline_xml(xml_payload) if xml_payload else {}
object_id = (
_safe_text(node.get("Id"))
or _safe_text(node.get("id"))
or xml_data.get("id", "")
)
author_wxid = (
_safe_text(node.get("UserName"))
or _safe_text(node.get("Username"))
or _safe_text(node.get("Towxid"))
or xml_data.get("author_wxid", "")
)
author_name = (
_safe_text(node.get("NickName"))
or _safe_text(node.get("nickname"))
or server.contact_manager.get_nickname(author_wxid)
)
content = (
_safe_text(node.get("ContentDesc"))
or _safe_text(node.get("contentDesc"))
or _safe_text(node.get("Content"))
)
if "<TimelineObject" in content:
content = ""
content = content or xml_data.get("content", "")
create_time = (
_safe_text(node.get("CreateTime"))
or _safe_text(node.get("createTime"))
)
timestamp = xml_data.get("timestamp", "")
if not timestamp and create_time.isdigit():
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
media = []
for list_key in ("MediaList", "mediaList", "Medias", "medias"):
media_list = _extract_list(node.get(list_key))
for item in media_list:
media.append({
"url": _safe_text(item.get("Url")) or _safe_text(item.get("url")),
"thumb": _safe_text(item.get("ThumbUrl")) or _safe_text(item.get("thumb")),
"id": _safe_text(item.get("Id")) or _safe_text(item.get("id"))
})
if not media:
media = xml_data.get("media", [])
comment_nodes = []
like_nodes = []
for key in ("CommentList", "Comments", "commentList", "comments"):
comment_nodes.extend(_extract_list(node.get(key)))
for key in ("LikeList", "Likes", "likeList", "likes"):
like_nodes.extend(_extract_list(node.get(key)))
return {
"id": object_id,
"author_wxid": author_wxid,
"author_name": author_name,
"author_avatar": server.contact_manager.get_head_image(author_wxid),
"content": content,
"timestamp": timestamp,
"media": [item for item in media if item.get("url") or item.get("thumb")],
"comments": [item for item in (_normalize_comment(x) for x in comment_nodes) if item.get("content") or item.get("author_wxid")],
"likes": [item for item in (_normalize_like(x) for x in like_nodes) if item.get("author_wxid")],
"raw": node
}
def _extract_posts(payload, server):
posts = []
seen_ids = set()
for node in _walk_nodes(payload):
if not isinstance(node, dict):
continue
looks_like_post = any(key in node for key in ("Id", "id", "ContentDesc", "CommentList", "LikeList", "ObjectDesc"))
has_timeline_xml = any(isinstance(value, str) and "<TimelineObject" in value for value in node.values())
if not looks_like_post and not has_timeline_xml:
continue
post = _normalize_post(node, server)
if not post.get("id"):
continue
if post["id"] in seen_ids:
continue
seen_ids.add(post["id"])
posts.append(post)
return posts
@friend_circle_bp.route("/friend_circle")
@login_required
def friend_circle_page():
return render_template("friend_circle.html")
@friend_circle_bp.route("/api/friend_circle/contacts")
@login_required
def friend_circle_contacts():
server = current_app.dashboard_server
keyword = request.args.get("keyword", "").strip().lower()
result = []
for wxid, nickname in server.contact_manager.get_personal_contacts().items():
if keyword and keyword not in wxid.lower() and keyword not in str(nickname).lower():
continue
result.append({
"wxid": wxid,
"nickname": nickname,
"avatar": server.contact_manager.get_head_image(wxid)
})
if len(result) >= 50:
break
return jsonify({"success": True, "data": result})
@friend_circle_bp.route("/api/friend_circle/list")
@login_required
def friend_circle_list():
server = current_app.dashboard_server
towxid = request.args.get("towxid", "").strip()
max_id = int(request.args.get("max_id", 0))
first_page_md5 = request.args.get("first_page_md5", "").strip()
try:
if towxid:
payload = _run_client_coro(
server,
server.client.get_friend_circle_detail(towxid=towxid, max_id=max_id, first_page_md5=first_page_md5)
)
else:
payload = _run_client_coro(
server,
server.client.get_friend_circle_list(max_id=max_id, first_page_md5=first_page_md5)
)
posts = _extract_posts(payload, server)
return jsonify({"success": True, "data": posts, "raw": payload})
except Exception as exc:
logger.error(f"获取朋友圈列表失败: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@friend_circle_bp.route("/api/friend_circle/detail")
@login_required
def friend_circle_detail():
server = current_app.dashboard_server
object_id = request.args.get("id", "").strip()
towxid = request.args.get("towxid", "").strip()
try:
payload = _run_client_coro(
server,
server.client.get_friend_circle_id_detail(object_id=object_id, towxid=towxid)
)
posts = _extract_posts(payload, server)
detail = posts[0] if posts else None
return jsonify({"success": True, "data": detail, "raw": payload})
except Exception as exc:
logger.error(f"获取朋友圈详情失败: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@friend_circle_bp.route("/api/friend_circle/publish", methods=["POST"])
@login_required
def friend_circle_publish():
server = current_app.dashboard_server
data = request.get_json(silent=True) or {}
content = (data.get("content") or "").strip()
blacklist = (data.get("blacklist") or "").strip()
with_user_list = (data.get("with_user_list") or "").strip()
images = data.get("images") or []
if not content and not images:
return jsonify({"success": False, "message": "内容和图片不能同时为空"}), 400
try:
media_items = []
for image in images:
upload_data = _run_client_coro(
server,
server.client.upload_friend_circle_media(image)
)
urls = _extract_list(upload_data.get("Urls"))
thumbs = _extract_list(upload_data.get("ThumbUrls"))
media_items.append({
"id": _safe_text(upload_data.get("Id")) or _safe_text(upload_data.get("MediaId")),
"url": _safe_text(urls[0].get("Url")) if urls else "",
"thumb": _safe_text(thumbs[0].get("Url")) if thumbs else (_safe_text(urls[0].get("Url")) if urls else ""),
"md5": _safe_text(upload_data.get("Md5")),
"total_size": upload_data.get("TotalSize", 0),
"width": upload_data.get("Width", 0),
"height": upload_data.get("Height", 0)
})
payload = _run_client_coro(
server,
server.client.publish_friend_circle(
content=content,
media_items=media_items,
blacklist=blacklist,
with_user_list=with_user_list
)
)
return jsonify({"success": True, "data": payload})
except Exception as exc:
logger.error(f"发布朋友圈失败: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@friend_circle_bp.route("/api/friend_circle/like", methods=["POST"])
@login_required
def friend_circle_like():
server = current_app.dashboard_server
data = request.get_json(silent=True) or {}
object_id = str(data.get("id") or "").strip()
cancel_like = bool(data.get("cancel"))
if not object_id:
return jsonify({"success": False, "message": "缺少朋友圈ID"}), 400
try:
if cancel_like:
payload = _run_client_coro(
server,
server.client.friend_circle_operation(object_id=object_id, type=5)
)
else:
payload = _run_client_coro(
server,
server.client.friend_circle_comment(object_id=object_id, type=1)
)
return jsonify({"success": True, "data": payload})
except Exception as exc:
logger.error(f"朋友圈点赞操作失败: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@friend_circle_bp.route("/api/friend_circle/comment", methods=["POST"])
@login_required
def friend_circle_comment():
server = current_app.dashboard_server
data = request.get_json(silent=True) or {}
object_id = str(data.get("id") or "").strip()
content = (data.get("content") or "").strip()
reply_comment_id = int(data.get("reply_comment_id") or 0)
if not object_id or not content:
return jsonify({"success": False, "message": "评论目标和内容不能为空"}), 400
try:
payload = _run_client_coro(
server,
server.client.friend_circle_comment(
object_id=object_id,
content=content,
type=2,
reply_comment_id=reply_comment_id
)
)
return jsonify({"success": True, "data": payload})
except Exception as exc:
logger.error(f"朋友圈评论失败: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500

View File

@@ -147,6 +147,7 @@ class DashboardServer:
from admin.dashboard.blueprints.virtual_group import virtual_group_bp
from admin.dashboard.blueprints.file_browser import file_browser_bp
from admin.dashboard.blueprints.message_push import message_push_bp
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
# 在app.register_blueprint部分添加
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
@@ -160,6 +161,7 @@ class DashboardServer:
app.register_blueprint(plugin_routes)
app.register_blueprint(file_browser_bp)
app.register_blueprint(message_push_bp)
app.register_blueprint(friend_circle_bp)
self.LOG.info("所有蓝图已注册")

View File

@@ -666,6 +666,16 @@
{ label: '错误日志', path: '/errors' }
]
},
{
key: 'friend-circle',
label: '朋友圈',
icon: 'el-icon-picture-outline-round',
description: '集中管理朋友圈查看、发布、点赞与评论流程',
defaultPath: '/friend_circle',
items: [
{ label: '朋友圈管理', path: '/friend_circle' }
]
},
{
key: 'groups',
label: '群组',

View File

@@ -0,0 +1,456 @@
{% 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">{{ (post.author_name || '?').slice(0, 1) }}</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 %}

View File

@@ -1,4 +1,5 @@
from wechat_ipad import UserLoggedOut
from wechat_ipad.client.friend_circle import FriendCircleMixin
from wechat_ipad.client.firends import FriendMixin
from wechat_ipad.client.group import ChatroomMixin
from wechat_ipad.client.login import LoginMixin
@@ -7,7 +8,7 @@ from wechat_ipad.client.tools import ToolMixin
from wechat_ipad.client.user import UserMixin
class WechatAPIClient(LoginMixin, MessageMixin, FriendMixin, ChatroomMixin, UserMixin,
class WechatAPIClient(LoginMixin, MessageMixin, FriendCircleMixin, FriendMixin, ChatroomMixin, UserMixin,
ToolMixin):
# 这里都是需要结合多个功能的方法

View File

@@ -0,0 +1,145 @@
import base64
import os
from typing import Union
import aiofiles
import aiohttp
from wechat_ipad import UserLoggedOut
from wechat_ipad.client.base import WechatAPIClientBase
from wechat_ipad.models.friend_circle_info import build_friend_circle_xml
class FriendCircleMixin(WechatAPIClientBase):
async def get_friend_circle_list(self, max_id: int = 0, first_page_md5: str = "") -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "Maxid": max_id, "Fristpagemd5": first_page_md5}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetList", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def get_friend_circle_detail(self, towxid: str, max_id: int = 0, first_page_md5: str = "") -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {
"Wxid": self.wxid,
"Towxid": towxid,
"Maxid": max_id,
"Fristpagemd5": first_page_md5
}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetDetail", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def get_friend_circle_id_detail(self, object_id: Union[str, int], towxid: str = "") -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "Towxid": towxid, "Id": int(object_id)}
response = await session.post(
f"http://{self.ip}:{self.port}/api/FriendCircle/GetIdDetail",
json=json_param
)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def publish_friend_circle(self, content: str, media_items: list[dict] | None = None,
blacklist: str = "", with_user_list: str = "") -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
xml_content = build_friend_circle_xml(self.wxid, content, media_items=media_items)
async with aiohttp.ClientSession() as session:
json_param = {
"Wxid": self.wxid,
"Content": xml_content,
"BlackList": blacklist,
"WithUserList": with_user_list
}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Messages", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def friend_circle_comment(self, object_id: str, content: str = "", type: int = 2,
reply_comment_id: int = 0) -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {
"Wxid": self.wxid,
"Id": str(object_id),
"Type": int(type),
"Content": content,
"ReplyCommnetId": int(reply_comment_id)
}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Comment", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def friend_circle_operation(self, object_id: str, type: int, comment_id: int = 0) -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {
"Wxid": self.wxid,
"Id": str(object_id),
"Type": int(type),
"CommnetId": int(comment_id)
}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Operation", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def sync_friend_circle(self, sync_key: str = "") -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "Synckey": sync_key}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/MmSnsSync", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)
async def upload_friend_circle_media(self, media: Union[str, bytes, os.PathLike]) -> dict:
if not self.wxid:
raise UserLoggedOut("请先登录")
if isinstance(media, str):
media_base64 = media.split(",", 1)[1] if "," in media else media
elif isinstance(media, bytes):
media_base64 = base64.b64encode(media).decode()
elif isinstance(media, os.PathLike):
async with aiofiles.open(media, "rb") as f:
media_base64 = base64.b64encode(await f.read()).decode()
else:
raise ValueError("media should be str, bytes, or path")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "Base64": media_base64}
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Upload", json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data", {})
self.error_handler(json_resp)

View File

@@ -1,25 +1,65 @@
FRIEND_CIRCLE_INFO = """
import random
import time
from typing import Iterable
def _safe_float(value) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def generate_timeline_id() -> str:
return f"{int(time.time() * 1000)}{random.randint(1000, 9999)}"
def build_media_xml(media_items: Iterable[dict]) -> str:
parts = []
for index, item in enumerate(media_items):
url = item.get("url", "")
thumb = item.get("thumb", url)
media_id = item.get("id") or f"{int(time.time() * 1000)}{index}"
md5 = item.get("md5", "")
total_size = item.get("total_size", 0)
width = item.get("width", 0)
height = item.get("height", 0)
parts.append(
f"""
<media>
<id><![CDATA[{media_id}]]></id>
<type><![CDATA[2]]></type>
<title></title>
<description></description>
<private><![CDATA[0]]></private>
<url type="1" md5="{md5}"><![CDATA[{url}]]></url>
<thumb type="1"><![CDATA[{thumb}]]></thumb>
<videoDuration><![CDATA[0.0]]></videoDuration>
<size totalSize="{_safe_float(total_size):.1f}" width="{_safe_float(width):.1f}" height="{_safe_float(height):.1f}"></size>
</media>
""".strip()
)
return "\n".join(parts)
def build_friend_circle_xml(wxid: str, content: str, media_items: list[dict] | None = None,
timeline_id: str | None = None, create_time: int | None = None) -> str:
media_items = media_items or []
timeline_id = timeline_id or generate_timeline_id()
create_time = create_time or int(time.time())
content_style = 1 if media_items else 2
media_xml = build_media_xml(media_items)
return f"""
<TimelineObject>
<id>
<![CDATA[{id}]]>
</id>
<username>
<![CDATA[{wxid}]]>
</username>
<createTime>
<![CDATA[{time}]]>
</createTime>
<id><![CDATA[{timeline_id}]]></id>
<username><![CDATA[{wxid}]]></username>
<createTime><![CDATA[{create_time}]]></createTime>
<contentDescShowType>0</contentDescShowType>
<contentDescScene>0</contentDescScene>
<private>
<![CDATA[0]]>
</private>
<contentDesc>
<![CDATA[{content}]]>
</contentDesc>
<contentattr>
<![CDATA[0]]>
</contentattr>
<private><![CDATA[0]]></private>
<contentDesc><![CDATA[{content}]]></contentDesc>
<contentattr><![CDATA[0]]></contentattr>
<sourceUserName></sourceUserName>
<publicUserName></publicUserName>
<sourceNickName></sourceNickName>
@@ -27,58 +67,24 @@ FRIEND_CIRCLE_INFO = """
<weappInfo>
<appUserName></appUserName>
<pagePath></pagePath>
<version>
<![CDATA[0]]>
</version>
<version><![CDATA[0]]></version>
<isHidden>0</isHidden>
<debugMode>
<![CDATA[0]]>
</debugMode>
<debugMode><![CDATA[0]]></debugMode>
<shareActionId></shareActionId>
<isGame>
<![CDATA[0]]>
</isGame>
<isGame><![CDATA[0]]></isGame>
<messageExtraData></messageExtraData>
<subType>
<![CDATA[0]]>
</subType>
<subType><![CDATA[0]]></subType>
<preloadResources></preloadResources>
</weappInfo>
<canvasInfoXml></canvasInfoXml>
<ContentObject>
<contentStyle>
<![CDATA[1]]>
</contentStyle>
<contentSubStyle>
<![CDATA[0]]>
</contentSubStyle>
<contentStyle><![CDATA[{content_style}]]></contentStyle>
<contentSubStyle><![CDATA[0]]></contentSubStyle>
<title></title>
<description></description>
<contentUrl></contentUrl>
<mediaList>
<media>
<id>
<![CDATA[14672447414385119864]]>
</id>
<type>
<![CDATA[2]]>
</type>
<title></title>
<description></description>
<private>
<![CDATA[0]]>
</private>
<url type=\"1\" md5=\"c661215f338618b3282a6ea5174a0fb5\">
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/0]]>
</url>
<thumb type=\"1\">
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/150]]>
</thumb>
<videoDuration>
<![CDATA[0.0]]>
</videoDuration>
<size totalSize=\"47880.0\" width=\"1080.0\" height=\"1080.0\"></size>
</media>
{media_xml}
</mediaList>
</ContentObject>
<actionInfo>
@@ -91,11 +97,11 @@ FRIEND_CIRCLE_INFO = """
<appInfo>
<id></id>
</appInfo>
<location poiClassifyId=\"\" poiName=\"\" poiAddress=\"\" poiClassifyType=\"0\" city=\"\"></location>
<location poiClassifyId="" poiName="" poiAddress="" poiClassifyType="0" city=""></location>
<streamvideo>
<streamvideourl></streamvideourl>
<streamvideothumburl></streamvideothumburl>
<streamvideoweburl></streamvideoweburl>
</streamvideo>
</TimelineObject>
"""
""".strip()