164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
/**
|
||
* 商品试算页(docs/v2.1/trial-page.md):采集后的主工作流。
|
||
* 01 商品信息 → 02 价格试算 → 03 俄文文案 → 04 图片与AI生图 → 05 入库与导出。
|
||
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
|
||
*/
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { Link, useParams } from 'react-router';
|
||
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
|
||
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
|
||
import { apiErrorMessage } from '@/services/api';
|
||
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
|
||
import TrialInfoPanel from './TrialInfoPanel';
|
||
import TrialPricingPanel from './TrialPricingPanel';
|
||
import TrialSuitePanel from './TrialSuitePanel';
|
||
import TrialExportPanel from './TrialExportPanel';
|
||
import CopyPanel from '../product/CopyPanel';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const SECTIONS = [
|
||
{ id: 'section-info', label: '商品信息' },
|
||
{ id: 'section-pricing', label: '价格试算' },
|
||
{ id: 'section-copy', label: '俄文文案' },
|
||
{ id: 'section-collect', label: '采集图片' },
|
||
{ id: 'section-plan', label: '出图方案' },
|
||
{ id: 'section-result', label: '生成结果' },
|
||
{ id: 'section-export', label: '入库与导出' },
|
||
];
|
||
|
||
export default function TrialPage() {
|
||
const { id } = useParams();
|
||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [active, setActive] = useState(SECTIONS[0].id);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!id) return;
|
||
setLoading(true);
|
||
try {
|
||
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
|
||
setProduct(p);
|
||
setAssets(a);
|
||
} catch (e) {
|
||
message.error(apiErrorMessage(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
// 滚动监听:高亮当前区域
|
||
useEffect(() => {
|
||
const onScroll = () => {
|
||
let current = SECTIONS[0].id;
|
||
for (const s of SECTIONS) {
|
||
const el = document.getElementById(s.id);
|
||
if (el && el.getBoundingClientRect().top <= 90) current = s.id;
|
||
}
|
||
setActive(current);
|
||
};
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => window.removeEventListener('scroll', onScroll);
|
||
}, []);
|
||
|
||
const save = useCallback(
|
||
async (partial: Partial<ProductDetail>) => {
|
||
if (!id || !product) return;
|
||
setSaving(true);
|
||
try {
|
||
const updated = await updateProduct(id, partial);
|
||
setProduct(updated);
|
||
} catch (e) {
|
||
message.error(apiErrorMessage(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
},
|
||
[id, product],
|
||
);
|
||
|
||
if (loading || !product) {
|
||
return (
|
||
<div style={{ padding: 80, textAlign: 'center' }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
|
||
|
||
return (
|
||
<div style={{ display: 'flex', gap: 16 }}>
|
||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Space>
|
||
<Title level={4} style={{ margin: 0 }}>
|
||
{titleZh || product.name || '(未命名商品)'}
|
||
</Title>
|
||
<span style={{ color: STAGE_COLOR[product.stage] }}>
|
||
{STAGE_LABEL[product.stage] || product.stage}
|
||
</span>
|
||
</Space>
|
||
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
|
||
</div>
|
||
<Space size={16} style={{ marginTop: 4 }}>
|
||
{product.name && product.name !== titleZh && (
|
||
<Text type="secondary" ellipsis={{ tooltip: product.name }} style={{ maxWidth: 420 }}>
|
||
俄文:{product.name}
|
||
</Text>
|
||
)}
|
||
<Link to={`/product/${product.id}`}>进入商品编辑页 →</Link>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
|
||
<div id="section-info">
|
||
<Card title="1 商品信息">
|
||
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
|
||
</Card>
|
||
</div>
|
||
<div id="section-pricing">
|
||
<Card title="2 价格试算">
|
||
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
|
||
</Card>
|
||
</div>
|
||
<div id="section-copy">
|
||
<Card title="3 俄文文案(AI)">
|
||
<CopyPanel product={product} onSave={save} leftSpan={11} rightSpan={13} />
|
||
</Card>
|
||
</div>
|
||
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
|
||
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
||
<div id="section-export">
|
||
<Card title="7 入库与导出">
|
||
<TrialExportPanel product={product} />
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧区域导航 */}
|
||
<div style={{ width: 120, flexShrink: 0 }}>
|
||
<div style={{ position: 'sticky', top: 80 }}>
|
||
<Menu
|
||
mode="inline"
|
||
selectedKeys={[active]}
|
||
style={{ borderInlineEnd: 0, background: 'transparent' }}
|
||
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
|
||
onClick={({ key }) => {
|
||
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|