Files
omniai-web/src/features/size-template/SizeTemplatePage.tsx
T

1394 lines
52 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
CloseOutlined,
CloudUploadOutlined,
FileImageOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import type { WebViewKey } from "../../types";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu";
import "../../styles/pages/ecommerce.css";
2026-06-05 19:34:36 +08:00
import "../../styles/pages/size-template.css";
import "../../styles/pages/local-theme-parity.css";
2026-06-02 12:38:01 +08:00
interface SizeTemplatePageProps {
isAuthenticated?: boolean;
onOpenMore?: () => void;
onOpenEcommerce?: () => void;
onSelectView?: (view: WebViewKey) => void;
}
interface SizeTemplatePreset {
title: string;
group: string;
category: string;
mainSpec: string;
ratio: string;
ratioCss: string;
limit: string;
summary: string;
details: string[];
tone: "green" | "cyan" | "violet" | "amber";
}
interface SizeTemplateSizeOption {
label: string;
mainSpec: string;
ratio: string;
ratioCss: string;
}
interface SizeTemplateUploadImage {
id: string;
src: string;
name: string;
}
const sizeTemplateGroups = [
{ key: "socialCn", label: "社交/内容平台" },
{ key: "socialGlobal", label: "国际社交平台" },
{ key: "ecommerce", label: "电商平台" },
{ key: "id", label: "证件照/官方证件" },
];
const socialContentPlatformOptions = [
{ label: "微信公众号", category: "微信公众号" },
{ label: "小红书", category: "小红书" },
{ label: "抖音", category: "抖音/TikTok" },
{ label: "B站", category: "B站" },
{ label: "微博", category: "微博" },
];
const internationalSocialPlatformOptions = [
{ label: "Instagram", category: "Instagram" },
{ label: "YouTube", category: "YouTube" },
{ label: "Twitter/X", category: "Twitter/X" },
{ label: "Facebook", category: "Facebook" },
{ label: "LinkedIn", category: "LinkedIn" },
];
const ecommercePlatformOptions = [
{ label: "淘宝/天猫", category: "淘宝/天猫" },
{ label: "京东", category: "京东" },
{ label: "拼多多", category: "拼多多" },
{ label: "亚马逊", category: "亚马逊" },
{ label: "虾皮 Shopee/Lazada", category: "虾皮 Shopee/Lazada" },
];
const idPhotoPlatformOptions = [
{ label: "常规证件照", category: "常规证件照" },
{ label: "各国签证 / 护照电子照", category: "各国签证/护照电子照" },
{ label: "考试报名照片", category: "考试报名照片" },
{ label: "求职相关", category: "求职相关" },
];
const wechatTypeOptions = [
{ label: "头条封面", title: "微信公众号头条封面" },
{ label: "次条封面", title: "微信公众号次条封面" },
{ label: "正文图", title: "微信公众号正文图" },
];
const xiaohongshuTypeOptions = [
{ label: "笔记封面", title: "小红书笔记封面" },
{ label: "头像", title: "小红书头像" },
];
const douyinTypeOptions = [
{ label: "视频封面", title: "抖音/TikTok 视频封面" },
{ label: "头像", title: "抖音/TikTok 头像" },
];
const bilibiliTypeOptions = [
{ label: "视频封面", title: "B站视频封面" },
{ label: "头像", title: "B站头像" },
{ label: "专栏配图", title: "B站专栏配图" },
];
const weiboTypeOptions = [
{ label: "正文图", title: "微博正文图" },
{ label: "动图", title: "微博动图" },
{ label: "头像", title: "微博头像" },
{ label: "背景图", title: "微博背景图" },
];
const instagramTypeOptions = [
{ label: "常规帖子", title: "Instagram 常规帖子" },
{ label: "动态 / Reels 封面", title: "Instagram 动态/Reels 封面" },
{ label: "头像", title: "Instagram 头像" },
];
const youtubeTypeOptions = [
{ label: "视频缩略图", title: "YouTube 视频缩略图" },
{ label: "频道横幅", title: "YouTube 频道横幅" },
{ label: "频道头像", title: "YouTube 频道头像" },
];
const twitterTypeOptions = [
{ label: "推文图", title: "Twitter/X 推文图" },
{ label: "头部图", title: "Twitter/X 头部图" },
{ label: "卡片图", title: "Twitter/X 卡片图" },
{ label: "头像", title: "Twitter/X 头像" },
];
const facebookTypeOptions = [
{ label: "帖子图", title: "Facebook 帖子图" },
{ label: "桌面封面", title: "Facebook 桌面封面" },
{ label: "移动端封面", title: "Facebook 移动端封面" },
{ label: "故事", title: "Facebook 故事" },
{ label: "群组封面", title: "Facebook 群组封面" },
{ label: "头像", title: "Facebook 头像" },
];
const linkedInTypeOptions = [
{ label: "帖子图", title: "LinkedIn 帖子图" },
{ label: "文章封面", title: "LinkedIn 文章封面" },
{ label: "个人背景图", title: "LinkedIn 个人背景图" },
{ label: "个人头像", title: "LinkedIn 个人头像" },
{ label: "企业 Logo", title: "LinkedIn 企业 Logo" },
{ label: "企业横幅", title: "LinkedIn 企业横幅" },
];
const taobaoTypeOptions = [
{ label: "主图 / SKU 图", title: "淘宝/天猫主图/SKU图" },
{ label: "详情页", title: "淘宝/天猫详情页" },
];
const jingdongTypeOptions = [
{ label: "主图 / SKU 图", title: "京东主图/SKU图" },
{ label: "详情页", title: "京东详情页" },
];
const pinduoduoTypeOptions = [
{ label: "主图", title: "拼多多主图" },
{ label: "详情页", title: "拼多多详情页" },
];
const amazonTypeOptions = [{ label: "主图", title: "亚马逊主图" }];
const shopeeLazadaTypeOptions = [{ label: "商品主图", title: "虾皮 Shopee/Lazada 商品主图" }];
const regularIdPhotoTypeOptions = [
{ label: "一寸照", title: "一寸照" },
{ label: "二寸照", title: "二寸照" },
{ label: "小二寸", title: "小二寸(护照/签证)" },
];
const visaPassportTypeOptions = [
{ label: "中国护照", title: "中国护照" },
{ label: "美国签证", title: "美国签证" },
{ label: "英国签证", title: "英国签证" },
{ label: "加拿大签证", title: "加拿大签证" },
];
const examPhotoTypeOptions = [
{ label: "国考", title: "国考报名照片" },
{ label: "英语四六级", title: "英语四六级报名照片" },
{ label: "教资", title: "教资报名照片" },
];
const jobPhotoTypeOptions = [
{ label: "简历照片", title: "简历照片" },
{ label: "LinkedIn 头像", title: "求职 LinkedIn 头像" },
];
const typeOptionsByCategory = {
微信公众号: wechatTypeOptions,
小红书: xiaohongshuTypeOptions,
"抖音/TikTok": douyinTypeOptions,
B站: bilibiliTypeOptions,
微博: weiboTypeOptions,
Instagram: instagramTypeOptions,
YouTube: youtubeTypeOptions,
"Twitter/X": twitterTypeOptions,
Facebook: facebookTypeOptions,
LinkedIn: linkedInTypeOptions,
"淘宝/天猫": taobaoTypeOptions,
京东: jingdongTypeOptions,
拼多多: pinduoduoTypeOptions,
亚马逊: amazonTypeOptions,
"虾皮 Shopee/Lazada": shopeeLazadaTypeOptions,
常规证件照: regularIdPhotoTypeOptions,
"各国签证/护照电子照": visaPassportTypeOptions,
考试报名照片: examPhotoTypeOptions,
求职相关: jobPhotoTypeOptions,
};
const sizeOptionsByPresetTitle: Record<string, SizeTemplateSizeOption[]> = {
: [
{ label: "1080×1440px", mainSpec: "1080×1440px", ratio: "3:4", ratioCss: "3 / 4" },
{ label: "1080×1080px", mainSpec: "1080×1080px", ratio: "1:1", ratioCss: "1 / 1" },
],
"Instagram 常规帖子": [
{ label: "1080×1350px", mainSpec: "1080×1350px", ratio: "4:5", ratioCss: "4 / 5" },
{ label: "1080×1080px", mainSpec: "1080×1080px", ratio: "1:1", ratioCss: "1 / 1" },
],
"淘宝/天猫详情页": [
{ label: "宽750px", mainSpec: "宽750px", ratio: "高≤1546px", ratioCss: "750 / 1546" },
{ label: "宽790px", mainSpec: "宽790px", ratio: "高≤1546px", ratioCss: "790 / 1546" },
],
: [
{ label: "750×352px", mainSpec: "750×352px", ratio: "750:352", ratioCss: "750 / 352" },
{ label: "800×800px", mainSpec: "800×800px", ratio: "1:1", ratioCss: "1 / 1" },
],
"虾皮 Shopee/Lazada 商品主图": [
{ label: "推荐 1024×1024px", mainSpec: "1024×1024px", ratio: "1:1", ratioCss: "1 / 1" },
{ label: "基础 800×800px", mainSpec: "800×800px", ratio: "1:1", ratioCss: "1 / 1" },
],
};
const sizeTemplatePresets: SizeTemplatePreset[] = [
{
title: "微信公众号头条封面",
group: "socialCn",
category: "微信公众号",
mainSpec: "900×383px",
ratio: "2.35:1",
ratioCss: "900 / 383",
limit: "≤5MB",
summary: "头条封面:900×383px2.35:1,≤5MB。",
details: ["次条封面:200×200px1:1", "正文图:宽≤1080px,单张≤10MB"],
tone: "green",
},
{
title: "微信公众号次条封面",
group: "socialCn",
category: "微信公众号",
mainSpec: "200×200px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "次条封面:200×200px1:1。",
details: ["头条封面:900×383px2.35:1,≤5MB", "正文图:宽≤1080px,单张≤10MB"],
tone: "green",
},
{
title: "微信公众号正文图",
group: "socialCn",
category: "微信公众号",
mainSpec: "宽≤1080px",
ratio: "自适应",
ratioCss: "4 / 3",
limit: "单张≤10MB",
summary: "正文图:宽≤1080px,单张≤10MB。",
details: ["头条封面:900×383px2.35:1,≤5MB", "次条封面:200×200px1:1"],
tone: "green",
},
{
title: "小红书笔记封面",
group: "socialCn",
category: "小红书",
mainSpec: "1080×1440px",
ratio: "3:4",
ratioCss: "3 / 4",
limit: "单张≤20MB",
summary: "笔记封面:1080×1440px3:4),单张≤20MB。",
details: ["可在尺寸中切换 1080×1080px1:1", "头像:400×400px1:1"],
tone: "cyan",
},
{
title: "小红书头像",
group: "socialCn",
category: "小红书",
mainSpec: "400×400px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "头像:400×400px1:1。",
details: ["笔记封面:1080×1440px3:4)、1080×1080px1:1),单张≤20MB"],
tone: "cyan",
},
{
title: "抖音/TikTok 视频封面",
group: "socialCn",
category: "抖音/TikTok",
mainSpec: "1080×1920px",
ratio: "9:16",
ratioCss: "9 / 16",
limit: "≤2MB",
summary: "视频封面:1080×1920px9:16,≤2MB(上下 15% 区域会被遮挡)。",
details: ["头像:200×200px1:1"],
tone: "violet",
},
{
title: "抖音/TikTok 头像",
group: "socialCn",
category: "抖音/TikTok",
mainSpec: "200×200px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "头像:200×200px1:1。",
details: ["视频封面:1080×1920px9:16,≤2MB(上下 15% 区域会被遮挡)"],
tone: "violet",
},
{
title: "B站视频封面",
group: "socialCn",
category: "B站",
mainSpec: "1146×717px",
ratio: "16:10",
ratioCss: "1146 / 717",
limit: "≤2MB",
summary: "视频封面:1146×717px16:10,≤2MB。",
details: ["头像:300×300px1:1", "专栏配图:宽度≤1000px"],
tone: "amber",
},
{
title: "B站头像",
group: "socialCn",
category: "B站",
mainSpec: "300×300px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "头像:300×300px1:1。",
details: ["视频封面:1146×717px16:10,≤2MB", "专栏配图:宽度≤1000px"],
tone: "amber",
},
{
title: "B站专栏配图",
group: "socialCn",
category: "B站",
mainSpec: "宽度≤1000px",
ratio: "自适应",
ratioCss: "4 / 3",
limit: "按平台限制导出",
summary: "专栏配图:宽度≤1000px。",
details: ["视频封面:1146×717px16:10,≤2MB", "头像:300×300px1:1"],
tone: "amber",
},
{
title: "微博正文图",
group: "socialCn",
category: "微博",
mainSpec: "推荐宽1080px",
ratio: "长图≤1:3",
ratioCss: "4 / 3",
limit: "单张≤20MB",
summary: "正文图:推荐宽 1080px,单张≤20MB;长图比例≤1:3。",
details: ["动图:≤5MB", "头像:200×200px1:1", "背景图:920×300px"],
tone: "green",
},
{
title: "微博动图",
group: "socialCn",
category: "微博",
mainSpec: "按内容导出",
ratio: "自适应",
ratioCss: "4 / 3",
limit: "≤5MB",
summary: "动图:≤5MB。",
details: ["正文图:推荐宽 1080px,单张≤20MB;长图比例≤1:3", "头像:200×200px1:1", "背景图:920×300px"],
tone: "green",
},
{
title: "微博头像",
group: "socialCn",
category: "微博",
mainSpec: "200×200px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "头像:200×200px1:1。",
details: ["正文图:推荐宽 1080px,单张≤20MB;长图比例≤1:3", "动图:≤5MB", "背景图:920×300px"],
tone: "green",
},
{
title: "微博背景图",
group: "socialCn",
category: "微博",
mainSpec: "920×300px",
ratio: "920:300",
ratioCss: "920 / 300",
limit: "按平台限制导出",
summary: "背景图:920×300px。",
details: ["正文图:推荐宽 1080px,单张≤20MB;长图比例≤1:3", "动图:≤5MB", "头像:200×200px1:1"],
tone: "green",
},
{
title: "Instagram 常规帖子",
group: "socialGlobal",
category: "Instagram",
mainSpec: "1080×1350px",
ratio: "4:5",
ratioCss: "4 / 5",
limit: "图片≤1MB",
summary: "常规帖子:1080×1350px4:5 推荐),图片≤1MB。",
details: ["可在尺寸中切换 1080×1080px1:1", "动态 / Reels 封面:1080×1920px9:16", "头像:320×320px1:1"],
tone: "cyan",
},
{
title: "Instagram 动态/Reels 封面",
group: "socialGlobal",
category: "Instagram",
mainSpec: "1080×1920px",
ratio: "9:16",
ratioCss: "9 / 16",
limit: "图片≤1MB",
summary: "动态 / Reels 封面:1080×1920px9:16,图片≤1MB。",
details: ["常规帖子:1080×1350px4:5 推荐)或 1080×1080px1:1", "头像:320×320px1:1"],
tone: "cyan",
},
{
title: "Instagram 头像",
group: "socialGlobal",
category: "Instagram",
mainSpec: "320×320px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "图片≤1MB",
summary: "头像:320×320px1:1,图片≤1MB。",
details: ["常规帖子:1080×1350px4:5 推荐)或 1080×1080px1:1", "动态 / Reels 封面:1080×1920px9:16"],
tone: "cyan",
},
{
title: "YouTube 视频缩略图",
group: "socialGlobal",
category: "YouTube",
mainSpec: "1280×720px",
ratio: "16:9",
ratioCss: "16 / 9",
limit: "宽≥640px,≤2MB",
summary: "视频缩略图:1280×720px16:9,宽≥640px,≤2MB。",
details: ["频道横幅:2560×1440px;安全区 1546×423px", "频道头像:800×800px1:1"],
tone: "amber",
},
{
title: "YouTube 频道横幅",
group: "socialGlobal",
category: "YouTube",
mainSpec: "2560×1440px",
ratio: "16:9",
ratioCss: "16 / 9",
limit: "安全区1546×423px",
summary: "频道横幅:2560×1440px;安全区 1546×423px。",
details: ["视频缩略图:1280×720px16:9,宽≥640px,≤2MB", "频道头像:800×800px1:1"],
tone: "amber",
},
{
title: "YouTube 频道头像",
group: "socialGlobal",
category: "YouTube",
mainSpec: "800×800px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "频道头像:800×800px1:1。",
details: ["视频缩略图:1280×720px16:9,宽≥640px,≤2MB", "频道横幅:2560×1440px;安全区 1546×423px"],
tone: "amber",
},
{
title: "Twitter/X 推文图",
group: "socialGlobal",
category: "Twitter/X",
mainSpec: "1600×900px",
ratio: "16:9",
ratioCss: "16 / 9",
limit: "GIF≤15MB,视频≤512MB",
summary: "推文图:1600×900px16:9。素材限制:GIF≤15MB,视频≤512MB。",
details: ["头部图:1500×500px3:1", "卡片图:800×418px", "头像:400×400px1:1"],
tone: "violet",
},
{
title: "Twitter/X 头部图",
group: "socialGlobal",
category: "Twitter/X",
mainSpec: "1500×500px",
ratio: "3:1",
ratioCss: "3 / 1",
limit: "GIF≤15MB,视频≤512MB",
summary: "头部图:1500×500px3:1。素材限制:GIF≤15MB,视频≤512MB。",
details: ["推文图:1600×900px16:9", "卡片图:800×418px", "头像:400×400px1:1"],
tone: "violet",
},
{
title: "Twitter/X 卡片图",
group: "socialGlobal",
category: "Twitter/X",
mainSpec: "800×418px",
ratio: "800:418",
ratioCss: "800 / 418",
limit: "GIF≤15MB,视频≤512MB",
summary: "卡片图:800×418px。素材限制:GIF≤15MB,视频≤512MB。",
details: ["推文图:1600×900px16:9", "头部图:1500×500px3:1", "头像:400×400px1:1"],
tone: "violet",
},
{
title: "Twitter/X 头像",
group: "socialGlobal",
category: "Twitter/X",
mainSpec: "400×400px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "GIF≤15MB,视频≤512MB",
summary: "头像:400×400px1:1。素材限制:GIF≤15MB,视频≤512MB。",
details: ["推文图:1600×900px16:9", "头部图:1500×500px3:1", "卡片图:800×418px"],
tone: "violet",
},
{
title: "Facebook 帖子图",
group: "socialGlobal",
category: "Facebook",
mainSpec: "1200×630px",
ratio: "1.91:1",
ratioCss: "1200 / 630",
limit: "按平台压缩规则导出",
summary: "帖子图:1200×630px。",
details: ["桌面封面:820×312px", "移动端封面:640×360px", "故事:1080×1920px9:16", "群组封面:1640×856px", "头像:170×170px1:1"],
tone: "green",
},
{
title: "Facebook 桌面封面",
group: "socialGlobal",
category: "Facebook",
mainSpec: "820×312px",
ratio: "820:312",
ratioCss: "820 / 312",
limit: "按平台压缩规则导出",
summary: "桌面封面:820×312px。",
details: ["帖子图:1200×630px", "移动端封面:640×360px", "故事:1080×1920px9:16", "群组封面:1640×856px", "头像:170×170px1:1"],
tone: "green",
},
{
title: "Facebook 移动端封面",
group: "socialGlobal",
category: "Facebook",
mainSpec: "640×360px",
ratio: "16:9",
ratioCss: "16 / 9",
limit: "按平台压缩规则导出",
summary: "移动端封面:640×360px。",
details: ["帖子图:1200×630px", "桌面封面:820×312px", "故事:1080×1920px9:16", "群组封面:1640×856px", "头像:170×170px1:1"],
tone: "green",
},
{
title: "Facebook 故事",
group: "socialGlobal",
category: "Facebook",
mainSpec: "1080×1920px",
ratio: "9:16",
ratioCss: "9 / 16",
limit: "按平台压缩规则导出",
summary: "故事:1080×1920px9:16。",
details: ["帖子图:1200×630px", "桌面封面:820×312px", "移动端封面:640×360px", "群组封面:1640×856px", "头像:170×170px1:1"],
tone: "green",
},
{
title: "Facebook 群组封面",
group: "socialGlobal",
category: "Facebook",
mainSpec: "1640×856px",
ratio: "1640:856",
ratioCss: "1640 / 856",
limit: "按平台压缩规则导出",
summary: "群组封面:1640×856px。",
details: ["帖子图:1200×630px", "桌面封面:820×312px", "移动端封面:640×360px", "故事:1080×1920px9:16", "头像:170×170px1:1"],
tone: "green",
},
{
title: "Facebook 头像",
group: "socialGlobal",
category: "Facebook",
mainSpec: "170×170px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台压缩规则导出",
summary: "头像:170×170px1:1。",
details: ["帖子图:1200×630px", "桌面封面:820×312px", "移动端封面:640×360px", "故事:1080×1920px9:16", "群组封面:1640×856px"],
tone: "green",
},
{
title: "LinkedIn 帖子图",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "1200×627px",
ratio: "1.91:1",
ratioCss: "1200 / 627",
limit: "按平台压缩规则导出",
summary: "帖子图:1200×627px。",
details: ["文章封面:1200×644px", "个人背景图:1584×396px", "个人头像:400×400px1:1", "企业 Logo300×300px1:1", "企业横幅:1128×191px"],
tone: "cyan",
},
{
title: "LinkedIn 文章封面",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "1200×644px",
ratio: "1200:644",
ratioCss: "1200 / 644",
limit: "按平台压缩规则导出",
summary: "文章封面:1200×644px。",
details: ["帖子图:1200×627px", "个人背景图:1584×396px", "个人头像:400×400px1:1", "企业 Logo300×300px1:1", "企业横幅:1128×191px"],
tone: "cyan",
},
{
title: "LinkedIn 个人背景图",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "1584×396px",
ratio: "4:1",
ratioCss: "4 / 1",
limit: "按平台限制导出",
summary: "个人背景图:1584×396px。",
details: ["帖子图:1200×627px", "文章封面:1200×644px", "个人头像:400×400px1:1", "企业 Logo300×300px1:1", "企业横幅:1128×191px"],
tone: "cyan",
},
{
title: "LinkedIn 个人头像",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "400×400px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "个人头像:400×400px1:1。",
details: ["帖子图:1200×627px", "文章封面:1200×644px", "个人背景图:1584×396px", "企业 Logo300×300px1:1", "企业横幅:1128×191px"],
tone: "cyan",
},
{
title: "LinkedIn 企业 Logo",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "300×300px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "企业 Logo300×300px1:1。",
details: ["帖子图:1200×627px", "文章封面:1200×644px", "个人背景图:1584×396px", "个人头像:400×400px1:1", "企业横幅:1128×191px"],
tone: "cyan",
},
{
title: "LinkedIn 企业横幅",
group: "socialGlobal",
category: "LinkedIn",
mainSpec: "1128×191px",
ratio: "1128:191",
ratioCss: "1128 / 191",
limit: "按平台限制导出",
summary: "企业横幅:1128×191px。",
details: ["帖子图:1200×627px", "文章封面:1200×644px", "个人背景图:1584×396px", "个人头像:400×400px1:1", "企业 Logo300×300px1:1"],
tone: "cyan",
},
{
title: "淘宝/天猫主图/SKU图",
group: "ecommerce",
category: "淘宝/天猫",
mainSpec: "800×800px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "≤3MB",
summary: "主图 / SKU 图:800×800px1:1,≤3MB。",
details: ["详情页:宽 750px/790px,单张高≤1546px"],
tone: "green",
},
{
title: "淘宝/天猫详情页",
group: "ecommerce",
category: "淘宝/天猫",
mainSpec: "宽750px",
ratio: "高≤1546px",
ratioCss: "750 / 1546",
limit: "单张高≤1546px",
summary: "详情页:宽 750px/790px,单张高≤1546px。",
details: ["主图 / SKU 图:800×800px1:1,≤3MB"],
tone: "green",
},
{
title: "京东主图/SKU图",
group: "ecommerce",
category: "京东",
mainSpec: "800×800px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "≤1MB(白底)",
summary: "主图 / SKU 图:800×800px1:1,≤1MB(白底)。",
details: ["详情页:宽 750px"],
tone: "cyan",
},
{
title: "京东详情页",
group: "ecommerce",
category: "京东",
mainSpec: "宽750px",
ratio: "自适应",
ratioCss: "4 / 3",
limit: "按平台限制导出",
summary: "详情页:宽 750px。",
details: ["主图 / SKU 图:800×800px1:1,≤1MB(白底)"],
tone: "cyan",
},
{
title: "拼多多主图",
group: "ecommerce",
category: "拼多多",
mainSpec: "750×352px",
ratio: "750:352",
ratioCss: "750 / 352",
limit: "≤1MB",
summary: "主图:750×352px,≤1MB。",
details: ["可在尺寸中切换 800×800px1:1", "详情页:宽 750px"],
tone: "amber",
},
{
title: "拼多多详情页",
group: "ecommerce",
category: "拼多多",
mainSpec: "宽750px",
ratio: "自适应",
ratioCss: "4 / 3",
limit: "按平台限制导出",
summary: "详情页:宽 750px。",
details: ["主图:750×352px、800×800px1:1),≤1MB"],
tone: "amber",
},
{
title: "亚马逊主图",
group: "ecommerce",
category: "亚马逊",
mainSpec: "≥1600×1600px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "≤10MB(纯白底)",
summary: "主图:≥1600×1600px1:1,≤10MB(纯白底),最小 500×500px。",
details: ["最小 500×500px"],
tone: "violet",
},
{
title: "虾皮 Shopee/Lazada 商品主图",
group: "ecommerce",
category: "虾皮 Shopee/Lazada",
mainSpec: "1024×1024px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "≤2MB",
summary: "商品主图:推荐 1024×1024px1:1,≤2MB。",
details: ["可在尺寸中切换基础 800×800px1:1"],
tone: "green",
},
{
title: "一寸照",
group: "id",
category: "常规证件照",
mainSpec: "295×413px",
ratio: "295:413",
ratioCss: "295 / 413",
limit: "按平台限制导出",
summary: "一寸照:295×413px。",
details: ["二寸照:413×579px", "小二寸(护照 / 签证):413×531px"],
tone: "cyan",
},
{
title: "二寸照",
group: "id",
category: "常规证件照",
mainSpec: "413×579px",
ratio: "413:579",
ratioCss: "413 / 579",
limit: "按平台限制导出",
summary: "二寸照:413×579px。",
details: ["一寸照:295×413px", "小二寸(护照 / 签证):413×531px"],
tone: "cyan",
},
{
title: "小二寸(护照/签证)",
group: "id",
category: "常规证件照",
mainSpec: "413×531px",
ratio: "413:531",
ratioCss: "413 / 531",
limit: "按平台限制导出",
summary: "小二寸(护照 / 签证):413×531px。",
details: ["一寸照:295×413px", "二寸照:413×579px"],
tone: "cyan",
},
{
title: "中国护照",
group: "id",
category: "各国签证/护照电子照",
mainSpec: "354×472px",
ratio: "354:472",
ratioCss: "354 / 472",
limit: "≤40KB(白底)",
summary: "中国护照:354×472px,≤40KB(白底)。",
details: ["美国签证:600×600px1:1,≤240KB", "英国签证:600×750px", "加拿大签证:420×540px"],
tone: "violet",
},
{
title: "美国签证",
group: "id",
category: "各国签证/护照电子照",
mainSpec: "600×600px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "≤240KB",
summary: "美国签证:600×600px1:1,≤240KB。",
details: ["中国护照:354×472px,≤40KB(白底)", "英国签证:600×750px", "加拿大签证:420×540px"],
tone: "violet",
},
{
title: "英国签证",
group: "id",
category: "各国签证/护照电子照",
mainSpec: "600×750px",
ratio: "600:750",
ratioCss: "600 / 750",
limit: "按平台限制导出",
summary: "英国签证:600×750px。",
details: ["中国护照:354×472px,≤40KB(白底)", "美国签证:600×600px1:1,≤240KB", "加拿大签证:420×540px"],
tone: "violet",
},
{
title: "加拿大签证",
group: "id",
category: "各国签证/护照电子照",
mainSpec: "420×540px",
ratio: "420:540",
ratioCss: "420 / 540",
limit: "按平台限制导出",
summary: "加拿大签证:420×540px。",
details: ["中国护照:354×472px,≤40KB(白底)", "美国签证:600×600px1:1,≤240KB", "英国签证:600×750px"],
tone: "violet",
},
{
title: "国考报名照片",
group: "id",
category: "考试报名照片",
mainSpec: "宽130px",
ratio: "按公告",
ratioCss: "4 / 5",
limit: "≤30KB",
summary: "国考:宽 130px,≤30KB。",
details: ["英语四六级:192×144px,≤20KB", "教资:宽 114-480px,≤200KB"],
tone: "amber",
},
{
title: "英语四六级报名照片",
group: "id",
category: "考试报名照片",
mainSpec: "192×144px",
ratio: "4:3",
ratioCss: "4 / 3",
limit: "≤20KB",
summary: "英语四六级:192×144px,≤20KB。",
details: ["国考:宽 130px,≤30KB", "教资:宽 114-480px,≤200KB"],
tone: "amber",
},
{
title: "教资报名照片",
group: "id",
category: "考试报名照片",
mainSpec: "宽114-480px",
ratio: "按公告",
ratioCss: "4 / 5",
limit: "≤200KB",
summary: "教资:宽 114-480px,≤200KB。",
details: ["国考:宽 130px,≤30KB", "英语四六级:192×144px,≤20KB"],
tone: "amber",
},
{
title: "简历照片",
group: "id",
category: "求职相关",
mainSpec: "300dpi起",
ratio: "按简历要求",
ratioCss: "4 / 5",
limit: "≤200KB(部分≤100KB",
summary: "简历照片:≤200KB(部分≤100KB),300dpi 起。",
details: ["LinkedIn 头像:400×400px1:1"],
tone: "green",
},
{
title: "求职 LinkedIn 头像",
group: "id",
category: "求职相关",
mainSpec: "400×400px",
ratio: "1:1",
ratioCss: "1 / 1",
limit: "按平台限制导出",
summary: "LinkedIn 头像:400×400px1:1。",
details: ["简历照片:≤200KB(部分≤100KB),300dpi 起"],
tone: "green",
},
];
function createSizeTemplateUploadItems(files: File[], remainingSlots: number): SizeTemplateUploadImage[] {
return files
.filter((file) => file.type.startsWith("image/"))
.slice(0, remainingSlots)
.map((file, index) => ({
id: `size-template-${Date.now()}-${index}-${file.name}`,
src: URL.createObjectURL(file),
name: file.name,
}));
}
function SizeTemplatePage({ onOpenEcommerce }: SizeTemplatePageProps) {
const uploadInputRef = useRef<HTMLInputElement>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const uploadObjectUrlsRef = useRef<string[]>([]);
const [activeGroup, setActiveGroup] = useState(sizeTemplateGroups[0]!.key);
const [activePresetTitle, setActivePresetTitle] = useState(sizeTemplatePresets[0]!.title);
const [activePanelTab, setActivePanelTab] = useState<"preset" | "detail">("preset");
const [platformDialogOpen, setPlatformDialogOpen] = useState(false);
const [typeDialogOpen, setTypeDialogOpen] = useState(false);
const [sizeDialogOpen, setSizeDialogOpen] = useState(false);
const [activeSizeOptionByTitle, setActiveSizeOptionByTitle] = useState<Record<string, string>>({});
const [uploadedImages, setUploadedImages] = useState<SizeTemplateUploadImage[]>([]);
const [isUploadDragging, setIsUploadDragging] = useState(false);
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(false);
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
const filteredTemplates = useMemo(
() => sizeTemplatePresets.filter((item) => item.group === activeGroup),
[activeGroup],
);
const selectedPreset =
filteredTemplates.find((item) => item.title === activePresetTitle) ?? filteredTemplates[0] ?? sizeTemplatePresets[0]!;
const activeGroupLabel = sizeTemplateGroups.find((item) => item.key === selectedPreset.group)?.label ?? "尺寸模板";
const platformOptions =
activeGroup === "socialCn"
? socialContentPlatformOptions
: activeGroup === "socialGlobal"
? internationalSocialPlatformOptions
: activeGroup === "ecommerce"
? ecommercePlatformOptions
: activeGroup === "id"
? idPhotoPlatformOptions
: [];
const selectedPlatformLabel =
platformOptions.length
? (platformOptions.find((item) => item.category === selectedPreset.category)?.label ?? selectedPreset.category)
: selectedPreset.category;
const typeOptions = typeOptionsByCategory[selectedPreset.category as keyof typeof typeOptionsByCategory] ?? [];
const selectedTypeLabel = typeOptions.find((option) => option.title === selectedPreset.title)?.label ?? selectedPreset.title;
const sizeOptions = sizeOptionsByPresetTitle[selectedPreset.title] ?? [];
const selectedSizeOption =
sizeOptions.find((option) => option.mainSpec === activeSizeOptionByTitle[selectedPreset.title]) ?? sizeOptions[0];
const selectedMainSpec = selectedSizeOption?.mainSpec ?? selectedPreset.mainSpec;
const selectedRatio = selectedSizeOption?.ratio ?? selectedPreset.ratio;
const primaryUploadedImage = uploadedImages[0] ?? null;
const sizeTemplateMentionImages: MentionImageOption[] = uploadedImages.map((image, index) => ({
...image,
label: `上传图 ${index + 1}`,
}));
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(sizeTemplateMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
const insertRequirementImageMention = (image: MentionImageOption) => {
const textarea = requirementTextareaRef.current;
const cursor = textarea?.selectionStart ?? requirement.length;
const next = insertImageMentionValue(requirement, cursor, image.name, 500);
setRequirement(next.value);
setRequirementImageMentionQuery(null);
window.requestAnimationFrame(() => {
requirementTextareaRef.current?.focus();
requirementTextareaRef.current?.setSelectionRange(next.selectionStart, next.selectionStart);
});
};
useEffect(
() => () => {
uploadObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
uploadObjectUrlsRef.current = [];
},
[],
);
const addUploadedImages = (files: File[]) => {
const remainingSlots = 7 - uploadedImages.length;
if (remainingSlots <= 0) return;
const nextImages = createSizeTemplateUploadItems(files, remainingSlots);
if (!nextImages.length) return;
uploadObjectUrlsRef.current.push(...nextImages.map((item) => item.src));
setUploadedImages((current) => [...current, ...nextImages].slice(0, 7));
};
const handleUploadChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addUploadedImages(Array.from(files));
event.target.value = "";
};
const handleUploadDrop = (event: DragEvent<HTMLElement>) => {
event.preventDefault();
setIsUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addUploadedImages(files);
};
const removeUploadedImage = (imageId: string) => {
setUploadedImages((current) => {
const removed = current.find((item) => item.id === imageId);
if (removed) {
URL.revokeObjectURL(removed.src);
uploadObjectUrlsRef.current = uploadObjectUrlsRef.current.filter((url) => url !== removed.src);
}
return current.filter((item) => item.id !== imageId);
});
};
const selectGroup = (group: string) => {
setActiveGroup(group);
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen(false);
const firstPreset = sizeTemplatePresets.find((item) => item.group === group);
if (firstPreset) setActivePresetTitle(firstPreset.title);
};
const selectPlatform = (category: string) => {
const nextPreset = sizeTemplatePresets.find((item) => item.group === activeGroup && item.category === category);
if (nextPreset) setActivePresetTitle(nextPreset.title);
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen(false);
};
const selectType = (title: string) => {
setActivePresetTitle(title);
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen(false);
};
const selectSizeOption = (mainSpec: string) => {
setActiveSizeOptionByTitle((current) => ({ ...current, [selectedPreset.title]: mainSpec }));
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen(false);
};
return (
<section
className={`product-clone-page page-motion size-template-workbench${isSettingsCollapsed ? " is-settings-collapsed" : ""}`}
data-tool="clone"
aria-label="尺寸模板"
>
<div className="product-clone-shell">
<aside
id="size-template-settings-panel"
className="product-clone-panel"
aria-label="尺寸模板参数"
aria-hidden={isSettingsCollapsed ? true : undefined}
>
<div className="product-clone-panel__scroll clone-ai-panel size-template-settings-panel">
<section className="clone-ai-card size-template-upload-card">
<h2>
<CloudUploadOutlined />
</h2>
<div
role="button"
tabIndex={0}
className={`clone-ai-upload-zone size-template-upload-zone${isUploadDragging ? " is-dragging" : ""}`}
onClick={() => uploadInputRef.current?.click()}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
uploadInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsUploadDragging(false)}
onDrop={handleUploadDrop}
>
<div className="clone-ai-upload-main">
<span className="clone-ai-upload-icon">
<FileImageOutlined />
</span>
<span className="clone-ai-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="clone-ai-upload-hint"> 7 </span>
</div>
{uploadedImages.length ? (
<div className="clone-ai-uploaded-files size-template-uploaded-files" aria-label="已上传商品原图">
{uploadedImages.map((item) => (
<figure key={item.id} className="clone-ai-uploaded-file">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
aria-label={`移除 ${item.name}`}
onClick={(event) => {
event.stopPropagation();
removeUploadedImage(item.id);
}}
>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</div>
<input ref={uploadInputRef} type="file" accept="image/*" multiple onChange={handleUploadChange} />
</section>
<section className="clone-ai-card size-template-generate-card">
<h2>
<SettingOutlined />
</h2>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-tag-group size-template-category-grid" role="tablist" aria-label="尺寸模板分类">
{sizeTemplateGroups.map((group) => (
<button
key={group.key}
type="button"
role="tab"
aria-selected={activeGroup === group.key}
className={activeGroup === group.key ? "is-active" : ""}
onClick={() => selectGroup(group.key)}
>
{group.label}
</button>
))}
</div>
</div>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group">
{[
{ key: "platform", label: "平台", value: selectedPlatformLabel },
{ key: "type", label: "类型", value: typeOptions.length > 0 ? selectedTypeLabel : selectedPreset.title },
{ key: "size", label: "尺寸", value: selectedMainSpec },
{ key: "ratio", label: "比例", value: selectedRatio },
].map((item) => {
const isPlatformSelect = platformOptions.length > 0 && item.key === "platform";
const isTypeSelect = typeOptions.length > 0 && item.key === "type";
const isSizeSelect = sizeOptions.length > 0 && item.key === "size";
const isClickable = isPlatformSelect || isTypeSelect || isSizeSelect;
return (
<div
key={item.label}
className={`clone-ai-basic-select size-template-static-field${isClickable ? " is-clickable" : ""}`}
>
<button
type="button"
aria-expanded={
isClickable
? isPlatformSelect
? platformDialogOpen
: isTypeSelect
? typeDialogOpen
: sizeDialogOpen
: undefined
}
aria-haspopup={isClickable ? "dialog" : undefined}
onClick={
isPlatformSelect
? () => {
setTypeDialogOpen(false);
setSizeDialogOpen(false);
setPlatformDialogOpen((current) => !current);
}
: isTypeSelect
? () => {
setPlatformDialogOpen(false);
setSizeDialogOpen(false);
setTypeDialogOpen((current) => !current);
}
: isSizeSelect
? () => {
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen((current) => !current);
}
: undefined
}
>
<span>{item.label}</span>
<strong>{item.value}</strong>
<i aria-hidden="true" />
</button>
{isPlatformSelect && platformDialogOpen ? (
<div className="size-template-platform-dialog" role="dialog" aria-label="选择平台">
{platformOptions.map((option) => (
<button
key={option.category}
type="button"
className={selectedPreset.category === option.category ? "is-active" : ""}
onClick={() => selectPlatform(option.category)}
>
{option.label}
</button>
))}
</div>
) : null}
{isTypeSelect && typeDialogOpen ? (
<div className="size-template-platform-dialog" role="dialog" aria-label="选择类型">
{typeOptions.map((option) => (
<button
key={option.title}
type="button"
className={selectedPreset.title === option.title ? "is-active" : ""}
onClick={() => selectType(option.title)}
>
{option.label}
</button>
))}
</div>
) : null}
{isSizeSelect && sizeDialogOpen ? (
<div className="size-template-platform-dialog" role="dialog" aria-label="选择尺寸">
{sizeOptions.map((option) => (
<button
key={option.mainSpec}
type="button"
className={selectedMainSpec === option.mainSpec ? "is-active" : ""}
onClick={() => selectSizeOption(option.mainSpec)}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
</section>
<section className="clone-ai-model-panel size-template-option-panel" aria-label="平台尺寸设置">
<div className="clone-ai-model-tabs" role="tablist" aria-label="尺寸模板设置类型">
<button
type="button"
className={activePanelTab === "preset" ? "is-active" : ""}
aria-selected={activePanelTab === "preset"}
onClick={() => setActivePanelTab("preset")}
>
</button>
<button
type="button"
className={activePanelTab === "detail" ? "is-active" : ""}
aria-selected={activePanelTab === "detail"}
onClick={() => setActivePanelTab("detail")}
>
</button>
</div>
<div className="clone-ai-model-scroll">
{activePanelTab === "preset" ? (
<div className="clone-ai-model-scene-grid size-template-option-grid">
{filteredTemplates.map((item) => (
<button
key={item.title}
type="button"
className={selectedPreset.title === item.title ? "is-active" : ""}
aria-pressed={selectedPreset.title === item.title}
onClick={() => {
setActivePresetTitle(item.title);
setPlatformDialogOpen(false);
setTypeDialogOpen(false);
setSizeDialogOpen(false);
}}
>
<span aria-hidden="true" />
{item.title}
</button>
))}
</div>
) : (
<div className="clone-ai-model-scene-grid size-template-option-grid">
{selectedPreset.details.map((item) => (
<button key={item} type="button" className="is-active">
<span aria-hidden="true" />
{item}
</button>
))}
</div>
)}
</div>
</section>
<button type="button" className="clone-ai-generate size-template-submit" onClick={onOpenEcommerce}>
</button>
</div>
</aside>
<button
type="button"
className="clone-ai-settings-toggle"
onClick={() => setIsSettingsCollapsed((current) => !current)}
aria-label={isSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
aria-controls="size-template-settings-panel"
aria-expanded={!isSettingsCollapsed}
title={isSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
>
{isSettingsCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
<main className="product-clone-preview clone-ai-preview size-template-preview" aria-label="尺寸模板预览">
<header className="clone-ai-preview-header size-template-preview-header">
<strong></strong>
<span>
AI <b></b>
</span>
</header>
<section className={`clone-ai-empty-state size-template-empty-state${primaryUploadedImage ? " has-upload" : ""}`} aria-live="polite">
{primaryUploadedImage ? (
<img className="size-template-empty-state__image" src={primaryUploadedImage.src} alt={primaryUploadedImage.name} />
) : (
<FileImageOutlined />
)}
<strong>{primaryUploadedImage ? "已添加参考图" : "等待生成"}</strong>
<span>{primaryUploadedImage ? `已上传 ${uploadedImages.length}/7 张,开始生成后将套用当前尺寸。` : "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}</span>
</section>
<section className="clone-ai-bottom-input" aria-label="信息详情">
<div className="clone-ai-input-wrapper">
<textarea
ref={requirementTextareaRef}
value={requirement}
onChange={(event) => {
const nextValue = event.target.value.slice(0, 500);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, event.target.selectionStart);
}}
onClick={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyUp={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyDown={(event) => {
if (event.key === "Escape") setRequirementImageMentionQuery(null);
}}
maxLength={500}
placeholder="补充尺寸用途,例如平台、商品类型、是否需要留白或安全区"
/>
{requirementImageMentionQuery !== null && sizeTemplateMentionImages.length ? (
<ImageMentionMenu images={sizeTemplateMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
) : null}
<button type="button" className="clone-ai-send-button" onClick={onOpenEcommerce} aria-label="应用到电商生成">
</button>
</div>
<span className="clone-ai-char-count">{requirement.length}/500</span>
</section>
</main>
</div>
</section>
);
}
export default SizeTemplatePage;