|
|
@@ -0,0 +1,300 @@
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
+import {
|
|
|
+ Card,
|
|
|
+ Input,
|
|
|
+ Button,
|
|
|
+ Space,
|
|
|
+ message,
|
|
|
+ Image,
|
|
|
+ Tag,
|
|
|
+ Typography,
|
|
|
+ Steps,
|
|
|
+ Empty,
|
|
|
+ Tooltip,
|
|
|
+} from 'antd'
|
|
|
+import {
|
|
|
+ LinkOutlined,
|
|
|
+ SendOutlined,
|
|
|
+ PictureOutlined,
|
|
|
+ UploadOutlined,
|
|
|
+} from '@ant-design/icons'
|
|
|
+import {
|
|
|
+ submitDeconstruct,
|
|
|
+ getDeconstructResult,
|
|
|
+} from '../api/recall'
|
|
|
+import type { DeconstructPointsVO, DeconstructResultVO } from '../api/types'
|
|
|
+import DeconstructTree from './DeconstructTree'
|
|
|
+import { toHttps } from '../utils/url'
|
|
|
+
|
|
|
+const { Text } = Typography
|
|
|
+
|
|
|
+const POLL_INTERVAL_MS = 2000
|
|
|
+const POLL_TIMEOUT_MS = 5 * 60_000
|
|
|
+
|
|
|
+type StageKey = 'idle' | 'submitting' | 'polling' | 'done' | 'error'
|
|
|
+
|
|
|
+/** 状态码 -> 中文 + 颜色 */
|
|
|
+function statusMeta(status: number | undefined): { label: string; color: string } {
|
|
|
+ switch (status) {
|
|
|
+ case 0: return { label: 'PENDING', color: 'default' }
|
|
|
+ case 1: return { label: 'RUNNING', color: 'processing' }
|
|
|
+ case 2: return { label: 'SUCCESS', color: 'success' }
|
|
|
+ case 3: return { label: 'FAILED', color: 'error' }
|
|
|
+ default: return { label: '--', color: 'default' }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default function MaterialDeconstructTab() {
|
|
|
+ const [imageUrl, setImageUrl] = useState<string>('')
|
|
|
+ const [title, setTitle] = useState<string>('')
|
|
|
+ const [stage, setStage] = useState<StageKey>('idle')
|
|
|
+ const [taskId, setTaskId] = useState<string>('')
|
|
|
+ const [pollAttempt, setPollAttempt] = useState<number>(0)
|
|
|
+ const [result, setResult] = useState<DeconstructResultVO | null>(null)
|
|
|
+ const pollTimerRef = useRef<number | null>(null)
|
|
|
+ const pollStartedAtRef = useRef<number>(0)
|
|
|
+
|
|
|
+ const stopPolling = useCallback(() => {
|
|
|
+ if (pollTimerRef.current) {
|
|
|
+ window.clearTimeout(pollTimerRef.current)
|
|
|
+ pollTimerRef.current = null
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ useEffect(() => () => stopPolling(), [stopPolling])
|
|
|
+
|
|
|
+ /** 提交解构任务 → 拿 taskId → 启动轮询 */
|
|
|
+ const onSubmit = useCallback(async () => {
|
|
|
+ const url = imageUrl.trim()
|
|
|
+ if (!url) {
|
|
|
+ message.warning('请先填入图片URL')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ stopPolling()
|
|
|
+ setResult(null)
|
|
|
+ setTaskId('')
|
|
|
+ setPollAttempt(0)
|
|
|
+ setStage('submitting')
|
|
|
+ try {
|
|
|
+ const cid = `material-${Date.now()}`
|
|
|
+ const trimmedTitle = title.trim()
|
|
|
+ const id = await submitDeconstruct({
|
|
|
+ bizType: 0,
|
|
|
+ contentType: 2,
|
|
|
+ channelContentId: cid,
|
|
|
+ imageList: [url],
|
|
|
+ ...(trimmedTitle ? { title: trimmedTitle } : {}),
|
|
|
+ })
|
|
|
+ if (!id) {
|
|
|
+ message.error('提交失败: 未返回 taskId')
|
|
|
+ setStage('error')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setTaskId(id)
|
|
|
+ setStage('polling')
|
|
|
+ pollStartedAtRef.current = Date.now()
|
|
|
+ pollOnce(id, cid)
|
|
|
+ } catch (e) {
|
|
|
+ message.error('提交解构失败')
|
|
|
+ setStage('error')
|
|
|
+ }
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, [imageUrl, title, stopPolling])
|
|
|
+
|
|
|
+ const pollOnce = (id: string, cid: string) => {
|
|
|
+ setPollAttempt((n) => n + 1)
|
|
|
+ getDeconstructResult({
|
|
|
+ taskId: id,
|
|
|
+ bizType: 0,
|
|
|
+ contentType: 2,
|
|
|
+ channelContentId: cid,
|
|
|
+ })
|
|
|
+ .then((r) => {
|
|
|
+ if (!r) {
|
|
|
+ schedulePoll(id, cid)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setResult(r)
|
|
|
+ if (r.finished) {
|
|
|
+ setStage('done')
|
|
|
+ if (r.success) message.success('解构完成')
|
|
|
+ else message.error('解构失败: ' + (r.reason ?? '未知原因'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ schedulePoll(id, cid)
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ schedulePoll(id, cid)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const schedulePoll = (id: string, cid: string) => {
|
|
|
+ if (Date.now() - pollStartedAtRef.current > POLL_TIMEOUT_MS) {
|
|
|
+ message.warning('轮询超时, 请稍后再查')
|
|
|
+ setStage('error')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ pollTimerRef.current = window.setTimeout(() => pollOnce(id, cid), POLL_INTERVAL_MS)
|
|
|
+ }
|
|
|
+
|
|
|
+ const sm = statusMeta(result?.status)
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
|
+ <Card size="small" bodyStyle={{ padding: 16 }}>
|
|
|
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
|
+ <Space wrap size={8} style={{ width: '100%' }}>
|
|
|
+ <Text strong>图片URL</Text>
|
|
|
+ <Input
|
|
|
+ placeholder="粘贴公网可访问的图片 URL"
|
|
|
+ value={imageUrl}
|
|
|
+ onChange={(e) => setImageUrl(e.target.value)}
|
|
|
+ style={{ width: 480 }}
|
|
|
+ prefix={<LinkOutlined />}
|
|
|
+ allowClear
|
|
|
+ />
|
|
|
+ {/* 本地上传入口 - 暂未启用, 待 OSS 直传打通后开放 */}
|
|
|
+ <Tooltip title="本地上传暂未开放, 请使用图片 URL">
|
|
|
+ <Button icon={<UploadOutlined />} disabled>
|
|
|
+ 本地上传
|
|
|
+ </Button>
|
|
|
+ </Tooltip>
|
|
|
+ </Space>
|
|
|
+
|
|
|
+ <Space wrap size={8} style={{ width: '100%' }}>
|
|
|
+ <Text strong>
|
|
|
+ 标题 <Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>(可选)</Text>
|
|
|
+ </Text>
|
|
|
+ <Input
|
|
|
+ placeholder="可选: 输入素材标题, 辅助解构理解"
|
|
|
+ value={title}
|
|
|
+ onChange={(e) => setTitle(e.target.value)}
|
|
|
+ style={{ width: 480 }}
|
|
|
+ allowClear
|
|
|
+ />
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<SendOutlined />}
|
|
|
+ loading={stage === 'submitting' || stage === 'polling'}
|
|
|
+ onClick={onSubmit}
|
|
|
+ disabled={!imageUrl.trim()}
|
|
|
+ >
|
|
|
+ 提交解构
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+
|
|
|
+ {imageUrl && (
|
|
|
+ <div>
|
|
|
+ <Text type="secondary" style={{ fontSize: 12 }}>预览:</Text>
|
|
|
+ <div style={{ marginTop: 4 }}>
|
|
|
+ <Image
|
|
|
+ src={toHttps(imageUrl)}
|
|
|
+ alt="material"
|
|
|
+ style={{ maxHeight: 200, borderRadius: 4 }}
|
|
|
+ fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Space>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {(stage !== 'idle' || result) && (
|
|
|
+ <Card size="small" title={
|
|
|
+ <Space>
|
|
|
+ <PictureOutlined style={{ color: '#722ed1' }} />
|
|
|
+ <span>解构进度 & 结果</span>
|
|
|
+ {taskId && <Tag style={{ marginLeft: 4 }}>taskId: {taskId}</Tag>}
|
|
|
+ </Space>
|
|
|
+ }>
|
|
|
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
|
+ <Steps
|
|
|
+ size="small"
|
|
|
+ current={
|
|
|
+ stage === 'submitting' ? 0 :
|
|
|
+ stage === 'polling' ? 1 :
|
|
|
+ stage === 'done' ? 2 :
|
|
|
+ 0
|
|
|
+ }
|
|
|
+ status={stage === 'error' ? 'error' : undefined}
|
|
|
+ items={[
|
|
|
+ { title: '提交解构' },
|
|
|
+ { title: '等待结果', description: stage === 'polling' ? `第 ${pollAttempt} 次轮询` : undefined },
|
|
|
+ { title: '完成', description: result ? sm.label : undefined },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+
|
|
|
+ {result && <ResultPanel result={result} />}
|
|
|
+
|
|
|
+ {!result && (stage === 'submitting' || stage === 'polling') && (
|
|
|
+ <Text type="secondary">解构进行中, 每 2 秒自动刷新…</Text>
|
|
|
+ )}
|
|
|
+ </Space>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </Space>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** 结果展示: 状态 Tag + URL 三件套 + 复用 DeconstructTree 渲染选题/灵感点/关键点/目的点 */
|
|
|
+function ResultPanel({ result }: { result: DeconstructResultVO }) {
|
|
|
+ const sm = statusMeta(result.status)
|
|
|
+
|
|
|
+ // 适配 DeconstructTree 输入: 复用同一组件保持 Tab1/Tab3 视觉一致
|
|
|
+ const treeData: DeconstructPointsVO = {
|
|
|
+ vid: 0,
|
|
|
+ title: null,
|
|
|
+ videoUrl: null,
|
|
|
+ htmlUrl: null,
|
|
|
+ topic: result.topic ?? null,
|
|
|
+ highValuePoints: result.highValuePoints ?? [],
|
|
|
+ }
|
|
|
+
|
|
|
+ // 素材解构页暂不开放"以此召回", 全部按钮灰掉(noop)
|
|
|
+ const onRecall = () => {}
|
|
|
+
|
|
|
+ const hasContent = !!result.topic || (result.highValuePoints && result.highValuePoints.length > 0)
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <Space wrap size={8} style={{ marginBottom: 12 }}>
|
|
|
+ <Tag color={sm.color}>{sm.label}</Tag>
|
|
|
+ {result.fromCache && <Tag color="cyan">缓存命中</Tag>}
|
|
|
+ {result.url?.pointUrl && (
|
|
|
+ <Button size="small" type="link" href={result.url.pointUrl} target="_blank">
|
|
|
+ pointUrl
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ {result.url?.weightUrl && (
|
|
|
+ <Button size="small" type="link" href={result.url.weightUrl} target="_blank">
|
|
|
+ weightUrl
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ {result.url?.patternUrl && (
|
|
|
+ <Button size="small" type="link" href={result.url.patternUrl} target="_blank">
|
|
|
+ patternUrl
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </Space>
|
|
|
+
|
|
|
+ {result.reason && (
|
|
|
+ <Typography.Paragraph type="warning" style={{ marginBottom: 12 }}>
|
|
|
+ 原因: {result.reason}
|
|
|
+ </Typography.Paragraph>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {result.success && hasContent ? (
|
|
|
+ <DeconstructTree
|
|
|
+ data={treeData}
|
|
|
+ onRecallByText={onRecall}
|
|
|
+ disableRecall
|
|
|
+ disabledRecallTip="素材解构页暂未开放「以此召回」"
|
|
|
+ />
|
|
|
+ ) : !result.finished ? (
|
|
|
+ <Text type="secondary">解构尚未完成…</Text>
|
|
|
+ ) : (
|
|
|
+ <Empty description="无解构结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|