Skip to content

Commit 5dd9416

Browse files
committed
feat: add federation management features
- Introduced a new Federation Management section in the admin panel with an overview, user management, proxy user management, post synchronization management, and queue management. - Added functionality to view and manage federated users, including their followers and historical post backfill. - Implemented post synchronization features, allowing for manual resync and pushing to ActivityPub. - Created a service layer for federation-related API calls to streamline data fetching and manipulation. - Enhanced UI components for better user experience in managing federation-related tasks.
1 parent c2cfdfc commit 5dd9416

File tree

10 files changed

+1176
-15
lines changed

10 files changed

+1176
-15
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ stats.html
3131

3232
scratch-gui
3333

34-
.claude
34+
.claude
35+
.env*.local

src/components/posts/PostCard.vue

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,22 @@
127127
</template>
128128
<v-list-item-title>删除</v-list-item-title>
129129
</v-list-item>
130-
<!-- 手动触发社交同步 -->
131-
<v-list-item :disabled="actionLoading" @click="manualSyncPost">
132-
<template #prepend>
133-
<v-icon size="18">mdi-share-all-outline</v-icon>
134-
</template>
135-
<v-list-item-title>同步到社交平台</v-list-item-title>
136-
</v-list-item>
137130
<!-- 复制链接 -->
138131
<v-list-item @click="copyLink">
139132
<template #prepend>
140133
<v-icon size="18">mdi-link-variant</v-icon>
141134
</template>
142135
<v-list-item-title>复制链接</v-list-item-title>
143136
</v-list-item>
137+
<!-- 联邦社交数据 -->
138+
<v-list-item
139+
@click="federationDialog = true"
140+
>
141+
<template #prepend>
142+
<v-icon size="18">mdi-access-point-network</v-icon>
143+
</template>
144+
<v-list-item-title>联邦社交数据</v-list-item-title>
145+
</v-list-item>
144146
</v-list>
145147
</v-menu>
146148
</div>
@@ -435,14 +437,91 @@
435437
</v-carousel>
436438
</div>
437439
</v-dialog>
440+
441+
<!-- Federation Data Dialog -->
442+
<v-dialog v-model="federationDialog" max-width="500px">
443+
<v-card class="post-dialog-card">
444+
<div class="post-dialog-header">
445+
<span class="text-h6">联邦社交数据</span>
446+
<v-spacer></v-spacer>
447+
<v-btn icon="mdi-close" variant="text" size="small" @click="federationDialog = false"></v-btn>
448+
</div>
449+
<v-card-text class="pa-4">
450+
<v-list density="compact" v-if="post.platform_refs">
451+
<v-list-item v-if="post.platform_refs.twitter">
452+
<template v-slot:prepend><v-icon color="blue">mdi-twitter</v-icon></template>
453+
<v-list-item-title>Twitter</v-list-item-title>
454+
<v-list-item-subtitle>
455+
<a v-if="post.platform_refs.twitter.url" :href="post.platform_refs.twitter.url" target="_blank">
456+
{{ post.platform_refs.twitter.id || 'Link' }}
457+
</a>
458+
<span v-else>{{ post.platform_refs.twitter.id }}</span>
459+
<span v-if="post.platform_refs.twitter.kind" class="ml-2 text-caption">({{ post.platform_refs.twitter.kind }})</span>
460+
</v-list-item-subtitle>
461+
</v-list-item>
462+
463+
<v-list-item v-if="post.platform_refs.bluesky">
464+
<template v-slot:prepend><v-icon color="light-blue">mdi-weather-cloudy</v-icon></template>
465+
<v-list-item-title>Bluesky</v-list-item-title>
466+
<v-list-item-subtitle>
467+
<a v-if="getBskyUrl(post.platform_refs.bluesky)" :href="getBskyUrl(post.platform_refs.bluesky)" target="_blank" rel="noopener">
468+
在 Bluesky 上查看
469+
</a>
470+
<div v-if="post.platform_refs.bluesky.uri" class="text-caption text-truncate" style="max-width: 300px;">URI: {{ post.platform_refs.bluesky.uri }}</div>
471+
<div v-if="post.platform_refs.bluesky.cid" class="text-caption text-truncate" style="max-width: 300px;">CID: {{ post.platform_refs.bluesky.cid }}</div>
472+
</v-list-item-subtitle>
473+
</v-list-item>
474+
475+
<v-list-item v-if="post.platform_refs.activitypub">
476+
<template v-slot:prepend><v-icon color="purple">mdi-earth</v-icon></template>
477+
<v-list-item-title>ActivityPub</v-list-item-title>
478+
<v-list-item-subtitle>
479+
<a v-if="post.platform_refs.activitypub.url" :href="post.platform_refs.activitypub.url" target="_blank">
480+
{{ post.platform_refs.activitypub.id || 'View Note' }}
481+
</a>
482+
<span v-else>{{ post.platform_refs.activitypub.id }}</span>
483+
</v-list-item-subtitle>
484+
</v-list-item>
485+
</v-list>
486+
<div v-else class="text-center pa-4 text-medium-emphasis">
487+
无同步数据
488+
</div>
489+
</v-card-text>
490+
491+
<v-divider></v-divider>
492+
493+
<v-card-actions v-if="canDelete">
494+
<v-btn
495+
variant="text"
496+
color="primary"
497+
:loading="actionLoading"
498+
prepend-icon="mdi-share-all-outline"
499+
@click="manualSyncPost"
500+
>
501+
全平台同步
502+
</v-btn>
503+
<v-spacer></v-spacer>
504+
<v-btn
505+
variant="text"
506+
color="secondary"
507+
:loading="actionLoading"
508+
prepend-icon="mdi-earth"
509+
@click="pushToFederation"
510+
>
511+
推送到联邦
512+
</v-btn>
513+
</v-card-actions>
514+
</v-card>
515+
</v-dialog>
438516
</template>
439517

