feat: 商品试算页

This commit is contained in:
R524809
2026-08-26 17:38:44 +08:00
parent 6027d8f8d7
commit ac4ab22ac2
18 changed files with 3055 additions and 3 deletions
+162
View File
@@ -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>
);
}