78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
562 lines
17 KiB
TypeScript
562 lines
17 KiB
TypeScript
/**
|
|
* 贴纸配置面板
|
|
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
|
*/
|
|
import React, { useCallback, useState } from "react";
|
|
import { Drawer, Switch } from "antd";
|
|
import type {
|
|
StickerConfig,
|
|
StickerItem,
|
|
StickerType,
|
|
TextStickerPreset,
|
|
} from "../types";
|
|
import {
|
|
DEFAULT_STICKER_CONFIG,
|
|
DEFAULT_STICKER_ITEM,
|
|
TEXT_STICKER_PRESET_LABELS,
|
|
} from "../types";
|
|
|
|
interface StickerPanelProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
config: StickerConfig;
|
|
onChange: (config: StickerConfig) => void;
|
|
totalDuration: number;
|
|
}
|
|
|
|
/** 常用 emoji 素材 */
|
|
const EMOJI_LIST = [
|
|
"😀",
|
|
"😂",
|
|
"🥰",
|
|
"😎",
|
|
"🤩",
|
|
"😱",
|
|
"🤔",
|
|
"😴",
|
|
"🥳",
|
|
"😍",
|
|
"❤️",
|
|
"🔥",
|
|
"⭐",
|
|
"✨",
|
|
"💯",
|
|
"👍",
|
|
"👏",
|
|
"🎉",
|
|
"🎵",
|
|
"💪",
|
|
"📌",
|
|
"💡",
|
|
"🎯",
|
|
"✅",
|
|
"❌",
|
|
"⬆️",
|
|
"⬇️",
|
|
"➡️",
|
|
"⭕",
|
|
"🔔",
|
|
];
|
|
|
|
/** 文字花字预设对应的 CSS 样式预览 */
|
|
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
|
|
normal: { color: "#fff", textShadow: "none" },
|
|
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
|
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
|
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
|
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
|
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
|
gradient: {
|
|
color: "transparent",
|
|
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
|
WebkitBackgroundClip: "text",
|
|
},
|
|
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
|
};
|
|
|
|
/** 生成唯一 ID */
|
|
const genId = () =>
|
|
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
const StickerPanel: React.FC<StickerPanelProps> = ({
|
|
open,
|
|
onClose,
|
|
config,
|
|
onChange,
|
|
totalDuration,
|
|
}) => {
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
|
|
|
|
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
|
|
|
|
/** 更新单个贴纸 */
|
|
const updateItem = useCallback(
|
|
(id: string, partial: Partial<StickerItem>) => {
|
|
onChange({
|
|
...config,
|
|
items: config.items.map((s) =>
|
|
s.id === id ? { ...s, ...partial } : s,
|
|
),
|
|
});
|
|
},
|
|
[config, onChange],
|
|
);
|
|
|
|
/** 添加贴纸 */
|
|
const addSticker = useCallback(
|
|
(type: StickerType, content: string) => {
|
|
const newItem: StickerItem = {
|
|
...DEFAULT_STICKER_ITEM,
|
|
id: genId(),
|
|
type,
|
|
content,
|
|
duration: totalDuration > 0 ? totalDuration : 5,
|
|
z_index: config.items.length + 1,
|
|
};
|
|
onChange({
|
|
...config,
|
|
enabled: true,
|
|
items: [...config.items, newItem],
|
|
});
|
|
setSelectedId(newItem.id);
|
|
},
|
|
[config, onChange, totalDuration],
|
|
);
|
|
|
|
/** 删除贴纸 */
|
|
const removeSticker = useCallback(
|
|
(id: string) => {
|
|
onChange({
|
|
...config,
|
|
items: config.items.filter((s) => s.id !== id),
|
|
});
|
|
if (selectedId === id) setSelectedId(null);
|
|
},
|
|
[config, onChange, selectedId],
|
|
);
|
|
|
|
/** 重置所有 */
|
|
const handleReset = useCallback(() => {
|
|
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
|
|
setSelectedId(null);
|
|
}, [config.enabled, onChange]);
|
|
|
|
/** 文字花字输入 */
|
|
const [textInput, setTextInput] = useState("");
|
|
|
|
return (
|
|
<Drawer
|
|
title="贴纸"
|
|
placement="right"
|
|
width={460}
|
|
open={open}
|
|
onClose={onClose}
|
|
className="sticker-panel-drawer"
|
|
>
|
|
{/* 顶部开关 */}
|
|
<div className="sticker-header">
|
|
<span className="sticker-header-label">启用贴纸</span>
|
|
<Switch
|
|
size="small"
|
|
checked={config.enabled}
|
|
onChange={(checked) => onChange({ ...config, enabled: checked })}
|
|
/>
|
|
</div>
|
|
|
|
{/* 类型 Tab */}
|
|
<div className="sticker-tabs">
|
|
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
|
onClick={() => setActiveTab(t)}
|
|
>
|
|
{t === "emoji"
|
|
? "表情贴纸"
|
|
: t === "image"
|
|
? "图片贴纸"
|
|
: "文字花字"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab 内容区 */}
|
|
<div className="sticker-tab-content">
|
|
{/* Emoji 素材库 */}
|
|
{activeTab === "emoji" && (
|
|
<div className="sticker-emoji-grid">
|
|
{EMOJI_LIST.map((emoji) => (
|
|
<button
|
|
key={emoji}
|
|
className="sticker-emoji-btn"
|
|
onClick={() => addSticker("emoji", emoji)}
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 图片贴纸 */}
|
|
{activeTab === "image" && (
|
|
<div className="sticker-image-input">
|
|
<input
|
|
type="text"
|
|
className="sticker-url-input"
|
|
placeholder="输入图片 URL 添加贴纸..."
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
|
addSticker("image", e.currentTarget.value.trim());
|
|
e.currentTarget.value = "";
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
className="sticker-url-add-btn"
|
|
onClick={() => {
|
|
const input =
|
|
document.querySelector<HTMLInputElement>(
|
|
".sticker-url-input",
|
|
);
|
|
if (input?.value.trim()) {
|
|
addSticker("image", input.value.trim());
|
|
input.value = "";
|
|
}
|
|
}}
|
|
>
|
|
添加
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 文字花字 */}
|
|
{activeTab === "text" && (
|
|
<div className="sticker-text-section">
|
|
<div className="sticker-text-input-row">
|
|
<input
|
|
type="text"
|
|
className="sticker-text-input"
|
|
placeholder="输入文字内容..."
|
|
value={textInput}
|
|
onChange={(e) => setTextInput(e.target.value)}
|
|
/>
|
|
<button
|
|
className="sticker-text-add-btn"
|
|
disabled={!textInput.trim()}
|
|
onClick={() => {
|
|
if (textInput.trim()) {
|
|
addSticker("text", textInput.trim());
|
|
setTextInput("");
|
|
}
|
|
}}
|
|
>
|
|
添加
|
|
</button>
|
|
</div>
|
|
<div className="sticker-text-presets">
|
|
<div className="sticker-preset-title">花字预设预览</div>
|
|
<div className="sticker-preset-grid">
|
|
{(
|
|
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
|
|
).map((p) => (
|
|
<div
|
|
key={p}
|
|
className="sticker-preset-preview"
|
|
style={{
|
|
...TEXT_PRESET_STYLES[p],
|
|
background:
|
|
p === "bubble"
|
|
? "rgba(0,0,0,0.5)"
|
|
: p === "gradient"
|
|
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
|
: "#1a1a2e",
|
|
}}
|
|
>
|
|
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
|
<div className="sticker-preset-name">
|
|
{TEXT_STICKER_PRESET_LABELS[p]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 已添加贴纸列表 */}
|
|
{config.items.length > 0 && (
|
|
<div className="sticker-list-section">
|
|
<div className="sticker-section-title">
|
|
已添加贴纸 ({config.items.length})
|
|
</div>
|
|
<div className="sticker-list">
|
|
{config.items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
|
onClick={() => setSelectedId(item.id)}
|
|
>
|
|
<span className="sticker-list-icon">
|
|
{item.type === "emoji"
|
|
? item.content
|
|
: item.type === "text"
|
|
? "T"
|
|
: "🖼"}
|
|
</span>
|
|
<span className="sticker-list-name">
|
|
{item.type === "text"
|
|
? item.content.slice(0, 10)
|
|
: item.type === "emoji"
|
|
? "表情贴纸"
|
|
: "图片贴纸"}
|
|
</span>
|
|
<button
|
|
className="sticker-list-delete"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeSticker(item.id);
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 选中贴纸的属性编辑 */}
|
|
{selectedSticker && (
|
|
<div className="sticker-props-section">
|
|
<div className="sticker-section-title">属性调整</div>
|
|
|
|
{/* 位置 */}
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">位置 X</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={0}
|
|
max={100}
|
|
value={selectedSticker.x}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, { x: Number(e.target.value) })
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">{selectedSticker.x}%</span>
|
|
</div>
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">位置 Y</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={0}
|
|
max={100}
|
|
value={selectedSticker.y}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, { y: Number(e.target.value) })
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">{selectedSticker.y}%</span>
|
|
</div>
|
|
|
|
{/* 尺寸 */}
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">大小</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={5}
|
|
max={50}
|
|
value={selectedSticker.width}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
width: Number(e.target.value),
|
|
height: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">{selectedSticker.width}%</span>
|
|
</div>
|
|
|
|
{/* 旋转 */}
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">旋转</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={-180}
|
|
max={180}
|
|
value={selectedSticker.rotation}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
rotation: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">
|
|
{selectedSticker.rotation}°
|
|
</span>
|
|
</div>
|
|
|
|
{/* 透明度 */}
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">透明度</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={0}
|
|
max={100}
|
|
value={selectedSticker.opacity}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
opacity: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">
|
|
{selectedSticker.opacity}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* 时间 */}
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">开始</span>
|
|
<input
|
|
type="number"
|
|
className="sticker-prop-number"
|
|
min={0}
|
|
max={totalDuration}
|
|
step={0.1}
|
|
value={selectedSticker.start_time}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
start_time: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="sticker-prop-label">时长</span>
|
|
<input
|
|
type="number"
|
|
className="sticker-prop-number"
|
|
min={0}
|
|
max={totalDuration}
|
|
step={0.1}
|
|
value={selectedSticker.duration}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
duration: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* 文字贴纸特有属性 */}
|
|
{selectedSticker.type === "text" && (
|
|
<>
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">花字</span>
|
|
<select
|
|
className="sticker-prop-select"
|
|
value={selectedSticker.text_preset}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
text_preset: e.target.value as TextStickerPreset,
|
|
})
|
|
}
|
|
>
|
|
{(
|
|
Object.keys(
|
|
TEXT_STICKER_PRESET_LABELS,
|
|
) as TextStickerPreset[]
|
|
).map((p) => (
|
|
<option key={p} value={p}>
|
|
{TEXT_STICKER_PRESET_LABELS[p]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">字号</span>
|
|
<input
|
|
type="range"
|
|
className="sticker-prop-slider"
|
|
min={12}
|
|
max={72}
|
|
value={selectedSticker.font_size}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
font_size: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="sticker-prop-value">
|
|
{selectedSticker.font_size}px
|
|
</span>
|
|
</div>
|
|
<div className="sticker-prop-row">
|
|
<span className="sticker-prop-label">颜色</span>
|
|
<input
|
|
type="color"
|
|
className="sticker-prop-color"
|
|
value={selectedSticker.text_color}
|
|
onChange={(e) =>
|
|
updateItem(selectedSticker.id, {
|
|
text_color: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 预览 */}
|
|
<div className="sticker-preview-box">
|
|
<div
|
|
className="sticker-preview-item"
|
|
style={{
|
|
left: `${selectedSticker.x}%`,
|
|
top: `${selectedSticker.y}%`,
|
|
width: `${selectedSticker.width}%`,
|
|
height: `${selectedSticker.width}%`,
|
|
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
|
|
opacity: selectedSticker.opacity / 100,
|
|
fontSize:
|
|
selectedSticker.type === "text"
|
|
? `${selectedSticker.font_size}px`
|
|
: undefined,
|
|
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
|
|
}}
|
|
>
|
|
{selectedSticker.type === "emoji" && selectedSticker.content}
|
|
{selectedSticker.type === "text" && selectedSticker.content}
|
|
{selectedSticker.type === "image" && (
|
|
<img
|
|
src={selectedSticker.content}
|
|
alt="sticker"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 底部重置 */}
|
|
<div className="sticker-footer">
|
|
<button className="sticker-reset-btn" onClick={handleReset}>
|
|
清空所有贴纸
|
|
</button>
|
|
</div>
|
|
</Drawer>
|
|
);
|
|
};
|
|
|
|
export default StickerPanel;
|