440518
<script setup>
441519
import { computed, ref, watch, nextTick } from "vue";
442-
import { useRouter } from "vue-router";
520+
import { useRouter, useRoute } from "vue-router";
443521
import { localuser } from "@/services/localAccount";
444522
import { getS3staticurl } from "@/services/projectService";
445523
import PostsService from "@/services/postsService";
524+
import federationService from "@/services/federationService";
446525
import { showSnackbar } from "@/composables/useNotifications";
447526
import { useDeleteConfirm } from "@/composables/useDeleteConfirm";
448527
import axios from "@/axios/axios";
@@ -471,10 +550,12 @@ const props = defineProps({
471550
const emit = defineEmits(["deleted", "created", "updated"]);
472551
473552
const router = useRouter();
553+
const route = useRoute();
474554
475555
// Dialog states
476556
const replyDialog = ref(false);
477557
const quoteDialog = ref(false);
558+
const federationDialog = ref(false);
478559
const actionLoading = ref(false);
479560
480561
const mediaViewerOpen = ref(false);
@@ -519,6 +600,11 @@ const authorAvatar = computed(() => {
519600
return localuser.getUserAvatar(avatar);
520601
});
521602
603+
// Check if current view is detail view for this post
604+
const isDetailView = computed(() => {
605+
return String(route.params.id) === String(postId.value);
606+
});
607+
522608
// Time
523609
const createdAt = computed(
524610
() =>
@@ -1112,6 +1198,7 @@ const copyLink = async () => {
11121198
};
11131199
11141200
const handleDeleteClick = () => {
1201+
// Trigger rebuild
11151202
showDeleteConfirm(
11161203
async () => {
11171204
await PostsService.remove(postId.value);
@@ -1128,18 +1215,52 @@ const handleDeleteClick = () => {
11281215
);
11291216
};
11301217
1218+
const getBskyUrl = (bluesky) => {
1219+
if (!bluesky) return null;
1220+
if (bluesky.url) return bluesky.url;
1221+
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
1222+
const uri = bluesky.uri;
1223+
if (!uri) return null;
1224+
const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/);
1225+
if (match) {
1226+
return `https://bsky.app/profile/${match[1]}/post/${match[2]}`;
1227+
}
1228+
return null;
1229+
};
1230+
11311231
const manualSyncPost = async () => {
1132-
if (!requireLogin("手动同步")) return;
1232+
if (!canDelete.value) return;
1233+
if (!confirm("确定要重新同步此帖子到所有关联的社交平台吗?")) return;
1234+
1235+
actionLoading.value = true;
1236+
try {
1237+
const res = await federationService.userResyncPost(postId.value);
1238+
if (res.data?.status === "success" || res.data?.status === "ok") {
1239+
showSnackbar("同步任务已提交", "success");
1240+
} else {
1241+
showSnackbar(res.data?.message || "同步失败", "error");
1242+
}
1243+
} catch (e) {
1244+
showSnackbar(e?.message || "同步失败", "error");
1245+
} finally {
1246+
actionLoading.value = false;
1247+
}
1248+
};
1249+
1250+
const pushToFederation = async () => {
1251+
if (!canDelete.value) return;
1252+
if (!confirm("确定要推送到 ActivityPub 联邦网络吗?")) return;
1253+
11331254
actionLoading.value = true;
11341255
try {
1135-
const result = await PostsService.syncToSocial(postId.value);
1136-
if (result?.status === "success") {
1137-
showSnackbar("已提交同步任务", "success");
1256+
const res = await federationService.userPushPostToFederation(postId.value);
1257+
if (res.data?.status === "success" || res.data?.status === "ok") {
1258+
showSnackbar("推送任务已提交", "success");
11381259
} else {
1139-
showSnackbar(result?.message || "提交同步任务失败", "error");
1260+
showSnackbar(res.data?.message || "推送失败", "error");
11401261
}
11411262
} catch (e) {
1142-
showSnackbar(e?.message || "提交同步任务失败", "error");
1263+
showSnackbar(e?.message || "推送失败", "error");
11431264
} finally {
11441265
actionLoading.value = false;
11451266
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<template>
2+
<v-container>
3+
<v-card class="mb-6">
4+
<v-card-title>联邦系统概览</v-card-title>
5+
<v-card-text>
6+
<v-row>
7+
<v-col cols="12" md="6">
8+
<v-alert
9+
v-if="stats.federation"
10+
:type="stats.federation.enabled ? 'success' : 'warning'"
11+
variant="tonal"
12+
border="start"
13+
>
14+
<div class="text-h6">ActivityPub 状态</div>
15+
<div>域名: {{ stats.federation.domain }}</div>
16+
<div>状态: {{ stats.federation.enabled ? '已启用' : '未启用' }}</div>
17+
</v-alert>
18+
</v-col>
19+
<v-col cols="12" md="6" v-if="stats.queue">
20+
<v-card variant="outlined">
21+
<v-card-title>队列状态</v-card-title>
22+
<v-card-text>
23+
<div class="d-flex justify-space-between">
24+
<span>等待中: {{ stats.queue.waiting }}</span>
25+
<span>进行中: {{ stats.queue.active }}</span>
26+
<span>已完成: {{ stats.queue.completed }}</span>
27+
<span>失败: {{ stats.queue.failed }}</span>
28+
</div>
29+
</v-card-text>
30+
<v-card-actions>
31+
<v-btn color="primary" to="/app/admin/federation/queue">管理队列</v-btn>
32+
</v-card-actions>
33+
</v-card>
34+
</v-col>
35+
</v-row>
36+
</v-card-text>
37+
</v-card>
38+
39+
<v-row>
40+
<v-col cols="12" sm="6" md="3" v-for="(count, key) in displayCounts" :key="key">
41+
<v-card>
42+
<v-card-text class="d-flex flex-column align-center">
43+
<div class="text-h4 mb-2">{{ count.value }}</div>
44+
<div class="text-subtitle-1">{{ count.label }}</div>
45+
</v-card-text>
46+
</v-card>
47+
</v-col>
48+
</v-row>
49+
50+
<v-row class="mt-4" v-if="stats.platformSync">
51+
<v-col cols="12">
52+
<v-card>
53+
<v-card-title>平台同步统计</v-card-title>
54+
<v-card-text>
55+
<v-row>
56+
<v-col cols="12" md="4" v-for="(count, platform) in stats.platformSync" :key="platform">
57+
<v-list-item>
58+
<template v-slot:prepend>
59+
<v-icon :icon="getPlatformIcon(platform)"></v-icon>
60+
</template>
61+
<v-list-item-title class="text-capitalize">{{ platform }}</v-list-item-title>
62+
<template v-slot:append>
63+
<v-chip>{{ count }}</v-chip>
64+
</template>
65+
</v-list-item>
66+
</v-col>
67+
</v-row>
68+
</v-card-text>
69+
</v-card>
70+
</v-col>
71+
</v-row>
72+
73+
<v-row class="mt-4">
74+
<v-col cols="12">
75+
<div class="d-flex gap-2 flex-wrap">
76+
<v-btn prepend-icon="mdi-account-group" to="/app/admin/federation/users" color="primary" variant="tonal">本地联邦用户</v-btn>
77+
<v-btn prepend-icon="mdi-account-network" to="/app/admin/federation/proxy-users" color="secondary" variant="tonal">远程代理用户</v-btn>
78+
<v-btn prepend-icon="mdi-sync" to="/app/admin/federation/posts" color="info" variant="tonal">帖子同步管理</v-btn>
79+
</div>
80+
</v-col>
81+
</v-row>
82+
</v-container>
83+
</template>
84+
85+
<script setup>
86+
import { ref, onMounted, computed } from 'vue';
87+
import federationService from '@/services/federationService';
88+
89+
const stats = ref({
90+
federation: null,
91+
counts: {},
92+
platformSync: null,
93+
queue: null
94+
});
95+
96+
const displayCounts = computed(() => {
97+
const c = stats.value.counts || {};
98+
return [
99+
{ key: 'remoteFollowers', label: '远程关注者', value: c.remoteFollowers || 0 },
100+
{ key: 'proxyUsers', label: '远程代理用户', value: c.proxyUsers || 0 },
101+
{ key: 'federatedPosts', label: '联邦帖子', value: c.federatedPosts || 0 },
102+
{ key: 'localPosts', label: '本地帖子', value: c.localPosts || 0 },
103+
];
104+
});
105+
106+
const getPlatformIcon = (platform) => {
107+
switch(platform) {
108+
case 'twitter': return 'mdi-twitter';
109+
case 'bluesky': return 'mdi-butterfly'; // Assuming mdi-butterfly or similar, or just cloud
110+
case 'activitypub': return 'mdi-earth';
111+
default: return 'mdi-share-variant';
112+
}
113+
};
114+
115+
const loadStats = async () => {
116+
try {
117+
const res = await federationService.getStats();
118+
if (res.status === 200 && res.data.status === 'ok') {
119+
stats.value = res.data.data;
120+
}
121+
} catch (e) {
122+
console.error('Failed to load federation stats', e);
123+
}
124+
};
125+
126+
onMounted(() => {
127+
loadStats();
128+
});
129+
</script>

0 commit comments

Comments
 (0)