assistant/renderer/components/OutreachPlan.tsx

95 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { ArrowRight, Check, ExternalLink, Hand, MessageSquare, RefreshCw, UserCheck } from 'lucide-react';
import { displayTime } from '../taskView';
import type { Task } from '../types';
type ObservedProfile = { source_id: string; profile_url: string; nickname?: string; xiaohongshu_id?: string; observed_at: string; evidence?: string };
type ObservedSource = { source_id: string; author_profile_url?: string; author?: string; content?: string };
export type OutreachRequest = { action: 'follow' | 'message'; source_ids: string[]; content?: string; account_profile_url: string };
type Target = ObservedProfile & { author?: string; quote?: string; profileId: string };
function text(value: unknown): string { return typeof value === 'string' ? value : ''; }
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null; }
function profileId(value: unknown): string | null {
if (typeof value !== 'string') return null;
try {
const url = new URL(value);
if (url.protocol !== 'https:' || !['www.xiaohongshu.com', 'xiaohongshu.com'].includes(url.hostname) || url.username || url.password) return null;
return /^\/user\/profile\/([a-f0-9]{24})\/?$/i.exec(url.pathname)?.[1].toLowerCase() || null;
} catch { return null; }
}
export function verifiedOutreachTargets(task: Task): Target[] {
const sources = Array.isArray(task.artifacts.sources) ? task.artifacts.sources as ObservedSource[] : [];
const profiles = Array.isArray(task.artifacts.profiles) ? task.artifacts.profiles as ObservedProfile[] : [];
const targets: Target[] = [];
const seen = new Set<string>();
for (const profile of profiles) {
const source = sources.find(item => item.source_id === profile.source_id);
const identifier = profileId(profile.profile_url);
if (!source || !identifier || !profile.observed_at || profileId(source.author_profile_url) !== identifier) continue;
if (profile.evidence !== 'matched_source_author_link_and_visible_profile_header' || seen.has(identifier)) continue;
seen.add(identifier);
targets.push({ ...profile, profileId: identifier, author: source.author, quote: source.content });
}
return targets;
}
export function OutreachPlan({ task, busy, onAuthorize, onRecheck, onAcknowledge }: {
task: Task;
busy: boolean;
onAuthorize: (request: OutreachRequest) => Promise<boolean>;
onRecheck: () => Promise<boolean>;
onAcknowledge: (accountProfileUrl: string) => Promise<boolean>;
}) {
const intent = task.inputs.intent;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [content, setContent] = useState(text(task.inputs.message_text));
const goalVersion = typeof task.inputs._goal_version === 'number' ? task.inputs._goal_version : 1;
useEffect(() => { setSelected(new Set()); }, [task.id, goalVersion, intent]);
useEffect(() => { setContent(text(task.inputs.message_text)); }, [task.id, task.inputs.message_text]);
if (intent !== 'follow' && intent !== 'message') return null;
const account = object(task.artifacts.outreach_account);
const accountUrl = text(account?.account_profile_url);
const accountId = profileId(accountUrl);
const verifiedAccount = Boolean(accountId && accountId === account?.account_id && account?.goal_version === goalVersion && text(account?.observed_at));
const targets = verifiedOutreachTargets(task).filter(item => item.profileId !== accountId);
const selectedIds = targets.filter(item => selected.has(item.source_id)).map(item => item.source_id);
const pause = object(task.artifacts.outreach_pause);
const paused = intent === 'message' && Boolean(pause);
const observedAt = text(account?.observed_at);
const pausedAt = text(pause?.at);
const zonedTime = /(?:Z|[+-]\d{2}:\d{2})$/i;
const freshObservation = zonedTime.test(observedAt) && zonedTime.test(pausedAt) && Date.parse(observedAt) > Date.parse(pausedAt);
const canAcknowledge = paused && verifiedAccount && freshObservation && account?.send_error === false && account?.daily_limit === false;
const actionable = verifiedAccount && selectedIds.length > 0 && (intent === 'follow' || !!content.trim()) && !paused && task.status !== 'running';
const pending: string[] = [];
if (!verifiedAccount) pending.push('先在实际页面核对当前操作账号,目标修改后需要重新核对。');
if (!targets.length) pending.push('先沿来源作者入口核对目标主页,未核对的对象不会进入可选名单。');
else if (!selectedIds.length) pending.push('选择本次要操作的对象,名单不会默认全选。');
if (intent === 'message' && !content.trim()) pending.push('补充这次要发送的私信内容。');
if (paused) pending.push(text(pause?.message) || '当前账号的私信阶段已暂停,请先核对页面限制和未确认发送结果。');
async function authorize(): Promise<void> {
if (!actionable || busy) return;
const done = await onAuthorize({ action: intent as 'follow' | 'message', source_ids: selectedIds, account_profile_url: accountUrl, ...(intent === 'message' ? { content: content.trim() } : {}) });
if (done) setSelected(new Set());
}
async function acknowledge(): Promise<void> {
if (!canAcknowledge || busy || task.status === 'running') return;
if (await onAcknowledge(accountUrl)) setSelected(new Set());
}
return <section className="outreach-plan" data-testid="outreach-plan">
<header>{intent === 'message' ? <MessageSquare size={17} /> : <UserCheck size={17} />}<h2>{intent === 'message' ? '核对这次私信计划' : '核对这次关注计划'}</h2></header>
{text(task.inputs.action_scope) && <p className="outreach-scope"><strong>目标范围</strong>{text(task.inputs.action_scope)}</p>}
<div className={`outreach-account${verifiedAccount ? ' verified' : ''}`} data-testid="outreach-account"><span>{verifiedAccount ? <Check size={14} /> : <Hand size={14} />}{intent === 'message' ? '发送账号' : '操作账号'}</span>{verifiedAccount ? <><a href={accountUrl} target="_blank" rel="noreferrer" title={accountUrl}>查看已核对账号主页<ExternalLink size={11} /></a><small className="outreach-account-url">{accountUrl}</small><small>页面核对时间:{displayTime(text(account?.observed_at))}</small></> : <p>当前账号尚未通过实际页面核对。</p>}</div>
{targets.length > 0 && <fieldset className="outreach-targets"><legend>本次对象 <span>已选择 {selectedIds.length} 位</span></legend><button type="button" className="text-action" disabled={busy || task.status === 'running'} onClick={() => setSelected(selectedIds.length === targets.length ? new Set() : new Set(targets.map(item => item.source_id)))}>{selectedIds.length === targets.length ? '取消全选' : '选择全部已核对对象'}</button>{targets.map(target => <label key={target.source_id}><input type="checkbox" checked={selected.has(target.source_id)} disabled={busy || task.status === 'running'} onChange={event => setSelected(previous => { const next = new Set(previous); if (event.target.checked) next.add(target.source_id); else next.delete(target.source_id); return next; })} /><span><strong>{target.nickname || target.author || '已核对主页的对象'}</strong>{target.xiaohongshu_id && <small>小红书号:{target.xiaohongshu_id}</small>}{target.quote && <p>{target.quote.slice(0, 160)}</p>}<a href={target.profile_url} target="_blank" rel="noreferrer">查看目标主页<ExternalLink size={10} /></a></span></label>)}</fieldset>}
{intent === 'message' && <label className="outreach-message"><span>本次私信内容</span><textarea value={content} maxLength={2000} rows={4} disabled={busy || task.status === 'running'} aria-label="本次私信内容" placeholder="填写或修改本次要发送的消息" onChange={event => setContent(event.target.value)} /></label>}
{paused && <section className="outreach-recovery" data-testid="outreach-recovery"><strong>当前账号私信已暂停</strong><p>{canAcknowledge ? '暂停后已重新核对页面,当前未见发送失败或当日上限提示。你确认后可以解除暂停;解除不会发送消息,也不会重发结果未知的对象。' : freshObservation && (account?.send_error === true || account?.daily_limit === true) ? '最新页面仍显示发送失败或平台限制,暂不能解除暂停。' : '先重新核对当前页面。旧截图、旧核对结果或相同时间的记录不能用于解除暂停。'}</p><div><button type="button" className="secondary-button small" data-testid="outreach-recheck" disabled={busy || task.status === 'running'} onClick={() => { void onRecheck(); }}><RefreshCw size={13} />重新核对当前页面</button><button type="button" className="secondary-button small" data-testid="outreach-acknowledge" disabled={busy || task.status === 'running' || !canAcknowledge} onClick={() => { void acknowledge(); }}>我已核实,可继续私信</button></div></section>}
{pending.length > 0 && <ul className="outreach-pending">{pending.map(item => <li key={item}>{item}</li>)}</ul>}
<footer><p>{intent === 'message' ? '点击后将按此内容向选中的对象发送私信。发送异常会暂停后续私信,结果未知时不重复发送。' : '点击后将关注选中的对象,已经关注的对象会跳过。'}</p><button type="button" className="primary-button small" data-testid="authorize-outreach" disabled={busy || !actionable} onClick={() => { void authorize(); }}>{task.status === 'running' ? '计划正在执行' : intent === 'message' ? `私信选中的 ${selectedIds.length} 位` : `关注选中的 ${selectedIds.length} 位`}<ArrowRight size={14} /></button></footer>
</section>;
}