95 lines
9.6 KiB
TypeScript
95 lines
9.6 KiB
TypeScript
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>;
|
||
}
|