feat: add account events and private message management
douyin-release-gate / verify (push) Failing after 18m54s
douyin-release-gate / verify (push) Failing after 18m54s
Add Douyin notification polling, event details, and manual multi-account private messaging. Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
export type CollectionCheckpoint = {
|
||||
status?: string;
|
||||
last_completed_at?: string | null;
|
||||
last_error?: string | null;
|
||||
};
|
||||
|
||||
const labels: Record<string, { text: string; color: string }> = {
|
||||
idle: { text: '待采集', color: 'default' },
|
||||
pending: { text: '待采集', color: 'default' },
|
||||
succeeded: { text: '采集完成', color: 'success' },
|
||||
running: { text: '采集运行中', color: 'processing' },
|
||||
failed: { text: '采集失败', color: 'error' },
|
||||
blocked: { text: '采集受阻', color: 'warning' },
|
||||
};
|
||||
|
||||
export function collectionStatusPresentation(checkpoint?: CollectionCheckpoint | null) {
|
||||
const reason = checkpoint?.last_error || '';
|
||||
if (reason.includes('browser is awaiting user login')) {
|
||||
return {
|
||||
text: '需要登录',
|
||||
color: 'warning',
|
||||
reason: '浏览器需要登录,请登录并同步账号后重试。',
|
||||
};
|
||||
}
|
||||
const state = checkpoint?.status ? labels[checkpoint.status] : undefined;
|
||||
return { ...(state || { text: '状态未知', color: 'default' }), reason };
|
||||
}
|
||||
|
||||
const accountStatusPriority = ['需要登录', '采集失败', '采集受阻', '状态未知', '采集运行中', '待采集', '采集完成'];
|
||||
|
||||
export function accountCollectionStatusPresentation(
|
||||
works?: CollectionCheckpoint | null,
|
||||
comments?: CollectionCheckpoint | null,
|
||||
) {
|
||||
const states = [works, comments].map(collectionStatusPresentation);
|
||||
const primary = states.reduce((current, candidate) =>
|
||||
accountStatusPriority.indexOf(current.text) <= accountStatusPriority.indexOf(candidate.text) ? current : candidate,
|
||||
);
|
||||
const reasons = [...new Set(states.map(state => state.reason).filter(Boolean))];
|
||||
return { ...primary, reason: reasons.join(';') };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const eventTypeLabels: Record<string, string> = {
|
||||
like: '点赞', comment: '评论', follow: '关注', repost: '转发',
|
||||
};
|
||||
|
||||
export function eventTypeLabel(type: string): string {
|
||||
return eventTypeLabels[type] ?? type;
|
||||
}
|
||||
|
||||
export function listenerStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
stopped: '已关闭', starting: '启动中', ready: '接收正常', gap: '可能有遗漏', stopping: '关闭中', error: '异常',
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
export function messageTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息', voice: '语音消息', video: '视频消息', sticker: '表情消息', non_text: '非文本消息', text: '—',
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
export function eventUserProfileURL(secUID?: string): string | undefined {
|
||||
return secUID && /^[A-Za-z0-9_-]{1,256}$/.test(secUID)
|
||||
? `https://www.douyin.com/user/${encodeURIComponent(secUID)}` : undefined;
|
||||
}
|
||||
|
||||
export function eventWorkURL(workID: string, provided?: string): string | undefined {
|
||||
if (!/^[1-9][0-9]{0,29}$/.test(workID)) return undefined;
|
||||
if (provided) return new RegExp(`^https://www\\.douyin\\.com/(video|note)/${workID}$`).test(provided) ? provided : undefined;
|
||||
return `https://www.douyin.com/video/${workID}`;
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
accountId?: string;
|
||||
eventType?: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export function eventQuery(filters: EventFilters, page: number, pageSize: number): string {
|
||||
const query = new URLSearchParams({ page: String(page), page_size: String(pageSize), hours: String(filters.hours) });
|
||||
if (filters.accountId) query.set('account_id', filters.accountId);
|
||||
if (filters.eventType) query.set('event_type', filters.eventType);
|
||||
return query.toString();
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
export function dateTime(value?: string | number | null): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const twoDigits = (part: number) => String(part).padStart(2, '0');
|
||||
return `${String(date.getFullYear()).slice(-2)}/${twoDigits(date.getMonth() + 1)}/${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
// 优先展示后端具体 reason;409/503 冲突类错误即使无 hint 也直接透出后端
|
||||
@@ -58,7 +60,7 @@ export function extractShareURL(value?: string): string {
|
||||
return candidates?.find((candidate) => platformForShareURL(candidate)) || candidates?.[0] || '';
|
||||
}
|
||||
|
||||
// 账号可恢复性判定(对齐归档版 accountReadiness):绑定、出口健康、账号暂停三层。
|
||||
// 分别判定采集调度、环境启动和账号恢复:未启动阻止调度,但不能阻止启动。
|
||||
export const readinessReasonText: Record<string, string> = {
|
||||
account_paused: '账号已暂停',
|
||||
binding_missing: '未绑定运行环境',
|
||||
@@ -75,28 +77,30 @@ export interface AccountReadiness {
|
||||
label: string;
|
||||
reason: string;
|
||||
canResume: boolean;
|
||||
canStart: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export function accountReadiness(account: any, binding: any, bindingError: any = null): AccountReadiness {
|
||||
if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false };
|
||||
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false };
|
||||
if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, canStart: false, ready: false };
|
||||
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, canStart: false, ready: false };
|
||||
let blocked = '';
|
||||
if (binding.runtime_cleanup_pending) {
|
||||
blocked = 'runtime_stop_pending';
|
||||
} else if (binding.network_exit_id && binding.network_exit_health !== 'healthy') {
|
||||
blocked = 'network_exit_unhealthy';
|
||||
}
|
||||
if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, ready: false };
|
||||
if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, canStart: false, ready: false };
|
||||
if (account.runtime_status === 'paused') {
|
||||
const stopped = !binding.runtime_id;
|
||||
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false };
|
||||
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, canStart: false, ready: false };
|
||||
}
|
||||
const schedule = binding.schedule_block_reason || '';
|
||||
return {
|
||||
label: schedule ? readinessReasonText[schedule] || `不可调度:${schedule}` : '可调度',
|
||||
reason: schedule,
|
||||
canResume: false,
|
||||
canStart: !schedule || schedule === 'runtime_missing',
|
||||
ready: !schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
EyeOutlined,
|
||||
LineChartOutlined,
|
||||
CommentOutlined,
|
||||
BellOutlined,
|
||||
MessageOutlined,
|
||||
SettingOutlined,
|
||||
ProfileOutlined,
|
||||
ClusterOutlined,
|
||||
@@ -23,6 +25,8 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
|
||||
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
|
||||
{ test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' },
|
||||
{ test: (p) => p === '/creator/comments', title: '评论聚合', subtitle: '分别查看我的作品与竞品作品的已采集评论,按所属账号和评论发布时间筛选。' },
|
||||
{ test: (p) => p === '/creator/events', title: '事件聚合', subtitle: '查看已开启账号的点赞、评论、关注与转发通知;私信在独立收件箱展示。' },
|
||||
{ test: (p) => p === '/creator/messages', title: '私信管理', subtitle: '查看并回复已监听账号的文字私信会话。' },
|
||||
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
|
||||
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
|
||||
@@ -42,6 +46,8 @@ export const menu: MenuDataItem[] = [
|
||||
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
|
||||
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
|
||||
{ path: '/creator/comments', name: '评论聚合', icon: <CommentOutlined /> },
|
||||
{ path: '/creator/events', name: '事件聚合', icon: <BellOutlined /> },
|
||||
{ path: '/creator/messages', name: '私信管理', icon: <MessageOutlined /> },
|
||||
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export interface Conversation {
|
||||
account_id: string;
|
||||
account_name: string;
|
||||
peer_uid: string;
|
||||
peer_name: string;
|
||||
last_text: string;
|
||||
last_message_at: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface PrivateMessage {
|
||||
id: string;
|
||||
account_id: string;
|
||||
peer_uid: string;
|
||||
direction: string;
|
||||
message_type: string;
|
||||
text: string;
|
||||
state: string;
|
||||
error: string;
|
||||
message_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ChatDrafts = Record<string, { text: string; version: number }>;
|
||||
|
||||
export function conversationKey(conversation: Pick<Conversation, 'account_id' | 'peer_uid'>): string {
|
||||
return `${conversation.account_id}:${conversation.peer_uid}`;
|
||||
}
|
||||
|
||||
function timestamp(value: string): number {
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export function mergeConversations(previous: Conversation[], incoming: Conversation[]): Conversation[] {
|
||||
const merged = new Map(previous.map((conversation) => [conversationKey(conversation), conversation]));
|
||||
const seen = new Set<string>();
|
||||
for (const conversation of incoming) {
|
||||
const key = conversationKey(conversation);
|
||||
if (!seen.has(key)) merged.set(key, conversation);
|
||||
seen.add(key);
|
||||
}
|
||||
return [...merged.values()].sort((a, b) => timestamp(b.last_message_at) - timestamp(a.last_message_at)
|
||||
|| conversationKey(a).localeCompare(conversationKey(b)));
|
||||
}
|
||||
|
||||
export function mergeMessages(previous: PrivateMessage[], incoming: PrivateMessage[]): PrivateMessage[] {
|
||||
const merged = new Map(previous.map((message) => [message.id, message]));
|
||||
const seen = new Set<string>();
|
||||
for (const message of incoming) {
|
||||
if (!seen.has(message.id)) merged.set(message.id, message);
|
||||
seen.add(message.id);
|
||||
}
|
||||
return [...merged.values()].sort((a, b) => timestamp(a.message_at || a.created_at) - timestamp(b.message_at || b.created_at)
|
||||
|| a.id.localeCompare(b.id, undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
export function updateDraft(drafts: ChatDrafts, key: string, text: string): ChatDrafts {
|
||||
return { ...drafts, [key]: { text, version: (drafts[key]?.version ?? 0) + 1 } };
|
||||
}
|
||||
|
||||
export function clearSentDraft(drafts: ChatDrafts, key: string, version: number): ChatDrafts {
|
||||
if (!drafts[key] || drafts[key].version !== version) return drafts;
|
||||
return updateDraft(drafts, key, '');
|
||||
}
|
||||
|
||||
export interface MessageFilters {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export function conversationQuery(filters: MessageFilters, page: number, pageSize: number): string {
|
||||
const query = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (filters.accountId) query.set('account_id', filters.accountId);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export function messagesQuery(accountId: string, peerUid: string, page: number, pageSize: number): string {
|
||||
const query = new URLSearchParams({
|
||||
account_id: accountId,
|
||||
peer_uid: peerUid,
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export const messageDirectionLabels: Record<string, string> = {
|
||||
inbound: '收到',
|
||||
outbound: '发出',
|
||||
};
|
||||
|
||||
export function messageDirectionLabel(direction: string): string {
|
||||
return messageDirectionLabels[direction] ?? direction;
|
||||
}
|
||||
|
||||
export const messageStateLabels: Record<string, string> = {
|
||||
succeeded: '已发送',
|
||||
sending: '发送中',
|
||||
failed: '发送失败',
|
||||
unknown: '结果未确认',
|
||||
};
|
||||
|
||||
export const messageStateTones: Record<string, string> = {
|
||||
succeeded: 'success',
|
||||
sending: 'processing',
|
||||
failed: 'error',
|
||||
unknown: 'warning',
|
||||
};
|
||||
|
||||
export function messageStateLabel(state: string): string {
|
||||
return messageStateLabels[state] ?? state;
|
||||
}
|
||||
|
||||
export function messageStateTone(state: string): string {
|
||||
return messageStateTones[state] ?? 'default';
|
||||
}
|
||||
|
||||
export const messageTypeLabels: Record<string, string> = {
|
||||
text: '文字',
|
||||
image: '图片',
|
||||
unknown: '未知类型',
|
||||
};
|
||||
|
||||
export function messageTypeLabel(type: string): string {
|
||||
return messageTypeLabels[type] ?? type;
|
||||
}
|
||||
Reference in New Issue
Block a user