|
|
@@ -0,0 +1,381 @@
|
|
|
+
|
|
|
+import React, { useRef } from 'react';
|
|
|
+import { X, Upload, RotateCcw, Image as ImageIcon, Layout, Film, Ship, Utensils, Video, Anchor } from 'lucide-react';
|
|
|
+import { useTheme } from '../contexts/ThemeContext.tsx';
|
|
|
+import { Itinerary, CruiseShip } from '../types.ts';
|
|
|
+
|
|
|
+interface ThemeSettingsProps {
|
|
|
+ isOpen: boolean;
|
|
|
+ onClose: () => void;
|
|
|
+}
|
|
|
+
|
|
|
+const ThemeSettings: React.FC<ThemeSettingsProps> = ({ isOpen, onClose }) => {
|
|
|
+ const {
|
|
|
+ customLogo, customFooterLogo, heroImages,
|
|
|
+ setCustomLogo, setCustomFooterLogo, setHeroImages,
|
|
|
+ itineraries, setItineraries,
|
|
|
+ dining, setDining,
|
|
|
+ ships, setShips,
|
|
|
+ videoSection, setVideoSection,
|
|
|
+ shipsPageImages, setShipsPageImages,
|
|
|
+ resetTheme
|
|
|
+ } = useTheme();
|
|
|
+
|
|
|
+ const logoInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const footerLogoInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const heroInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const diningImgRef = useRef<HTMLInputElement>(null);
|
|
|
+ const videoThumbRef = useRef<HTMLInputElement>(null);
|
|
|
+ const auroraImgRef = useRef<HTMLInputElement>(null);
|
|
|
+
|
|
|
+ // Helper to update specific item in an array
|
|
|
+ const updateItinerary = (index: number, field: keyof Itinerary, value: any) => {
|
|
|
+ const newItems = [...itineraries];
|
|
|
+ newItems[index] = { ...newItems[index], [field]: value };
|
|
|
+ setItineraries(newItems);
|
|
|
+ };
|
|
|
+
|
|
|
+ const updateShip = (index: number, field: keyof CruiseShip, value: any) => {
|
|
|
+ const newItems = [...ships];
|
|
|
+ newItems[index] = { ...newItems[index], [field]: value };
|
|
|
+ setShips(newItems);
|
|
|
+ };
|
|
|
+
|
|
|
+ // Helper for single image upload
|
|
|
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>, callback: (url: string) => void) => {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (file) {
|
|
|
+ const url = URL.createObjectURL(file);
|
|
|
+ callback(url);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Helper for array image/video upload
|
|
|
+ const handleArrayMediaUpload = (e: React.ChangeEvent<HTMLInputElement>, index: number, type: 'itinerary_img' | 'itinerary_video' | 'ship' | 'lanyue') => {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (file) {
|
|
|
+ const url = URL.createObjectURL(file);
|
|
|
+ if (type === 'itinerary_img') updateItinerary(index, 'image', url);
|
|
|
+ else if (type === 'itinerary_video') updateItinerary(index, 'video', url);
|
|
|
+ else if (type === 'ship') updateShip(index, 'image', url);
|
|
|
+ else if (type === 'lanyue') {
|
|
|
+ const newImages = [...shipsPageImages.lanyue];
|
|
|
+ newImages[index] = url;
|
|
|
+ setShipsPageImages({...shipsPageImages, lanyue: newImages});
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!isOpen) return null;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm flex justify-end">
|
|
|
+ {/*
|
|
|
+ Width optimization:
|
|
|
+ w-full max-w-[340px]: Ensures it takes full width only on very small screens (up to 340px),
|
|
|
+ but stays as a 340px sidebar on larger screens, preventing full-screen coverage.
|
|
|
+ */}
|
|
|
+ <div className="w-full max-w-[340px] bg-white h-full shadow-2xl p-6 overflow-y-auto animate-slide-in-right">
|
|
|
+ <div className="flex justify-between items-center mb-6 border-b border-vista-darkblue/10 pb-4">
|
|
|
+ <h2 className="text-xl font-serif text-vista-darkblue">自定义内容配置</h2>
|
|
|
+ <button onClick={onClose} className="text-vista-darkblue/40 hover:text-vista-darkblue transition-colors">
|
|
|
+ <X size={24} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-4 pb-20">
|
|
|
+
|
|
|
+ {/* Section 1: Branding & Hero */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <ImageIcon size={16} /> 品牌与轮播图
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-6 bg-white border-t border-vista-darkblue/10">
|
|
|
+ {/* Main Logo */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs text-vista-darkblue/60 mb-2 font-bold">网站 Logo (顶部导航)</label>
|
|
|
+ <div className="flex items-center gap-4">
|
|
|
+ {customLogo ? (
|
|
|
+ <img src={customLogo} className="h-10 w-auto object-contain border p-1" alt="Logo" />
|
|
|
+ ) : <span className="text-xs text-gray-400">默认</span>}
|
|
|
+ <button onClick={() => logoInputRef.current?.click()} className="text-xs bg-vista-darkblue text-white px-3 py-1 rounded hover:bg-vista-gold transition-colors">上传</button>
|
|
|
+ <input type="file" ref={logoInputRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, setCustomLogo)} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Footer Logo */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs text-vista-darkblue/60 mb-2 font-bold">页脚 Logo (单独设置)</label>
|
|
|
+ <div className="flex items-center gap-4">
|
|
|
+ {customFooterLogo ? (
|
|
|
+ <img src={customFooterLogo} className="h-10 w-auto object-contain border p-1" alt="Footer Logo" />
|
|
|
+ ) : <span className="text-xs text-gray-400">默认/继承</span>}
|
|
|
+ <button onClick={() => footerLogoInputRef.current?.click()} className="text-xs bg-vista-darkblue text-white px-3 py-1 rounded hover:bg-vista-gold transition-colors">上传</button>
|
|
|
+ <input type="file" ref={footerLogoInputRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, setCustomFooterLogo)} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Hero Images */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs text-vista-darkblue/60 mb-2 font-bold">轮播图 ({heroImages.length})</label>
|
|
|
+ <div className="grid grid-cols-3 gap-2 mb-2">
|
|
|
+ {heroImages.map((img, i) => (
|
|
|
+ <div key={i} className="relative aspect-video">
|
|
|
+ <img src={img} className="w-full h-full object-cover rounded" alt={`Hero ${i}`} />
|
|
|
+ <button onClick={() => setHeroImages(heroImages.filter((_, idx) => idx !== i))} className="absolute top-0 right-0 bg-red-500 text-white p-0.5 rounded-bl hover:bg-red-600">
|
|
|
+ <X size={10} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <input type="file" ref={heroInputRef} className="hidden" multiple accept="image/*" onChange={(e) => {
|
|
|
+ if (e.target.files) {
|
|
|
+ const newImgs = Array.from(e.target.files).map((f: any) => URL.createObjectURL(f));
|
|
|
+ setHeroImages([...heroImages, ...newImgs]);
|
|
|
+ }
|
|
|
+ }} />
|
|
|
+ <button onClick={() => heroInputRef.current?.click()} className="w-full py-2 border border-dashed border-vista-darkblue/30 text-xs text-vista-darkblue hover:border-vista-gold hover:text-vista-gold transition-colors">添加图片</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ {/* New Section: Ships Page Details */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <Anchor size={16} /> 子页面: 长江行游轮
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-6 bg-white border-t border-vista-darkblue/10">
|
|
|
+ {/* Lanyue Images */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs text-vista-darkblue/60 mb-2 font-bold">长江行·揽月 (4张图)</label>
|
|
|
+ <div className="grid grid-cols-2 gap-2">
|
|
|
+ {shipsPageImages.lanyue.map((img, i) => (
|
|
|
+ <div key={i} className="relative group/img">
|
|
|
+ <img src={img} className="w-full h-20 object-cover rounded border" alt={`Lanyue ${i+1}`} />
|
|
|
+ <label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer">
|
|
|
+ <span className="text-xs text-white">更换</span>
|
|
|
+ <input type="file" className="hidden" accept="image/*" onChange={(e) => handleArrayMediaUpload(e, i, 'lanyue')} />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/* Aurora Image */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs text-vista-darkblue/60 mb-2 font-bold">长江行·极光 (背景图)</label>
|
|
|
+ <div className="relative group/aurora">
|
|
|
+ <img src={shipsPageImages.aurora} className="w-full h-24 object-cover rounded border" alt="Aurora" />
|
|
|
+ <label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover/aurora:opacity-100 transition-opacity cursor-pointer">
|
|
|
+ <span className="text-xs text-white">更换</span>
|
|
|
+ <input type="file" className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, (url) => setShipsPageImages({...shipsPageImages, aurora: url}))} />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ {/* Section 2: Featured Itineraries (Updated for Video) */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <Layout size={16} /> 精选航线 (视频/图片)
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-6 bg-white border-t border-vista-darkblue/10">
|
|
|
+ {itineraries.map((item, idx) => (
|
|
|
+ <div key={item.id} className="border-b pb-4 last:border-0 last:pb-0">
|
|
|
+ <div className="text-xs font-bold text-vista-gold mb-2">航线 {idx + 1}</div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={item.title}
|
|
|
+ onChange={(e) => updateItinerary(idx, 'title', e.target.value)}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="标题"
|
|
|
+ />
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={item.price}
|
|
|
+ onChange={(e) => updateItinerary(idx, 'price', e.target.value)}
|
|
|
+ className="w-1/2 text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="价格"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={item.days}
|
|
|
+ onChange={(e) => updateItinerary(idx, 'days', parseInt(e.target.value) || 0)}
|
|
|
+ className="w-1/2 text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="天数"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={item.route}
|
|
|
+ onChange={(e) => updateItinerary(idx, 'route', e.target.value)}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="路线 (如: 重庆 - 宜昌)"
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Image & Video Upload */}
|
|
|
+ <div className="grid grid-cols-2 gap-2 mt-2">
|
|
|
+ {/* Image */}
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <div className="h-16 bg-gray-100 rounded border overflow-hidden relative">
|
|
|
+ <img src={item.image} className="w-full h-full object-cover" alt="Thumb" />
|
|
|
+ </div>
|
|
|
+ <label className="text-[10px] text-center bg-gray-100 py-1 rounded cursor-pointer hover:bg-gray-200">
|
|
|
+ 更换图片
|
|
|
+ <input type="file" className="hidden" accept="image/*" onChange={(e) => handleArrayMediaUpload(e, idx, 'itinerary_img')} />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ {/* Video */}
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <div className="h-16 bg-gray-100 rounded border overflow-hidden relative flex items-center justify-center">
|
|
|
+ {item.video ? (
|
|
|
+ <video src={item.video} className="w-full h-full object-cover" />
|
|
|
+ ) : (
|
|
|
+ <Video size={20} className="text-gray-400" />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <label className="text-[10px] text-center bg-gray-100 py-1 rounded cursor-pointer hover:bg-gray-200">
|
|
|
+ 上传/更换视频
|
|
|
+ <input type="file" className="hidden" accept="video/*" onChange={(e) => handleArrayMediaUpload(e, idx, 'itinerary_video')} />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ {/* Section 3: Dining */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <Utensils size={16} /> 餐饮板块
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-4 bg-white border-t border-vista-darkblue/10">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={dining.title}
|
|
|
+ onChange={(e) => setDining({...dining, title: e.target.value})}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="标题"
|
|
|
+ />
|
|
|
+ <textarea
|
|
|
+ rows={3}
|
|
|
+ value={dining.description}
|
|
|
+ onChange={(e) => setDining({...dining, description: e.target.value})}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="描述"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={dining.buttonText}
|
|
|
+ onChange={(e) => setDining({...dining, buttonText: e.target.value})}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="按钮文字"
|
|
|
+ />
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-xs text-gray-500">背景图:</span>
|
|
|
+ <img src={dining.image} className="w-16 h-8 object-cover rounded border" alt="Dining BG" />
|
|
|
+ <button onClick={() => diningImgRef.current?.click()} className="text-xs bg-gray-200 px-2 py-1 rounded">更换</button>
|
|
|
+ <input type="file" ref={diningImgRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, (url) => setDining({...dining, image: url}))} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ {/* Section 4: Fleet / Ships */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <Ship size={16} /> 船队介绍 (首页)
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-6 bg-white border-t border-vista-darkblue/10">
|
|
|
+ {ships.map((ship, idx) => (
|
|
|
+ <div key={ship.id} className="border-b pb-4 last:border-0 last:pb-0">
|
|
|
+ <div className="text-xs font-bold text-vista-gold mb-2">游轮 {idx + 1}</div>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={ship.name}
|
|
|
+ onChange={(e) => updateShip(idx, 'name', e.target.value)}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="船名"
|
|
|
+ />
|
|
|
+ <textarea
|
|
|
+ rows={2}
|
|
|
+ value={ship.description}
|
|
|
+ onChange={(e) => updateShip(idx, 'description', e.target.value)}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="描述"
|
|
|
+ />
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <img src={ship.image} className="w-12 h-8 object-cover rounded border" alt="Ship" />
|
|
|
+ <label className="text-xs text-vista-darkblue underline cursor-pointer">
|
|
|
+ 更换图片
|
|
|
+ <input type="file" className="hidden" accept="image/*" onChange={(e) => handleArrayMediaUpload(e, idx, 'ship')} />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ {/* Section 5: Video Section */}
|
|
|
+ <details className="group border border-vista-darkblue/10 rounded-lg overflow-hidden">
|
|
|
+ <summary className="flex items-center gap-2 p-4 bg-vista-darkblue/5 cursor-pointer font-bold text-vista-darkblue text-sm select-none">
|
|
|
+ <Film size={16} /> 视频板块
|
|
|
+ </summary>
|
|
|
+ <div className="p-4 space-y-4 bg-white border-t border-vista-darkblue/10">
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={videoSection.title}
|
|
|
+ onChange={(e) => setVideoSection({...videoSection, title: e.target.value})}
|
|
|
+ className="w-1/2 text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="标题前半部分"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={videoSection.titleItalic}
|
|
|
+ onChange={(e) => setVideoSection({...videoSection, titleItalic: e.target.value})}
|
|
|
+ className="w-1/2 text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="斜体标题"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <textarea
|
|
|
+ rows={3}
|
|
|
+ value={videoSection.description}
|
|
|
+ onChange={(e) => setVideoSection({...videoSection, description: e.target.value})}
|
|
|
+ className="w-full text-xs border p-2 rounded focus:outline-none focus:border-vista-gold"
|
|
|
+ placeholder="描述"
|
|
|
+ />
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-xs text-gray-500">缩略图:</span>
|
|
|
+ <img src={videoSection.thumbnail} className="w-16 h-8 object-cover rounded border" alt="Video Thumb" />
|
|
|
+ <button onClick={() => videoThumbRef.current?.click()} className="text-xs bg-gray-200 px-2 py-1 rounded">更换</button>
|
|
|
+ <input type="file" ref={videoThumbRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, (url) => setVideoSection({...videoSection, thumbnail: url}))} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Reset Button */}
|
|
|
+ <div className="border-t border-vista-darkblue/10 pt-6 mt-4">
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ if(window.confirm('确定要重置所有内容为默认值吗?此操作不可撤销。')) {
|
|
|
+ resetTheme();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className="w-full py-3 text-vista-darkblue/60 text-xs font-bold uppercase tracking-widest hover:text-red-500 transition-colors flex items-center justify-center gap-2"
|
|
|
+ >
|
|
|
+ <RotateCcw size={14} /> 重置为默认内容
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default ThemeSettings;
|