refactor(server): 移除鉴权并归档遗留路由至 legacy/

- 删除 auth.py 与 deps.py,各路由去除 get_current_user 依赖
- collection.py 更名为 materials.py,冻结链路(ozon/publish/shops/categories)移入 legacy/
- 扩展默认生图服务端口并入 8800 并自动迁移旧配置,水印默认文案改为 Panda Store
- 新增 docs/v2.1/backend-structure.md 后端结构盘点文档
This commit is contained in:
R524809
2026-08-28 15:09:22 +08:00
parent 2835914fd8
commit dc6d38c128
44 changed files with 1556 additions and 389 deletions
-16
View File
@@ -1,16 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { getToken } from '@/services/auth';
/** 未登录则跳 /login */
export default function RequireAuth({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const token = getToken();
useEffect(() => {
if (!token) navigate('/login', { replace: true });
}, [token, navigate]);
if (!token) return null;
return <>{children}</>;
}
+4 -3
View File
@@ -31,7 +31,8 @@ const MainLayout = () => {
{!isMobile && (
<Sider
className="main-sider"
width={240}
width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
@@ -58,7 +59,7 @@ const MainLayout = () => {
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={240}
width={200}
>
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer>
@@ -67,7 +68,7 @@ const MainLayout = () => {
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
+52
View File
@@ -80,3 +80,55 @@
padding: 14px 20px !important;
}
}
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
padding: 12px 0 !important;
text-align: center;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
transform: none !important;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
transform: none !important;
margin: 0;
}
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
border-left: none !important;
}
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
display: none !important;
}
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
.sidebar-menu.ant-menu-vertical .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
padding-left: 0 !important;
padding-right: 0 !important;
}
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
display: none !important;
}
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
justify-content: center !important;
}
-46
View File
@@ -1,46 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button, Card, Input, message, Typography } from 'antd';
import { login } from '@/services/auth';
import { apiErrorMessage } from '@/services/api';
const { Title, Text } = Typography;
export default function LoginPage() {
const navigate = useNavigate();
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const onSubmit = async () => {
if (!token.trim()) return;
setLoading(true);
try {
await login(token.trim());
message.success('登录成功');
navigate('/collection', { replace: true });
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f5f5f5' }}>
<Card style={{ width: 420 }}>
<Title level={3} style={{ marginTop: 0 }}>Ozon </Title>
<Text type="secondary">访 Token .env APP_TOKEN</Text>
<Input.Password
placeholder="APP_TOKEN"
value={token}
onChange={(e) => setToken(e.target.value)}
onPressEnter={onSubmit}
style={{ marginTop: 16 }}
/>
<Button type="primary" block loading={loading} onClick={onSubmit} style={{ marginTop: 16 }}>
</Button>
</Card>
</div>
);
}
+59 -26
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Col, Input, message, Row, Typography } from 'antd';
import { Button, Col, Input, message, Row, Typography } from 'antd';
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file';
@@ -27,7 +27,15 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
const [nameRu, setNameRu] = useState(product.name ?? '');
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? '');
const [offerId, setOfferId] = useState(product.offer_id ?? '');
// 货号 = 型号-后缀:后缀独立存储(对齐 v1 modelCode+skuSuffix),型号只做前缀拼接、永不反推
const [skuSuffix, setSkuSuffix] = useState(() => {
const model = ((raw.model_code as string) ?? '').trim();
const offer = (product.offer_id ?? '').trim();
if (!offer) return '';
return model && offer.startsWith(`${model}-`) ? offer.slice(model.length + 1) : offer;
});
// 完整货号:型号与后缀拼接(任一为空则省略对应段与「-」)
const fullSku = [modelCode.trim(), skuSuffix.trim()].filter(Boolean).join('-');
// 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
const [purchaseUrl, setPurchaseUrl] = useState(
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''),
@@ -38,19 +46,9 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
/** 型号变化:货号前缀始终同步为新型号(对齐 v1 web:货号 = 型号-后缀)。
* 货号为空 → 带入「型号-」;货号非空 → 替换第一个「-」前的前缀、保留后缀;
* 型号清空时货号保持不动(避免误删已填后缀)。 */
const onModelChange = (v: string) => {
setModelCode(v);
setOfferId((prev) => {
if (!prev) return v ? `${v}-` : '';
if (!v) return prev;
const idx = prev.indexOf('-');
const suffix = idx >= 0 ? prev.slice(idx) : '-';
return `${v}${suffix}`;
});
};
/** 型号/后缀变化后同步完整货号落库 */
const saveSku = (model: string, suffix: string) =>
onSave({ offer_id: [model.trim(), suffix.trim()].filter(Boolean).join('-') });
const openUrl = purchaseUrl?.trim()
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
@@ -85,17 +83,46 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
<Input
value={modelCode}
placeholder="如 YZ"
onChange={(e) => onModelChange(e.target.value)}
onBlur={() => patchRaw({ model_code: modelCode.trim() })}
onChange={(e) => {
setModelCode(e.target.value);
saveSku(e.target.value, skuSuffix);
}}
addonAfter={
<a
title="复制型号"
style={{ opacity: modelCode.trim() ? 1 : 0.35 }}
onClick={() => {
if (!modelCode.trim()) return;
copyText(modelCode.trim()).then((ok) => ok && message.success('已复制型号'));
}}
>
<CopyOutlined />
</a>
}
/>
</Col>
<Col span={12}>
<FieldLabel>SKU</FieldLabel>
<FieldLabel>SKU{modelCode.trim() && <Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}> -</Text>}</FieldLabel>
<Input
value={offerId}
placeholder="型号-后缀(型号自动带入前缀)"
onChange={(e) => setOfferId(e.target.value)}
onBlur={() => onSave({ offer_id: offerId.trim() })}
value={skuSuffix}
placeholder="后缀,如 001"
addonBefore={modelCode.trim() ? `${modelCode.trim()}-` : undefined}
onChange={(e) => {
setSkuSuffix(e.target.value);
saveSku(modelCode, e.target.value);
}}
addonAfter={
<a
title="复制完整货号"
style={{ opacity: fullSku ? 1 : 0.35 }}
onClick={() => {
if (!fullSku) return;
copyText(fullSku).then((ok) => ok && message.success(`已复制货号:${fullSku}`));
}}
>
<CopyOutlined />
</a>
}
/>
</Col>
</Row>
@@ -108,11 +135,17 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
<ExportOutlined />
</a>
<Button
size="small"
type="primary"
ghost
icon={<ExportOutlined />}
onClick={() => window.open(openUrl, '_blank', 'noopener')}
>
</Button>
)}
<a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined />
+141 -36
View File
@@ -3,7 +3,7 @@
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
@@ -34,6 +34,74 @@ const GROUP_LABELS: Record<string, string> = {
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark';
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [text, setText] = useState(String(value));
useEffect(() => setText(String(value)), [value]);
const clamp = (v: number) => Math.max(0, Math.min(5, Number.isNaN(v) ? 0 : v));
const commit = () => onChange(clamp(parseInt(text, 10)));
const btnBase: CSSProperties = {
width: 26,
height: 26,
padding: 0,
border: 'none',
background: '#f5f5f5',
cursor: 'pointer',
fontSize: 15,
lineHeight: '26px',
color: '#555',
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
borderRadius: 8,
overflow: 'hidden',
background: '#fff',
}}
>
<button
type="button"
title="减少"
style={{ ...btnBase, color: value <= 0 ? '#ccc' : '#555', cursor: value <= 0 ? 'not-allowed' : 'pointer' }}
disabled={value <= 0}
onClick={() => onChange(clamp(value - 1))}
>
</button>
<input
value={text}
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ''))}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
title="可直接输入数量(0-5"
style={{
width: 34,
height: 26,
textAlign: 'center',
border: 'none',
outline: 'none',
fontSize: 13,
fontWeight: 600,
color: value === 0 ? '#bbb' : '#333',
background: '#fff',
}}
/>
<button
type="button"
title="增加"
style={{ ...btnBase, color: value >= 5 ? '#ccc' : '#555', cursor: value >= 5 ? 'not-allowed' : 'pointer' }}
disabled={value >= 5}
onClick={() => onChange(clamp(value + 1))}
>
</button>
</span>
);
}
interface Props {
product: ProductDetail;
assets: ProductAsset[];
@@ -109,7 +177,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 单张 AI 生图弹窗
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
@@ -597,7 +667,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Checkbox>
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
@@ -639,13 +708,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Space>
</div>
<span onClick={(e) => e.stopPropagation()}>
<InputNumber
size="small"
min={0}
max={5}
value={p.count}
onChange={(v) => setPlanCount(idx, v ?? 0)}
/>
<PlanStepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
</span>
</div>
))}
@@ -670,18 +733,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</a>
)}
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis={{ tooltip: planSummary }}>
<Text type="secondary" style={{ fontSize: 12, flex: 1, whiteSpace: 'normal', wordBreak: 'break-all' }}>
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text>
</div>
{/* 水印设置:左列最下、靠右 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</div>
</Card>
</Col>
<Col span={12}>
@@ -731,17 +786,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/>
</div>
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
{generating && (
<div style={{ flex: 1, minWidth: 200 }}>
<Progress
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
<Row align="middle" style={{ marginTop: 12, gap: 12 }} justify="end" wrap={false}>
<Select
style={{ width: 260 }}
popupMatchSelectWidth={false}
@@ -766,6 +811,15 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button>
</Row>
{generating && (
<Progress
style={{ marginTop: 12 }}
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
)}
</div>
</Col>
</Row>
@@ -777,17 +831,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Card
title="6 生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
ZIP
</Button>
)
<Space>
{suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
ZIP
</Button>
)}
{/* 水印设置(作用于下次生成) */}
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</Space>
}
>
{!suite ? (
{!suite && generatedAssets.length === 0 ? (
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
{suite && (
<>
<div style={{ marginBottom: 8 }}>
<Space wrap size={8}>
<Text type="secondary" style={{ fontSize: 12 }}>
@@ -869,6 +933,47 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</div>
</Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
{generatedAssets.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
AI
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
AI · {generatedAssets.length}
</Text>
</Text>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
{generatedAssets.map((a) => (
<div key={a.id} style={{ width: 112 }}>
<Image
src={assetUrl(a)}
width={100}
height={133}
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<div
style={{
fontSize: 11,
color: 'rgba(0,0,0,0.45)',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={a.stored_url ?? undefined}
>
AI {a.created_at ? new Date(a.created_at).toLocaleTimeString() : ''}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
</div>
)}
</>
)}
</Card>
-1
View File
@@ -20,6 +20,5 @@ export const router = createBrowserRouter([
],
},
// 后续加账户体系时再启用 /login
{ path: '/login', element: <Navigate to="/collection" replace /> },
{ path: '*', element: <Navigate to="/" replace /> },
]);
-11
View File
@@ -1,7 +1,6 @@
import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import { envConfig } from '@/config/env';
import { getToken } from './auth';
const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl,
@@ -11,16 +10,6 @@ const apiClient: AxiosInstance = axios.create({
},
});
// 请求拦截:附带 JWT
apiClient.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
-31
View File
@@ -1,31 +0,0 @@
import { api } from './api';
const TOKEN_KEY = 'ozon_kit_jwt';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export interface LoginResult {
access_token: string;
token_type: string;
expires_at: number;
}
export async function login(appToken: string): Promise<LoginResult> {
const res = await api.post<LoginResult>('/auth/login', { token: appToken });
setToken(res.access_token);
return res;
}
export function logout() {
clearToken();
}
+1
View File
@@ -67,6 +67,7 @@ export interface ProductAsset {
width: number | null;
height: number | null;
error: string | null;
created_at?: string;
}
export function listProducts(params?: { stage?: string; q?: string; page?: number; page_size?: number }) {
+1 -1
View File
@@ -80,7 +80,7 @@ export interface WatermarkPayload {
export const DEFAULT_WATERMARK: WatermarkPayload = {
enabled: false,
type: 'text',
text: 'xiongmaoyx',
text: 'Panda Store',
opacity: 30,
};