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

1394 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import "../../styles/pages/size-template.css";
import "../../styles/pages/local-theme-parity.css";
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;