feat: 商品试算页
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 商品试算页(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-images', label: '图片与AI生图' },
|
||||
{ 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="01 商品信息">
|
||||
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-pricing">
|
||||
<Card title="02 价格试算">
|
||||
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-copy">
|
||||
<Card title="03 俄文文案(AI)">
|
||||
<CopyPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-images">
|
||||
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
||||
</div>
|
||||
<div id="section-export">
|
||||
<Card title="05 入库与导出">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user