feat: editor add avatar

This commit is contained in:
JOYCEQL
2024-11-18 19:38:56 +08:00
parent bf26a3a40c
commit 473b65ec4e
10 changed files with 6822 additions and 4825 deletions
+2
View File
@@ -46,9 +46,11 @@
"react-resizable-panels": "^2.0.20",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -0,0 +1,459 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
DrawerClose
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Upload, X, Link as LinkIcon } from "lucide-react";
import {
PhotoConfig,
DEFAULT_CONFIG,
getRatioMultiplier,
getBorderRadiusValue
} from "@/types/resume";
import { motion } from "framer-motion";
import { Textarea } from "@/components/ui/textarea";
interface Props {
isOpen: boolean;
onClose: () => void;
photo?: string;
config?: PhotoConfig;
onPhotoChange: (photo: string | undefined, config?: PhotoConfig) => void;
onConfigChange: (config: PhotoConfig) => void;
theme: "dark" | "light";
}
const PhotoConfigDrawer: React.FC<Props> = ({
isOpen,
onClose,
photo,
config: initialConfig,
onPhotoChange,
onConfigChange,
theme
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(photo);
const [isDragging, setIsDragging] = useState(false);
const [imageUrl, setImageUrl] = useState(photo || "");
const drawerContentRef = useRef<HTMLInputElement>(null);
const [config, setConfig] = useState<PhotoConfig>(
initialConfig || DEFAULT_CONFIG
);
const isMobile = useMemo(() => {
return window.innerWidth <= 768;
}, [window.innerWidth]);
useEffect(() => {
if (isOpen) {
setConfig(initialConfig || DEFAULT_CONFIG);
setPreviewUrl(photo);
setImageUrl(photo || "");
}
const handleClick = (e: MouseEvent) => {
if (!drawerContentRef.current?.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [isOpen, initialConfig, photo]);
const handleFile = (file: File) => {
if (file.size > 5 * 1024 * 1024) {
alert("图片大小不能超过5MB");
return;
}
if (!file.type.startsWith("image/")) {
alert("请上传图片文件");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreviewUrl(result);
};
reader.readAsDataURL(file);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
};
const handleUrlChange = (e: string) => {
const url = e;
setImageUrl(url);
setPreviewUrl(url);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFile(file);
}
};
const handleRemovePhoto = () => {
setPreviewUrl(undefined);
setImageUrl("");
if (inputRef.current) {
inputRef.current.value = "";
}
};
const handleConfigChange = (updates: Partial<PhotoConfig>) => {
const newConfig = { ...config, ...updates };
if (config.aspectRatio !== "custom") {
if ("width" in updates) {
const ratio = getRatioMultiplier(config.aspectRatio);
newConfig.height =
Math.round(updates.width! * ratio) > 200
? 200
: Math.round(updates.width! * ratio);
}
if ("height" in updates) {
const ratio = 1 / getRatioMultiplier(config.aspectRatio);
newConfig.width =
Math.round(updates.height! * ratio) > 200
? 200
: Math.round(updates.height! * ratio);
}
}
setConfig(newConfig);
onConfigChange(newConfig);
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement>,
key: "width" | "height" | "customBorderRadius"
) => {
const value = Number(e.target.value) > 200 ? 200 : e.target.value;
if (value === "") {
setConfig((prev) => ({ ...prev, [key]: "" }));
return;
}
const numValue = Number(value);
if (!isNaN(numValue)) {
setConfig((prev) => ({ ...prev, [key]: numValue }));
}
};
const handleInputBlur = (
e: React.FocusEvent<HTMLInputElement>,
key: "width" | "height" | "customBorderRadius"
) => {
const value = e.target.value;
const numValue = value === "" ? 0 : Number(value);
if (key === "customBorderRadius") {
const maxRadius = Math.min(config.width, config.height) / 2;
const validValue = Math.max(0, Math.min(numValue, maxRadius));
handleConfigChange({ customBorderRadius: validValue });
} else {
const validValue = Math.max(24, Math.min(numValue, 200));
handleConfigChange({ [key]: validValue });
}
};
const handleSave = () => {
onPhotoChange(previewUrl, config);
onClose();
};
return (
<Drawer
direction={isMobile ? "bottom" : "left"}
modal={false}
open={isOpen}
dismissible={false}
onOpenChange={(open) => !open && onClose()}
>
<DrawerContent
ref={drawerContentRef}
className={cn(
theme === "dark" ? "bg-neutral-900 text-white" : "bg-white",
"md:fixed md:border-none md:flex md:bottom-0 md:left-0 md:right-0 md:h-[93%] md:max-w-[360px] md:mx-[-1px] md:z-10 md:outline-none shadow shadow-blue-500/40"
)}
>
<div className="mx-auto w-full max-w-md overflow-y-auto">
<DrawerHeader>
<DrawerTitle className="text-center"></DrawerTitle>
</DrawerHeader>
<div
className={cn(
"relative overflow-hidden border-2 transition-all mx-auto",
isDragging ? "border-blue-500 border-solid" : "border-dashed",
theme === "dark"
? "border-neutral-700 hover:border-neutral-600"
: "border-neutral-300 hover:border-neutral-400"
)}
style={{
width: `${config.width}px`,
height: `${config.height}px`,
borderRadius: getBorderRadiusValue(config),
maxWidth: "100%"
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{previewUrl ? (
<div className="relative h-full group">
<img
src={previewUrl}
alt="Profile"
className="w-full h-full object-cover"
/>
<div
className={cn(
"absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 transition-opacity",
"group-hover:opacity-100"
)}
>
<Button
onClick={handleRemovePhoto}
className="p-1.5 rounded-full bg-white/10 hover:bg-white/20"
>
<X className="w-4 h-4 text-white" />
</Button>
</div>
</div>
) : (
<Button
onClick={() => inputRef.current?.click()}
variant="ghost"
className="w-full h-full flex flex-col items-center justify-center p-0"
>
<Upload
className={cn(
"w-6 h-6 mb-2",
theme === "dark" ? "text-neutral-400" : "text-neutral-500"
)}
/>
<span className="text-sm"></span>
<span className="text-xs text-neutral-500 mt-1">
jpgpng 5MB
</span>
</Button>
)}
<motion.input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="p-6 space-y-6">
<div className="space-y-3">
<h3 className="text-sm font-medium">线</h3>
<Textarea
value={imageUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="请输入图片链接"
className={cn(
"h-9",
theme === "dark" && "bg-neutral-800 border-neutral-700"
)}
/>
</div>
<div className="space-y-4">
<div className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<Input
value={config.width}
onChange={(e) => handleInputChange(e, "width")}
onBlur={(e) => handleInputBlur(e, "width")}
className={cn(
"h-9 pr-7",
theme === "dark" && "bg-neutral-800 border-neutral-700"
)}
min={24}
max={200}
placeholder="宽度"
/>
<div
className={cn(
"absolute right-3 top-1/2 -translate-y-1/2 text-sm",
theme === "dark"
? "text-neutral-400"
: "text-neutral-500"
)}
>
W
</div>
</div>
<div className="relative">
<Input
value={config.height}
onChange={(e) => handleInputChange(e, "height")}
onBlur={(e) => handleInputBlur(e, "height")}
className={cn(
"h-9 pr-7",
theme === "dark" && "bg-neutral-800 border-neutral-700"
)}
min={24}
max={200}
placeholder="高度"
/>
<div
className={cn(
"absolute right-3 top-1/2 -translate-y-1/2 text-sm",
theme === "dark"
? "text-neutral-400"
: "text-neutral-500"
)}
>
H
</div>
</div>
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<div className="flex flex-wrap gap-2">
{(["1:1", "4:3", "3:4", "16:9", "custom"] as const).map(
(ratio) => (
<Button
key={ratio}
size="sm"
variant="outline"
className={cn(
"h-9",
config.aspectRatio === ratio
? theme === "dark"
? "bg-indigo-600 text-white"
: "bg-neutral-900 text-white"
: theme === "dark"
? "bg-[#262626] text-white border-none"
: "bg-transparent text-black border-neutral-200"
)}
onClick={() => {
if (ratio !== "custom") {
const height = Math.round(
config.width * getRatioMultiplier(ratio)
);
handleConfigChange({ aspectRatio: ratio, height });
} else {
handleConfigChange({ aspectRatio: ratio });
}
}}
>
{ratio === "custom" ? "自定义" : ratio}
</Button>
)
)}
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<div className="flex flex-wrap gap-2">
{(["none", "medium", "full", "custom"] as const).map(
(radius) => (
<Button
key={radius}
size="sm"
variant="outline"
className={cn(
"h-9",
config.borderRadius === radius
? theme === "dark"
? "bg-indigo-600 text-white"
: "bg-neutral-900 text-white"
: theme === "dark"
? "bg-[#262626] text-white"
: "bg-transparent text-black border-2 border-neutral-200"
)}
onClick={() =>
handleConfigChange({ borderRadius: radius })
}
>
{radius === "none"
? "无"
: radius === "medium"
? "中等"
: radius === "full"
? "圆形"
: "自定义"}
</Button>
)
)}
</div>
{config.borderRadius === "custom" && (
<Input
type="number"
value={config.customBorderRadius}
onChange={(e) => handleInputChange(e, "customBorderRadius")}
onBlur={(e) => handleInputBlur(e, "customBorderRadius")}
className={cn(
"h-9 mt-2",
theme === "dark" && "bg-neutral-800 border-neutral-700"
)}
min={0}
max={Math.min(config.width, config.height) / 2}
placeholder="自定义圆角大小"
/>
)}
</div>
</div>
</div>
<DrawerFooter>
<div className="flex gap-2">
<DrawerClose asChild>
<Button
className="w-full"
onClick={handleSave}
variant="destructive"
>
</Button>
</DrawerClose>
</div>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
};
export default PhotoConfigDrawer;
@@ -0,0 +1,76 @@
import React, { useState } from "react";
import { Settings2, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import PhotoConfigDrawer from "./PhotoConfigDrawer";
import { useResumeStore } from "@/store/useResumeStore";
import { PhotoConfig } from "@/types/resume";
interface Props {
className?: string;
theme: "dark" | "light";
}
const PhotoSelector: React.FC<Props> = ({ className, theme }) => {
const [showConfig, setShowConfig] = useState(false);
const { basic, updateBasicInfo } = useResumeStore();
const handlePhotoChange = (
photo: string | undefined,
config?: PhotoConfig
) => {
updateBasicInfo({
...basic,
photo,
photoConfig: config
});
};
const handleConfigChange = (config: PhotoConfig) => {
updateBasicInfo({
...basic,
photoConfig: config
});
};
return (
<div className={cn("relative", className)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={() => setShowConfig(true)}
>
<Settings2 className="w-3 h-3" />
</Button>
</div>
{basic.photo && (
<div className="mt-2 relative overflow-hidden">
<img
src={basic.photo}
alt="Selected"
className="w-[48px] h-[48px] object-cover rounded"
/>
</div>
)}
<PhotoConfigDrawer
isOpen={showConfig}
onClose={() => setShowConfig(false)}
photo={basic.photo}
config={basic.photoConfig}
onPhotoChange={handlePhotoChange}
onConfigChange={handleConfigChange}
theme={theme}
/>
</div>
);
};
export default PhotoSelector;
@@ -6,6 +6,7 @@ import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import { Reorder } from "framer-motion";
import IconSelector from "../IconSelector";
import PhotoUpload from "@/components/PhotoSelector";
type CustomFieldType = {
id: string;
@@ -194,8 +195,10 @@ const BasicPanel: React.FC = () => {
return (
<div className="space-y-5 p-4">
<PhotoUpload theme={theme} />
<div className="flex justify-between items-start gap-4">
<div className="flex-1">{renderField("name", "姓名")}</div>
<div>{renderField("employementStatus", "在职状态")}</div>
</div>
{renderField("title", "职位")}
@@ -1,5 +1,9 @@
import { cn } from "@/lib/utils";
import { BasicInfo, GlobalSettings } from "@/types/resume";
import {
BasicInfo,
getBorderRadiusValue,
GlobalSettings
} from "@/types/resume";
import { motion } from "framer-motion";
import React from "react";
@@ -11,6 +15,30 @@ interface BaaseInfoProps {
export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) {
return (
<div className="text-center space-y-4">
{basic.photo && (
<motion.div layout="position" className="flex justify-center">
<div
style={{
width: `${basic.photoConfig?.width || 100}px`,
height: `${basic.photoConfig?.height || 100}px`,
borderRadius: getBorderRadiusValue(
basic.photoConfig || {
borderRadius: "none",
customBorderRadius: 0
}
),
overflow: "hidden"
}}
>
<img
src={basic.photo}
alt={`${basic.name}'s photo`}
className="w-full h-full object-cover"
/>
</div>
</motion.div>
)}
<motion.h1
layout="position"
className={cn("font-bold", "text-gray-900")}
@@ -41,6 +69,7 @@ export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) {
basic.email,
basic.phone,
basic.location,
basic.employementStatus,
basic.birthDate ? new Date(basic.birthDate).toLocaleDateString() : "",
...(basic.customFields?.map((field) => field.value) || [])
]
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion, LayoutGroup } from "framer-motion";
import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import { throttle } from "lodash";
@@ -27,19 +27,18 @@ interface PageBreakLineProps {
pageNumber: number;
}
const PageBreakLine = ({ pageNumber }: PageBreakLineProps) => {
const PageBreakLine = React.memo(({ pageNumber }: PageBreakLineProps) => {
const A4_HEIGHT_MM = 297;
const TOP_MARGIN_MM = 4;
const BOTTOM_MARGIN_MM = 4;
const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - TOP_MARGIN_MM - BOTTOM_MARGIN_MM;
const MM_TO_PX = 3.78;
const SCALE_FACTOR = 2; // 考虑导出时的 scale: 2 设置
const pageHeight = CONTENT_HEIGHT_MM * MM_TO_PX;
return (
<div
className="absolute left-0 right-0 pointer-events-none page-break-line"
className="absolute left-0 right-0 pointer-events-none page-break-line"
style={{
top: `${pageHeight * pageNumber}px`,
breakAfter: "page",
@@ -54,7 +53,9 @@ const PageBreakLine = ({ pageNumber }: PageBreakLineProps) => {
</div>
</div>
);
};
});
PageBreakLine.displayName = "PageBreakLine";
export function PreviewPanel() {
const {
@@ -79,20 +80,16 @@ export function PreviewPanel() {
globalSettings?.fontFamily || "sans"
);
// 获取当前主题色
const currentThemeColor = useMemo(() => {
return colorTheme || THEME_COLORS[0];
}, [colorTheme]);
// 监测内容高度变化
useEffect(() => {
const updateContentHeight = () => {
const updateContentHeight = throttle(() => {
if (resumeContentRef.current) {
setContentHeight(resumeContentRef.current.scrollHeight);
}
};
updateContentHeight();
}, 100);
const resizeObserver = new ResizeObserver(updateContentHeight);
if (resumeContentRef.current) {
@@ -101,10 +98,10 @@ export function PreviewPanel() {
return () => {
resizeObserver.disconnect();
updateContentHeight.cancel();
};
}, []);
// 处理自动滚动
const handleScroll = React.useCallback(
throttle((offset: number) => {
if (previewRef.current) {
@@ -117,7 +114,6 @@ export function PreviewPanel() {
[scrollBehavior]
);
// 使用 IntersectionObserver 监测拖拽元素
React.useEffect(() => {
if (!draggingProjectId || !previewRef.current) return;
@@ -160,31 +156,33 @@ export function PreviewPanel() {
}, [draggingProjectId, handleScroll]);
const renderProjects = () => (
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title="项目经历"
themeColor={currentThemeColor}
globalSettings={globalSettings}
/>
<AnimatePresence mode="popLayout" initial={false}>
{projects
.filter((project) => project.visible)
.map((project) => (
<ProjectItem
key={project.id}
project={project}
draggingProjectId={draggingProjectId}
globalSettings={globalSettings}
/>
))}
</AnimatePresence>
</motion.div>
<LayoutGroup>
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title="项目经历"
themeColor={currentThemeColor}
globalSettings={globalSettings}
/>
<AnimatePresence mode="popLayout" initial={false}>
{projects
.filter((project) => project.visible)
.map((project) => (
<ProjectItem
key={project.id}
project={project}
draggingProjectId={draggingProjectId}
globalSettings={globalSettings}
/>
))}
</AnimatePresence>
</motion.div>
</LayoutGroup>
);
const pageBreakCount = useMemo(() => {
@@ -263,7 +261,6 @@ export function PreviewPanel() {
"text-[#000]"
)}
style={{
// 设置与 html2pdf 配置一致的尺寸
minHeight: "297mm"
}}
>
@@ -274,22 +271,20 @@ export function PreviewPanel() {
}}
id="resume-preview"
>
<motion.div layout className="space-y-8" ref={resumeContentRef}>
{/* Header */}
<BaseInfo basic={basic} globalSettings={globalSettings} />
<LayoutGroup>
<motion.div layout className="space-y-8" ref={resumeContentRef}>
<BaseInfo basic={basic} globalSettings={globalSettings} />
{menuSections
.filter((section) => section.enabled)
.sort((a, b) => a.order - b.order)
.map((section) => (
<motion.div key={section.id} layout>
{renderSection(section.id)}
</motion.div>
))}
</motion.div>
</LayoutGroup>
{/* Sections */}
{menuSections
.filter((section) => section.enabled)
.sort((a, b) => a.order - b.order)
.map((section) => (
<motion.div key={section.id} layout>
{renderSection(section.id)}
</motion.div>
))}
</motion.div>
{/* 动态分页指示线 */}
{Array.from({ length: pageBreakCount }, (_, i) => (
<PageBreakLine key={i} pageNumber={i + 1} />
))}
+118
View File
@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+5 -5
View File
@@ -5,6 +5,7 @@ import {
Education,
Experience,
GlobalSettings,
DEFAULT_CONFIG,
Project
} from "../types/resume";
@@ -29,7 +30,6 @@ interface ResumeStore {
colorTheme: string; // 当前使用的主题色 ID
// 新增 Actions
setColorTheme: (colorTheme: string) => void;
// Actions
@@ -64,8 +64,10 @@ const initialState = {
summary: "5年前端开发经验...",
birthDate: "",
icons: {},
photoConfig: DEFAULT_CONFIG,
customFields: [],
employementStatus: ""
employementStatus: "",
photo: "https://talencat.s3.amazonaws.com/app/avatar/builtin/cat001.png"
},
education: [
{
@@ -148,7 +150,6 @@ export const useResumeStore = create<ResumeStore>()(
(set) => ({
...initialState,
setColorTheme: (colorTheme) => {
console.log(colorTheme, "colorTheme");
set({ colorTheme });
},
@@ -172,10 +173,9 @@ export const useResumeStore = create<ResumeStore>()(
})),
reorderSections: (newOrder) => {
// 根据新顺序重新计算每个部分的 order
const updatedSections = newOrder.map((section, index) => ({
...section,
order: index // 根据数组索引设置新的顺序
order: index
}));
set({ menuSections: updatedSections });
+48 -1
View File
@@ -1,3 +1,48 @@
export interface PhotoConfig {
width: number;
height: number;
aspectRatio: "1:1" | "4:3" | "3:4" | "16:9" | "custom";
borderRadius: "none" | "medium" | "full" | "custom";
customBorderRadius: number;
}
export const DEFAULT_CONFIG: PhotoConfig = {
width: 96,
height: 96,
aspectRatio: "1:1",
borderRadius: "none",
customBorderRadius: 0
};
// 助手函数
export const getRatioMultiplier = (ratio: PhotoConfig["aspectRatio"]) => {
switch (ratio) {
case "4:3":
return 3 / 4;
case "3:4":
return 4 / 3;
case "16:9":
return 9 / 16;
default:
return 1;
}
};
export const getBorderRadiusValue = (config?: PhotoConfig) => {
if (!config) return "0";
switch (config.borderRadius) {
case "medium":
return "0.5rem";
case "full":
return "9999px";
case "custom":
return `${config.customBorderRadius}px`;
default:
return "0";
}
};
export interface BasicInfo {
birthDate: string;
name: string;
@@ -8,6 +53,8 @@ export interface BasicInfo {
summary: string;
icons: Record<string, string>;
employementStatus: string;
photo: string;
photoConfig: PhotoConfig;
customFields: Array<{
id: string;
label: string;
@@ -60,7 +107,7 @@ export type GlobalSettings = {
lineHeight?: number | undefined;
sectionSpacing?: number | undefined;
headerSize?: number | undefined;
subHeaderSize?: number | undefined;
subheaderSize?: number | undefined;
};
export interface ResumeTheme {
+6032 -4764
View File
File diff suppressed because it is too large Load Diff