Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
b4369a9de5
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"marscode.chatLanguage": "cn",
|
||||
"marscode.codeCompletionPro": {
|
||||
"enableCodeCompletionPro": true
|
||||
},
|
||||
"marscode.enableInlineCommand": true
|
||||
}
|
|
@ -26,8 +26,7 @@ export class GenDevService {
|
|||
deptStaffRecord: Record<string, Staff[]> = {};
|
||||
terms: Record<TaxonomySlug, Term[]> = {
|
||||
[TaxonomySlug.CATEGORY]: [],
|
||||
[TaxonomySlug.UNIT]: [],
|
||||
[TaxonomySlug.TAG]: [],
|
||||
[TaxonomySlug.TAG]: []
|
||||
};
|
||||
depts: Department[] = [];
|
||||
domains: Department[] = [];
|
||||
|
@ -67,7 +66,7 @@ export class GenDevService {
|
|||
const domains = this.depts.filter((item) => item.isDomain);
|
||||
for (const domain of domains) {
|
||||
await this.createTerms(domain, TaxonomySlug.CATEGORY, depth, count);
|
||||
await this.createTerms(domain, TaxonomySlug.UNIT, depth, count);
|
||||
// await this.createTerms(domain, TaxonomySlug.UNIT, depth, count);
|
||||
}
|
||||
}
|
||||
const termCount = await db.term.count();
|
||||
|
@ -204,6 +203,12 @@ export class GenDevService {
|
|||
const taxonomy = await db.taxonomy.findFirst({
|
||||
where: { slug: taxonomySlug },
|
||||
});
|
||||
|
||||
if (!taxonomy) {
|
||||
throw new Error(`Taxonomy with slug ${taxonomySlug} not found`);
|
||||
}
|
||||
|
||||
this.logger.log(`Creating terms for taxonomy: ${taxonomy.name} (${taxonomy.slug})`);
|
||||
let counter = 1;
|
||||
const createTermTree = async (
|
||||
parentId: string | null,
|
||||
|
|
|
@ -23,6 +23,7 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
|
|||
|
||||
<Form
|
||||
form={form}
|
||||
size="large"
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
|
@ -60,7 +61,7 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
|
|||
loading={isLoading}
|
||||
className="w-full h-10 rounded-lg"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
|
||||
import { Carousel } from '@web/src/components/common/element/Carousel';
|
||||
import LeaderCard from '@web/src/components/common/element/LeaderCard';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import * as tus from 'tus-js-client';
|
||||
interface TusUploadProps {
|
||||
onSuccess?: (response: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
const carouselItems = [
|
||||
{
|
||||
id: 1,
|
||||
image: 'https://th.bing.com/th/id/OIP.PEtRTLQwIX54HGFX5xNDYwHaE7?rs=1&pid=ImgDetMain',
|
||||
title: '自然风光',
|
||||
description: '壮丽的山川河流'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image: 'https://eskipaper.com/images/scenery-pictures-15.jpg',
|
||||
title: '城市景观',
|
||||
description: '现代化的都市风貌'
|
||||
}
|
||||
];
|
||||
const TusUploader: React.FC<TusUploadProps> = ({
|
||||
onSuccess,
|
||||
onError
|
||||
}) => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const handleFileUpload = useCallback((file: File) => {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
setProgress(0);
|
||||
setUploadError(null);
|
||||
// Extract file extension
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: "http://localhost:3000/upload",
|
||||
retryDelays: [0, 1000, 3000, 5000],
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
size: file.size.toString(),
|
||||
mimeType: file.type,
|
||||
extension: extension,
|
||||
modifiedAt: new Date(file.lastModified).toISOString(),
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
||||
setProgress(Number(percentage));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsUploading(false);
|
||||
setProgress(100);
|
||||
onSuccess && onSuccess(upload);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
onError && onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
upload.start();
|
||||
},
|
||||
[onSuccess, onError]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className=' flex flex-col gap-4'>
|
||||
<Carousel
|
||||
slides={carouselItems}
|
||||
|
||||
autoplayDelay={5000}
|
||||
className="shadow-xl"
|
||||
/>
|
||||
<LeaderCard
|
||||
name="张三"
|
||||
title="技术总监"
|
||||
description={
|
||||
"负责公司技术战略规划"
|
||||
}
|
||||
imageUrl="https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
{isUploading && (
|
||||
<div>
|
||||
<progress value={progress} max="100" />
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
<div style={{ color: 'red' }}>
|
||||
上传错误: {uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TusUploader;
|
|
@ -8,33 +8,31 @@ export function Header() {
|
|||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-primary to-primary-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||
<div className="mt-4 text-blue-50">
|
||||
<p className="text-base opacity-90">
|
||||
服务宗旨:畅通诉求渠道 • 促进建设发展 • 提升单位战斗力
|
||||
</p>
|
||||
<div className="mt-2 text-sm opacity-80 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<span>实时跟踪反馈进度</span>
|
||||
<span>保障信息传递安全</span>
|
||||
<span>高效解决实际问题</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||
<div className="mt-4 text-blue-50">
|
||||
<p className="text-base opacity-90">
|
||||
服务宗旨:畅通诉求渠道 • 促进建设发展 • 提升单位战斗力
|
||||
</p>
|
||||
<div className="mt-2 text-sm opacity-80 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<span>实时跟踪反馈进度</span>
|
||||
<span>保障信息传递安全</span>
|
||||
<span>高效解决实际问题</span>
|
||||
</div>
|
||||
<SearchFilters
|
||||
className="mt-4"
|
||||
searchTerm=""
|
||||
filterCategory={null}
|
||||
filterStatus={null}
|
||||
onSearchChange={handleSearch}
|
||||
onCategoryChange={handleFilterChange}
|
||||
onStatusChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<SearchFilters
|
||||
className="mt-4"
|
||||
searchTerm=""
|
||||
filterCategory={null}
|
||||
filterStatus={null}
|
||||
onSearchChange={handleSearch}
|
||||
onCategoryChange={handleFilterChange}
|
||||
onStatusChange={handleFilterChange}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,60 +1,150 @@
|
|||
import { StarIcon } from '@heroicons/react/24/outline';
|
||||
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { Button, Typography, Space, Tooltip } from 'antd';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Letter } from './types';
|
||||
import { getBadgeStyle } from './utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface LetterCardProps {
|
||||
letter: Letter;
|
||||
}
|
||||
|
||||
export function LetterCard({ letter }: LetterCardProps) {
|
||||
const [likes, setLikes] = useState(0);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [views] = useState(Math.floor(Math.random() * 100)); // 模拟浏览量数据
|
||||
|
||||
const handleLike = () => {
|
||||
if (!liked) {
|
||||
setLikes(prev => prev + 1);
|
||||
setLiked(true);
|
||||
toast.success('已点赞!', {
|
||||
icon: <LikeFilled className="text-blue-500" />,
|
||||
className: 'custom-message',
|
||||
|
||||
});
|
||||
} else {
|
||||
setLikes(prev => prev - 1);
|
||||
setLiked(false);
|
||||
toast('已取消点赞', {
|
||||
className: 'custom-message',
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative border-b hover:bg-blue-100/30 p-6 transition-all duration-300 ease-in-out ">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Header Section */}
|
||||
<div className="flex justify-between items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-full p-4 bg-white transition-all duration-300 ease-in-out hover:shadow-lg group"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Title & Priority */}
|
||||
<div className="flex justify-between items-start">
|
||||
<Title level={4} className="!mb-0 flex-1">
|
||||
<a
|
||||
target="_blank"
|
||||
href={`/letters/${letter.id}`}
|
||||
className="text-xl font-semibold text-navy-900 group-hover:text-blue-700 transition-colors hover:underline"
|
||||
target="_blank"
|
||||
className="text-navy-900 transition-all duration-300 relative
|
||||
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-blue-600
|
||||
group-hover:before:w-full before:transition-all before:duration-300
|
||||
group-hover:text-blue-600 group-hover:scale-105 group-hover:drop-shadow-md"
|
||||
>
|
||||
{letter.title}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</Title>
|
||||
{letter.priority && (
|
||||
<Badge type="priority" value={letter.priority} className="ml-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta Information */}
|
||||
<div className="text-sm text-gray-600 flex items-center justify-between">
|
||||
<span className="font-medium text-gray-800">{letter.sender} | {letter.unit}</span>
|
||||
<span>{letter.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Badges Section */}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Badge type="category" value={letter.category} />
|
||||
<Badge type="status" value={letter.status} />
|
||||
{letter.priority && <Badge type="priority" value={letter.priority} />}
|
||||
{/* Meta Info */}
|
||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||
<Space size="middle">
|
||||
<Space>
|
||||
<UserOutlined className="text-gray-400" />
|
||||
<Text strong>{letter.sender}</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
<Space>
|
||||
<BankOutlined className="text-gray-400" />
|
||||
<Text>{letter.unit}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-gray-400" />
|
||||
<Text type="secondary">{letter.date}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Content Preview */}
|
||||
{letter.content && (
|
||||
<p className="text-sm text-gray-700 line-clamp-2 leading-relaxed mt-2">
|
||||
{letter.content}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileTextOutlined className="text-gray-400 mt-1" />
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
className="!mb-3 text-gray-600 flex-1"
|
||||
>
|
||||
{letter.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges & Interactions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Space size="small" wrap className="flex-1">
|
||||
<Badge type="category" value={letter.category} />
|
||||
<Badge type="status" value={letter.status} />
|
||||
</Space>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<EyeOutlined className="text-lg" />
|
||||
<span className="text-sm">{views}</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
title={liked ? '取消点赞' : '点赞'}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type={liked ? 'primary' : 'default'}
|
||||
shape="round"
|
||||
size="small"
|
||||
icon={liked ? <LikeFilled /> : <LikeOutlined />}
|
||||
onClick={handleLike}
|
||||
className={`
|
||||
flex items-center gap-1 px-3 transform transition-all duration-300
|
||||
hover:scale-105 hover:shadow-md
|
||||
${liked ? 'bg-blue-500 hover:bg-blue-600' : 'hover:border-blue-500 hover:text-blue-500'}
|
||||
`}
|
||||
>
|
||||
<span className={liked ? 'text-white' : ''}>{likes}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ type, value }: { type: 'priority' | 'category' | 'status'; value: string }) {
|
||||
function Badge({
|
||||
type,
|
||||
value,
|
||||
className = ''
|
||||
}: {
|
||||
type: 'priority' | 'category' | 'status';
|
||||
value: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${getBadgeStyle(type, value)}
|
||||
transition-all duration-150 ease-in-out transform hover:scale-105
|
||||
transition-all duration-200 ease-in-out transform hover:scale-105
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
|
|
|
@ -22,7 +22,7 @@ export default function LetterListPage() {
|
|||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
||||
<Header />
|
||||
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
||||
<div className="flex-grow">
|
||||
<div className="flex-grow flex flex-col gap-2">
|
||||
{filteredLetters.map((letter) => (
|
||||
<LetterCard key={letter.id} letter={letter} />
|
||||
))}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
||||
import { Leader } from './types';
|
||||
|
||||
interface LeaderCardProps {
|
||||
leader: Leader;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export default function LeaderCard({ leader, isSelected, onSelect }: LeaderCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.005 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`
|
||||
bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100
|
||||
${isSelected
|
||||
? 'ring-2 ring-[#00308F]'
|
||||
: 'hover:shadow-lg hover:border-blue-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{/* Image Container */}
|
||||
<div className="sm:w-48 h-64 sm:h-auto flex-shrink-0">
|
||||
<img
|
||||
src={leader.imageUrl}
|
||||
alt={leader.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{leader.name}
|
||||
</h3>
|
||||
<span className="px-3 py-1 text-sm font-medium bg-blue-50 text-[#00308F] rounded-full">
|
||||
{leader.rank}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{leader.division}</p>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{leader.email}
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
{leader.phone}
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
{leader.office}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="mt-auto w-full sm:w-auto flex items-center justify-center gap-2
|
||||
bg-[#00308F] text-white py-3 px-6 rounded-lg
|
||||
hover:bg-[#002070] transition-all duration-300
|
||||
focus:outline-none focus:ring-2 focus:ring-[#00308F] focus:ring-opacity-50
|
||||
transform hover:-translate-y-0.5"
|
||||
>
|
||||
<PaperAirplaneIcon className="w-5 h-5" />
|
||||
Compose Letter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -2,9 +2,17 @@ import { FunnelIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
|||
import { leaders } from "./mock";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Leader } from "./types";
|
||||
import { Input, Select } from "antd";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Filter() {
|
||||
const [selectedLeader, setSelectedLeader] = useState<Leader | null>(null);
|
||||
const { Search } = Input;
|
||||
|
||||
interface FilterProps {
|
||||
onSearch?: (query: string) => void;
|
||||
onDivisionChange?: (division: string) => void;
|
||||
}
|
||||
|
||||
export default function Filter({ onSearch, onDivisionChange }: FilterProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedDivision, setSelectedDivision] = useState<string>('all');
|
||||
|
||||
|
@ -12,30 +20,51 @@ export default function Filter() {
|
|||
return ['all', ...new Set(leaders.map(leader => leader.division))];
|
||||
}, []);
|
||||
|
||||
return <div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or rank..."
|
||||
className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] focus:border-transparent"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
||||
<select
|
||||
className="pl-10 pr-8 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] appearance-none bg-white"
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
onSearch?.(value);
|
||||
};
|
||||
|
||||
const handleDivisionChange = (value: string) => {
|
||||
setSelectedDivision(value);
|
||||
onDivisionChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col md:flex-row gap-4 mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Search
|
||||
placeholder="Search by name or rank..."
|
||||
allowClear
|
||||
enterButton={
|
||||
<div className="flex items-center gap-2">
|
||||
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||
<span>Search</span>
|
||||
</div>
|
||||
}
|
||||
size="large"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
size="large"
|
||||
value={selectedDivision}
|
||||
onChange={(e) => setSelectedDivision(e.target.value)}
|
||||
>
|
||||
{divisions.map(division => (
|
||||
<option key={division} value={division}>
|
||||
{division === 'all' ? 'All Divisions' : division}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onChange={handleDivisionChange}
|
||||
suffixIcon={<FunnelIcon className="w-5 h-5 text-gray-400" />}
|
||||
className="w-full md:w-64"
|
||||
options={divisions.map(division => ({
|
||||
value: division,
|
||||
label: division === 'all' ? 'All Divisions' : division,
|
||||
}))}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
export default function Header() {
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white p-6">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* 主标题 */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-wider">
|
||||
信件投递入口
|
||||
</h1>
|
||||
<p className="mt-2 text-blue-100 text-lg">
|
||||
保护您隐私的信件传输平台
|
||||
</p>
|
||||
</div>
|
||||
return <header className="bg-gradient-to-r from-primary to-primary-50 text-white p-6">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* 主标题 */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-wider">
|
||||
信件投递入口
|
||||
</h1>
|
||||
<p className="mt-2 text-blue-100 text-lg">
|
||||
保护您隐私的信件传输平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 隐私保护说明 */}
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
|
@ -61,13 +60,11 @@ export default function Header() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 隐私承诺 */}
|
||||
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
|
||||
<p>
|
||||
我们承诺:您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
{/* 隐私承诺 */}
|
||||
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
|
||||
<p>您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FunnelIcon, MagnifyingGlassIcon, PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Leader } from './types';
|
||||
import { leaders } from './mock';
|
||||
import Header from './header';
|
||||
import Filter from './filter';
|
||||
import LeaderCard from './LeaderCard';
|
||||
|
||||
|
||||
|
||||
export default function WriteLetterPage() {
|
||||
|
@ -26,104 +27,57 @@ export default function WriteLetterPage() {
|
|||
}, [searchQuery, selectedDivision]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200">
|
||||
<Header></Header>
|
||||
{/* 搜索和筛选区域 */}
|
||||
<div className=" px-4 py-8">
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100">
|
||||
<Header />
|
||||
|
||||
<Filter></Filter>
|
||||
{/* Modified Leader Cards Grid */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{filteredLeaders.map((leader) => (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Filter />
|
||||
|
||||
<AnimatePresence>
|
||||
{filteredLeaders.length > 0 ? (
|
||||
<motion.div
|
||||
key={leader.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.005 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`
|
||||
bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100
|
||||
${selectedLeader?.id === leader.id
|
||||
? 'ring-2 ring-[#00308F]'
|
||||
: 'hover:shadow-lg hover:border-blue-100'
|
||||
}
|
||||
`}
|
||||
className="grid grid-cols-1 gap-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{/* Image Container */}
|
||||
<div className="sm:w-48 h-64 sm:h-auto flex-shrink-0">
|
||||
<img
|
||||
src={leader.imageUrl}
|
||||
alt={leader.name}
|
||||
className="w-full h-full object-cover"
|
||||
{filteredLeaders.map((leader) => (
|
||||
<LeaderCard
|
||||
key={leader.id}
|
||||
leader={leader}
|
||||
isSelected={selectedLeader?.id === leader.id}
|
||||
onSelect={() => setSelectedLeader(leader)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="text-center py-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="inline-flex flex-col items-center gap-4">
|
||||
<svg
|
||||
className="w-16 h-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{leader.name}
|
||||
</h3>
|
||||
<span className="px-3 py-1 text-sm font-medium bg-blue-50 text-[#00308F] rounded-full">
|
||||
{leader.rank}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{leader.division}</p>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{leader.email}
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
{leader.phone}
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
{leader.office}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLeader(leader)}
|
||||
className="mt-auto w-full sm:w-auto flex items-center justify-center gap-2
|
||||
bg-[#00308F] text-white py-3 px-6 rounded-lg
|
||||
hover:bg-[#002070] transition-all duration-300
|
||||
focus:outline-none focus:ring-2 focus:ring-[#00308F] focus:ring-opacity-50
|
||||
transform hover:-translate-y-0.5"
|
||||
>
|
||||
<PaperAirplaneIcon className="w-5 h-5" />
|
||||
Compose Letter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</svg>
|
||||
<p className="text-gray-600 text-lg">
|
||||
No leaders found matching your search criteria
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{/* 无结果提示 */}
|
||||
{filteredLeaders.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 text-lg">
|
||||
No leaders found matching your search criteria
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { HTMLMotionProps, motion } from 'framer-motion';
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
import { cn } from '@web/src/utils/classname';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' |
|
||||
|
@ -162,7 +162,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
ref={ref}
|
||||
title={title}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
className={twMerge(
|
||||
// Base styles
|
||||
'relative inline-flex items-center justify-center font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
|
@ -187,7 +187,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
>
|
||||
{isLoading && (
|
||||
<LoadingOutlined
|
||||
className={cn(
|
||||
className={twMerge(
|
||||
"animate-spin",
|
||||
!isIconOnly && "mr-2",
|
||||
iconSizes[size]
|
||||
|
@ -196,7 +196,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
/>
|
||||
)}
|
||||
{!isLoading && leftIcon && (
|
||||
<span className={cn(
|
||||
<span className={twMerge(
|
||||
"inline-flex",
|
||||
!isIconOnly && "mr-2",
|
||||
iconSizes[size]
|
||||
|
@ -206,7 +206,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
)}
|
||||
{children}
|
||||
{!isLoading && rightIcon && !isIconOnly && (
|
||||
<span className={cn(
|
||||
<span className={twMerge(
|
||||
"inline-flex ml-2",
|
||||
iconSizes[size]
|
||||
)}>
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import React from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type LeaderCardProps = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
|
||||
onClick?: () => void; // 添加整个卡片的点击事件
|
||||
};
|
||||
|
||||
const LeaderCard: React.FC<LeaderCardProps> = ({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
imageUrl,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="group flex bg-white rounded-2xl shadow-md hover:shadow-2xl transition-all duration-300 ease-out max-w-2xl border border-gray-100 hover:border-blue-100 cursor-pointer overflow-hidden"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Section */}
|
||||
<div className="relative flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
className="w-44 h-64 object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{
|
||||
aspectRatio: "3/4",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex flex-col justify-between p-5 flex-1">
|
||||
<div className="space-y-3">
|
||||
{/* Name and Title */}
|
||||
<div className="transform transition-transform duration-300 group-hover:translate-x-2">
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-1 tracking-tight group-hover:text-primary-600 transition-colors">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-base font-medium text-primary-600 tracking-wide group-hover:text-blue-500">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-base leading-relaxed line-clamp-4 font-light group-hover:text-gray-700">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Decorative Element */}
|
||||
<div className="mt-4">
|
||||
<div className="h-0.5 w-8 bg-blue-600 rounded-full transform origin-left transition-all duration-300 group-hover:w-16 group-hover:bg-gradient-to-r from-blue-600 to-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default LeaderCard;
|
|
@ -19,10 +19,10 @@ export function Footer() {
|
|||
</div>
|
||||
<h3 className="text-white font-bold text-lg tracking-wide mb-2
|
||||
drop-shadow-md">
|
||||
美国空军官方领导机关信箱
|
||||
官方领导机关信箱
|
||||
</h3>
|
||||
<p className="text-tertiary-300 text-sm">
|
||||
为美国空军提供安全可靠的通信服务
|
||||
为提供安全可靠的通信服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,41 +1,52 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
import { useNavItem } from "./useNavItem";
|
||||
|
||||
export default function Navigation() {
|
||||
const { navItems } = useNavItem()
|
||||
return <nav className="mt-4 rounded-lg bg-[#0B1A32]/90">
|
||||
<div className="flex items-center justify-between px-6 py-1">
|
||||
<div className="flex space-x-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `
|
||||
group relative px-4 py-3
|
||||
transition-all duration-300 ease-out
|
||||
|
||||
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
|
||||
`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="relative z-10 transition-colors group-hover:text-white">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={`
|
||||
absolute bottom-1.5 left-1/2 h-[2px] bg-white
|
||||
transition-all duration-300 ease-out
|
||||
transform -translate-x-1/2
|
||||
${isActive
|
||||
? 'w-12 opacity-100'
|
||||
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
|
||||
}
|
||||
`} />
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
interface NavigationProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Navigation({ className }: NavigationProps) {
|
||||
const { navItems } = useNavItem();
|
||||
|
||||
return (
|
||||
<nav className={`mt-4 rounded-lg bg-[#0B1A32]/90 ${className}`}>
|
||||
<div className="flex flex-col md:flex-row items-stretch">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center px-6 py-1 w-full overflow-x-auto scrollbar-thin scrollbar-thumb-[#00308F] scrollbar-track-transparent">
|
||||
<div className="flex space-x-4 min-w-max">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `
|
||||
group relative px-4 py-3
|
||||
transition-all duration-300 ease-out
|
||||
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
|
||||
`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="relative z-10 transition-colors group-hover:text-white flex items-center gap-2">
|
||||
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
absolute bottom-1.5 left-1/2 h-[2px] bg-white
|
||||
transition-all duration-300 ease-out
|
||||
transform -translate-x-1/2
|
||||
${isActive
|
||||
? 'w-12 opacity-100'
|
||||
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
</nav>)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export interface MenuItemType {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
|
@ -1,16 +1,7 @@
|
|||
import { api } from "@nice/client";
|
||||
import { TaxonomySlug } from "@nice/common";
|
||||
import { useMemo } from "react";
|
||||
// export const NAV_ITEMS = [
|
||||
|
||||
// { to: "/write-letter", label: "咨询求助" },
|
||||
// { to: "/write-letter", label: "解难帮困" },
|
||||
// { to: "/write-letter", label: "需求提报" },
|
||||
// { to: "/write-letter", label: "意见建议" },
|
||||
// { to: "/write-letter", label: "问题反映" },
|
||||
// { to: "/write-letter", label: "举报投诉" },
|
||||
|
||||
// ] as const;
|
||||
export function useNavItem() {
|
||||
const { data } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
|
@ -21,7 +12,7 @@ export function useNavItem() {
|
|||
const navItems = useMemo(() => {
|
||||
// 定义固定的导航项
|
||||
const staticItems = {
|
||||
letterList: { to: "/letter-list", label: "公开信件" },
|
||||
letterList: { to: "/", label: "公开信件" },
|
||||
letterProgress: { to: "/letter-progress", label: "进度查询" },
|
||||
help: { to: "/help", label: "使用帮助" }
|
||||
};
|
||||
|
|
|
@ -1,106 +1,214 @@
|
|||
// ... existing imports ...
|
||||
|
||||
import { UserCircleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, ArrowLeftStartOnRectangleIcon } from "@heroicons/react/24/outline";
|
||||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useCallback, useMemo } from "react";
|
||||
import { Avatar } from "../../common/element/Avatar";
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
QuestionCircleOutlined,
|
||||
LogoutOutlined
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuItemType } from "./types";
|
||||
import { Spin } from "antd";
|
||||
|
||||
// USAF Theme Constants
|
||||
const USAF_THEME = {
|
||||
colors: {
|
||||
primary: '#00538E', // Air Force Blue
|
||||
secondary: '#003F6A', // Darker Blue
|
||||
accent: '#B22234', // Air Force Red
|
||||
background: '#F6F9FC', // Light Blue tint
|
||||
hover: '#E6EEF5', // Lighter hover state
|
||||
border: '#E5EDF5', // Light Border
|
||||
text: {
|
||||
primary: '#00538E',
|
||||
secondary: '#4A5568',
|
||||
light: '#718096',
|
||||
danger: '#B22234'
|
||||
}
|
||||
},
|
||||
shadows: {
|
||||
sm: '0 2px 4px 0 rgba(0, 83, 142, 0.08)',
|
||||
md: '0 4px 8px -1px rgba(0, 83, 142, 0.15), 0 2px 4px -1px rgba(0, 83, 142, 0.08)',
|
||||
lg: '0 12px 20px -3px rgba(0, 83, 142, 0.15), 0 4px 8px -2px rgba(0, 83, 142, 0.1)',
|
||||
hover: '0 6px 12px -2px rgba(0, 83, 142, 0.12), 0 3px 6px -1px rgba(0, 83, 142, 0.07)'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const menuVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95, y: -10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function UserMenu() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const menuItems = [
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const menuItems: MenuItemType[] = useMemo(() => [
|
||||
{
|
||||
icon: <UserCircleIcon className="w-5 h-5" />,
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: '个人信息',
|
||||
action: () => { },
|
||||
color: 'text-primary-600'
|
||||
},
|
||||
{
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: '设置',
|
||||
action: () => { },
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
icon: <QuestionMarkCircleIcon className="w-5 h-5" />,
|
||||
icon: <QuestionCircleOutlined className="text-lg" />,
|
||||
label: '帮助',
|
||||
action: () => { },
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
icon: <ArrowLeftStartOnRectangleIcon className="w-5 h-5" />,
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: '注销',
|
||||
action: () => logout(),
|
||||
color: 'text-red-600'
|
||||
},
|
||||
];
|
||||
], [logout]);
|
||||
|
||||
const handleMenuItemClick = useCallback((action: () => void) => {
|
||||
action();
|
||||
setShowMenu(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-10 h-10">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="relative rounded-full focus:outline-none
|
||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
focus:ring-offset-[#13294B]"
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="relative rounded-full focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||
focus:ring-offset-white transition-all duration-200 ease-in-out"
|
||||
>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white/80 hover:ring-blue-400
|
||||
transition-all duration-300"
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg"
|
||||
/>
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-emerald-500 ring-2 ring-white
|
||||
shadow-sm transition-transform duration-200
|
||||
ease-in-out hover:scale-110"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-green-500 ring-2 ring-white" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right
|
||||
bg-white rounded-xl shadow-lg ring-1 ring-black/5
|
||||
overflow-hidden"
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
bg-white rounded-xl overflow-hidden shadow-lg
|
||||
border border-[#E5EDF5]"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{user?.showname}
|
||||
</h4>
|
||||
<p className="text-xs text-tertiary-300 mt-1">
|
||||
{user?.username}
|
||||
</p>
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] "
|
||||
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<motion.button
|
||||
<button
|
||||
key={index}
|
||||
whileHover={{ x: 4, backgroundColor: '#F3F4F6' }}
|
||||
onClick={item.action}
|
||||
className={`flex items-center gap-3 w-full p-2.5
|
||||
rounded-lg text-sm font-medium
|
||||
transition-colors duration-200
|
||||
${item.color} hover:bg-gray-100`}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
text-sm font-medium rounded-lg transition-all
|
||||
focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/20
|
||||
group relative overflow-hidden
|
||||
active:scale-[0.99]
|
||||
${item.label === '注销'
|
||||
? 'text-[#B22234] hover:bg-red-50/80 hover:text-red-700'
|
||||
: 'text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className={`w-5 h-5 flex items-center justify-center
|
||||
transition-all duration-200 ease-in-out
|
||||
group-hover:scale-110 group-hover:rotate-6
|
||||
group-hover:translate-x-0.5 ${item.label === '注销'
|
||||
? 'group-hover:text-red-600'
|
||||
: 'group-hover:text-[#003F6A]'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</motion.button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { CourseDto } from "@nice/common";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
import { CourseHeader } from "./CourseHeader";
|
||||
import { CourseStats } from "./CourseStats";
|
||||
import { Popover } from "@web/src/components/presentation/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CourseCardProps {
|
||||
course: CourseDto;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
|
||||
return (
|
||||
<Card onClick={onClick} className="w-full max-w-sm">
|
||||
<CourseHeader
|
||||
title={course.title}
|
||||
subTitle={course.subTitle}
|
||||
thumbnail={course.thumbnail}
|
||||
level={course.level}
|
||||
numberOfStudents={course.numberOfStudents}
|
||||
publishedAt={course.publishedAt}
|
||||
/>
|
||||
<CourseStats
|
||||
averageRating={course.averageRating}
|
||||
numberOfReviews={course.numberOfReviews}
|
||||
completionRate={course.completionRate}
|
||||
totalDuration={course.totalDuration}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import {
|
||||
CalendarIcon,
|
||||
UserGroupIcon,
|
||||
AcademicCapIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CourseLevelLabel } from "@nice/common";
|
||||
|
||||
interface CourseHeaderProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
thumbnail?: string;
|
||||
level?: string;
|
||||
numberOfStudents?: number;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
export const CourseHeader = ({
|
||||
title,
|
||||
subTitle,
|
||||
thumbnail,
|
||||
level,
|
||||
numberOfStudents,
|
||||
publishedAt,
|
||||
}: CourseHeaderProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{thumbnail && (
|
||||
<div className="relative h-48 w-full">
|
||||
<img src={thumbnail} alt={title} className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
|
||||
{subTitle && <p className="mt-2 text-gray-600 ">{subTitle}</p>}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-tertiary-300 ">
|
||||
{level && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AcademicCapIcon className="h-4 w-4" />
|
||||
<span>{CourseLevelLabel[level]}</span>
|
||||
</div>
|
||||
)}
|
||||
{numberOfStudents !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserGroupIcon className="h-4 w-4" />
|
||||
<span>{numberOfStudents} 人学习中</span>
|
||||
</div>
|
||||
)}
|
||||
{publishedAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>{publishedAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,59 +0,0 @@
|
|||
import { StarIcon, ChartBarIcon, ClockIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
interface CourseStatsProps {
|
||||
averageRating?: number;
|
||||
numberOfReviews?: number;
|
||||
completionRate?: number;
|
||||
totalDuration?: number;
|
||||
}
|
||||
|
||||
export const CourseStats = ({
|
||||
averageRating,
|
||||
numberOfReviews,
|
||||
completionRate,
|
||||
totalDuration,
|
||||
}: CourseStatsProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 ">
|
||||
{averageRating !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StarIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
{numberOfReviews} 观看量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{completionRate !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChartBarIcon className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
完成率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDuration !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
|
||||
</div>
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
总时长
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import { CourseDetailProvider } from "./CourseDetailContext";
|
||||
import CourseDetailLayout from "./CourseDetailLayout";
|
||||
|
||||
export default function CourseDetail({ id }: { id?: string }) {
|
||||
const iframeStyle = {
|
||||
width: "50%",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CourseDetailProvider editId={id}>
|
||||
<CourseDetailLayout></CourseDetailLayout>
|
||||
</CourseDetailProvider>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
import { api, useCourse } from "@nice/client";
|
||||
import { courseDetailSelect, CourseDto } from "@nice/common";
|
||||
import React, { createContext, ReactNode, useState } from "react";
|
||||
import { string } from "zod";
|
||||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
course?: CourseDto;
|
||||
selectedLectureId?: string | undefined;
|
||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isLoading?: boolean;
|
||||
isHeaderVisible: boolean; // 新增
|
||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||
}
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
}
|
||||
export const CourseDetailContext =
|
||||
createContext<CourseDetailContextType | null>(null);
|
||||
export function CourseDetailProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||
api.course.findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
include: {
|
||||
sections: { include: { lectures: true } },
|
||||
enrollments: true,
|
||||
},
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||
return (
|
||||
<CourseDetailContext.Provider
|
||||
value={{
|
||||
editId,
|
||||
course,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
isLoading,
|
||||
isHeaderVisible,
|
||||
setIsHeaderVisible,
|
||||
}}>
|
||||
{children}
|
||||
</CourseDetailContext.Provider>
|
||||
);
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Course } from "@nice/common";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import CourseDetailSkeleton from "../CourseDetailSkeleton";
|
||||
import CourseDetailNavBar from "./CourseDetailNavBar";
|
||||
interface CourseDetailProps {
|
||||
course: Course;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailDescription: React.FC<CourseDetailProps> = ({
|
||||
course,
|
||||
isLoading,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<CourseDetailNavBar></CourseDetailNavBar>
|
||||
|
||||
<div className="w-[80%] mx-auto px-4 py-8">
|
||||
{isLoading || !course ? (
|
||||
<CourseDetailSkeleton />
|
||||
) : (
|
||||
<CourseDetailSkeleton />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,47 +0,0 @@
|
|||
import { NavBar } from "@web/src/components/presentation/NavBar";
|
||||
import { HomeIcon, BellIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
MagnifyingGlassIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export default function CourseDetailNavBar() {
|
||||
const navItems = [
|
||||
{
|
||||
id: "search",
|
||||
icon: <MagnifyingGlassIcon className="w-5 h-5" />,
|
||||
label: "搜索",
|
||||
},
|
||||
{
|
||||
id: "overview",
|
||||
icon: <HomeIcon className="w-5 h-5" />,
|
||||
label: "概述",
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
icon: <DocumentTextIcon className="w-5 h-5" />,
|
||||
label: "备注",
|
||||
},
|
||||
{
|
||||
id: "announcements",
|
||||
icon: <BellIcon className="w-5 h-5" />,
|
||||
label: "公告",
|
||||
},
|
||||
{
|
||||
id: "reviews",
|
||||
icon: <StarIcon className="w-5 h-5" />,
|
||||
label: "评价",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className=" bg-gray-50">
|
||||
<NavBar
|
||||
items={navItems}
|
||||
defaultSelected="overview"
|
||||
onSelect={(id) => console.log("Selected:", id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { CourseDetailContext } from "../../CourseDetailContext";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function Overview() {
|
||||
const { course } = useContext(CourseDetailContext);
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
{/* 课程描述 */}
|
||||
<div className="prose max-w-none">
|
||||
<p>{course?.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 学习目标 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course?.objectives.map((objective, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span>{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合人群 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course?.audiences.map((audience, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<span>{audience}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 课程要求 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
{course?.requirements.map((requirement, index) => (
|
||||
<li key={index}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 可获得技能 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course?.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
// components/CourseDetailDisplayArea.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import React from "react";
|
||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription";
|
||||
import { Course } from "@nice/common";
|
||||
|
||||
interface CourseDetailDisplayAreaProps {
|
||||
course: Course;
|
||||
videoSrc?: string;
|
||||
videoPoster?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailDisplayArea: React.FC<
|
||||
CourseDetailDisplayAreaProps
|
||||
> = ({ course, videoSrc, videoPoster, isLoading = false }) => {
|
||||
// 创建滚动动画效果
|
||||
const { scrollY } = useScroll();
|
||||
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 固定的视频区域 */}
|
||||
{/* 移除 sticky 定位,让视频区域随页面滚动 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
opacity: videoOpacity,
|
||||
}}
|
||||
className="w-full bg-black">
|
||||
<div className=" w-full ">
|
||||
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 课程内容区域 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="w-full">
|
||||
<CourseDetailDescription
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseDetailDisplayArea;
|
|
@ -1,57 +0,0 @@
|
|||
// components/Header.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
export const CourseDetailHeader = () => {
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
const [lastScrollY, setLastScrollY] = useState(0);
|
||||
const { course, isHeaderVisible, setIsHeaderVisible } =
|
||||
useContext(CourseDetailContext);
|
||||
useEffect(() => {
|
||||
const updateHeader = () => {
|
||||
const current = scrollY.get();
|
||||
const direction = current > lastScrollY ? "down" : "up";
|
||||
|
||||
if (direction === "down" && current > 100) {
|
||||
setIsHeaderVisible(false);
|
||||
} else if (direction === "up") {
|
||||
setIsHeaderVisible(true);
|
||||
}
|
||||
|
||||
setLastScrollY(current);
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 来优化性能
|
||||
const unsubscribe = scrollY.on("change", () => {
|
||||
requestAnimationFrame(updateHeader);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [lastScrollY, scrollY, setIsHeaderVisible]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: isHeaderVisible ? 0 : -100 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
||||
<div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-white text-xl ">{course?.title}</h1>
|
||||
</div>
|
||||
<nav className="flex items-center space-x-4">
|
||||
{/* 添加你的导航项目 */}
|
||||
<button className="px-4 py-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors">
|
||||
开始学习
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseDetailHeader;
|
|
@ -1,48 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useContext, useState } from "react";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
|
||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||
import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader";
|
||||
|
||||
export default function CourseDetailLayout() {
|
||||
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
|
||||
const handleLectureClick = (lectureId: string) => {
|
||||
setSelectedLectureId(lectureId);
|
||||
};
|
||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<CourseDetailHeader /> {/* 添加 Header 组件 */}
|
||||
{/* 主内容区域 */}
|
||||
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
||||
<div className="pt-16">
|
||||
{" "}
|
||||
{/* 添加这个包装 div */}
|
||||
<motion.div
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "75%" : "100%",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative">
|
||||
<CourseDetailDisplayArea
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
videoSrc="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
|
||||
videoPoster="https://picsum.photos/800/450"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={course?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import {
|
||||
SkeletonItem,
|
||||
SkeletonSection,
|
||||
} from "@web/src/components/presentation/Skeleton";
|
||||
|
||||
export const CourseDetailSkeleton = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 标题骨架屏 */}
|
||||
<div className="space-y-4">
|
||||
<SkeletonItem className="h-9 w-3/4" />
|
||||
<SkeletonItem className="h-6 w-1/2" delay={0.2} />
|
||||
</div>
|
||||
|
||||
{/* 描述骨架屏 */}
|
||||
<SkeletonSection items={2} />
|
||||
|
||||
{/* 学习目标骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 适合人群骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 技能骨架屏 */}
|
||||
<div>
|
||||
<SkeletonItem className="h-6 w-32 mb-4" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonItem
|
||||
key={i}
|
||||
className="h-8 w-20 rounded-full"
|
||||
delay={i * 0.2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseDetailSkeleton;
|
|
@ -1,19 +0,0 @@
|
|||
import { BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
interface CollapsedButtonProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CollapsedButton: React.FC<CollapsedButtonProps> = ({
|
||||
onToggle,
|
||||
}) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onToggle}
|
||||
className="p-2 bg-white rounded-l-lg shadow-lg hover:bg-gray-100">
|
||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
);
|
|
@ -1,105 +0,0 @@
|
|||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
PlayCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { SyllabusHeader } from "./SyllabusHeader";
|
||||
import { SectionItem } from "./SectionItem";
|
||||
import { CollapsedButton } from "./CollapsedButton";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[];
|
||||
onLectureClick?: (lectureId: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const { isHeaderVisible } = useContext(CourseDetailContext);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter((id) => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{/* 收起时的悬浮按钮 */}
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||
<CollapsedButton onToggle={onToggle} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isOpen ? "25%" : "0",
|
||||
right: 0,
|
||||
top: isHeaderVisible ? "64px" : "0",
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="h-full flex flex-col">
|
||||
<SyllabusHeader onToggle={onToggle} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => (
|
||||
<SectionItem
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[
|
||||
section.id
|
||||
] = el)
|
||||
}
|
||||
section={section}
|
||||
isExpanded={expandedSections.includes(
|
||||
section.id
|
||||
)}
|
||||
onToggle={toggleSection}
|
||||
onLectureClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,36 +0,0 @@
|
|||
// components/CourseSyllabus/LectureItem.tsx
|
||||
|
||||
import { Lecture } from "@nice/common";
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ClockIcon, PlayCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface LectureItemProps {
|
||||
lecture: Lecture;
|
||||
onClick: (lectureId: string) => void;
|
||||
}
|
||||
|
||||
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||
lecture,
|
||||
onClick,
|
||||
}) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
||||
onClick={() => onClick(lecture.id)}>
|
||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-tertiary-300 mt-1">
|
||||
{lecture.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-tertiary-300">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>{lecture.duration}分钟</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
|
@ -1,68 +0,0 @@
|
|||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import { LectureItem } from "./LectureItem";
|
||||
|
||||
// components/CourseSyllabus/SectionItem.tsx
|
||||
interface SectionItemProps {
|
||||
section: SectionDto;
|
||||
isExpanded: boolean;
|
||||
onToggle: (sectionId: string) => void;
|
||||
onLectureClick: (lectureId: string) => void;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||
({ section, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => onToggle(section.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第{Math.floor(section.order)}章
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-tertiary-300">
|
||||
{section.totalLectures}节课 ·{" "}
|
||||
{Math.floor(section.totalDuration / 60)}分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-tertiary-300" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t">
|
||||
{section.lectures.map((lecture) => (
|
||||
<LectureItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
onClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
|
@ -1,18 +0,0 @@
|
|||
// components/CourseSyllabus/SyllabusHeader.tsx
|
||||
import React from "react";
|
||||
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
interface SyllabusHeaderProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const SyllabusHeader: React.FC<SyllabusHeaderProps> = ({ onToggle }) => (
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
export * from "./CourseSyllabus";
|
|
@ -1,29 +0,0 @@
|
|||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
interface CourseObjectivesProps {
|
||||
objectives: string[];
|
||||
title?: string;
|
||||
}
|
||||
const CourseObjectives: React.FC<CourseObjectivesProps> = ({
|
||||
objectives,
|
||||
title = "您将会学到"
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h2 className="text-xl font-bold mb-4">{title}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{objectives.map((objective, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start space-x-3"
|
||||
>
|
||||
<CheckOutlined></CheckOutlined>
|
||||
<span className="text-gray-700">{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseObjectives;
|
|
@ -1,110 +0,0 @@
|
|||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
// import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
|
||||
// import { api, useCourse } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
// 定义课程表单验证 Schema
|
||||
const courseSchema = z.object({
|
||||
title: z.string().min(1, "课程标题不能为空"),
|
||||
subTitle: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
thumbnail: z.string().url().nullish(),
|
||||
// level: z.nativeEnum(CourseLevel),
|
||||
requirements: z.array(z.string()).nullish(),
|
||||
objectives: z.array(z.string()).nullish(),
|
||||
});
|
||||
export type CourseFormData = z.infer<typeof courseSchema>;
|
||||
interface CourseEditorContextType {
|
||||
onSubmit: SubmitHandler<CourseFormData>;
|
||||
editId?: string; // 添加 editId
|
||||
part?: string;
|
||||
// course?: CourseDto;
|
||||
}
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
part?: string;
|
||||
}
|
||||
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
||||
export function CourseFormProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
// const { create, update } = useCourse()
|
||||
// const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
|
||||
const navigate = useNavigate();
|
||||
const methods = useForm<CourseFormData>({
|
||||
resolver: zodResolver(courseSchema),
|
||||
defaultValues: {
|
||||
// level: CourseLevel.BEGINNER,
|
||||
requirements: [],
|
||||
objectives: [],
|
||||
},
|
||||
});
|
||||
// useEffect(() => {
|
||||
// if (course) {
|
||||
// const formData = {
|
||||
// title: course.title,
|
||||
// subTitle: course.subTitle,
|
||||
// description: course.description,
|
||||
// thumbnail: course.thumbnail,
|
||||
// level: course.level,
|
||||
// requirements: course.requirements,
|
||||
// objectives: course.objectives,
|
||||
// status: course.status,
|
||||
// };
|
||||
// methods.reset(formData as any);
|
||||
// }
|
||||
// }, [course, methods]);
|
||||
const onSubmit: SubmitHandler<CourseFormData> = async (
|
||||
data: CourseFormData
|
||||
) => {
|
||||
try {
|
||||
if (editId) {
|
||||
// await update.mutateAsync({
|
||||
// where: { id: editId },
|
||||
// data: {
|
||||
// ...data
|
||||
// }
|
||||
// })
|
||||
toast.success("课程更新成功!");
|
||||
} else {
|
||||
// const result = await create.mutateAsync({
|
||||
// data: {
|
||||
// status: CourseStatus.DRAFT,
|
||||
// ...data
|
||||
// }
|
||||
// })
|
||||
// navigate(`/course/${result.id}/editor`, { replace: true });
|
||||
toast.success("课程创建成功!");
|
||||
}
|
||||
methods.reset(data);
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
toast.error("操作失败,请重试!");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<CourseEditorContext.Provider
|
||||
value={{
|
||||
onSubmit,
|
||||
editId,
|
||||
// course
|
||||
}}>
|
||||
<FormProvider {...methods}>{children}</FormProvider>
|
||||
</CourseEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useCourseEditor = () => {
|
||||
const context = useContext(CourseEditorContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCourseEditor must be used within CourseFormProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
||||
|
||||
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
||||
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
||||
import { convertToOptions } from '@nice/client';
|
||||
import { CourseFormData } from '../context/CourseEditorContext';
|
||||
import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
||||
import { FormQuillInput } from '@web/src/components/common/form/FormQuillInput';
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
{/* <FormInput viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" /> */}
|
||||
{/* <FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" /> */}
|
||||
<FormQuillInput maxLength={400} name="description" label="课程描述" placeholder="请输入课程描述"></FormQuillInput>
|
||||
|
||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { useState } from "react";
|
||||
import { PlusIcon, } from "@heroicons/react/24/outline";
|
||||
import { Section, UUIDGenerator } from "@nice/common";
|
||||
import SectionFormList from "./SectionFormList";
|
||||
|
||||
const CourseContentFormHeader = () =>
|
||||
<div className="mb-8 bg-blue-50 p-4 rounded-lg">
|
||||
<h2 className="text-xl font-semibold text-blue-800 mb-2">创建您的课程大纲</h2>
|
||||
<p className="text-primary-600">
|
||||
通过组织清晰的章节和课时,帮助学员更好地学习。建议:
|
||||
</p>
|
||||
<ul className="mt-2 text-primary-600 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
</ul>
|
||||
</div>
|
||||
const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-tertiary-300">
|
||||
<PlusIcon className="mx-auto h-12 w-12 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function CourseContentForm() {
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const addSection = () => {
|
||||
setSections(prev => [...prev, {
|
||||
id: UUIDGenerator.generate(),
|
||||
title: '新章节',
|
||||
lectures: []
|
||||
}]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<CourseContentFormHeader />
|
||||
<div className="space-y-4">
|
||||
{sections.length === 0 ? (
|
||||
<CourseSectionEmpty />
|
||||
) : (
|
||||
<SectionFormList
|
||||
sections={sections}
|
||||
setSections={setSections}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
rounded="xl"
|
||||
onClick={addSection}
|
||||
leftIcon={<PlusIcon></PlusIcon>}
|
||||
className="mt-4 bg-blue-500 hover:bg-blue-600 text-white"
|
||||
>
|
||||
添加新章节
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { CourseFormData } from "../context/CourseEditorContext";
|
||||
|
||||
export function CourseGoalForm() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormArrayField name="requirements" label="前置要求" placeholder="添加要求"></FormArrayField>
|
||||
<FormArrayField name="objectives" label="学习目标" placeholder="添加目标"></FormArrayField>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function CourseSettingForm() {
|
||||
return <>Setting</>
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
import { VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, Bars3Icon, PencilIcon, TrashIcon, CloudArrowUpIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { Lecture } from "@nice/common";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
import { useState } from "react";
|
||||
const LectureTypeIcon = ({ type }: { type: string }) => {
|
||||
const iconClass = "h-5 w-5";
|
||||
switch (type) {
|
||||
case 'video':
|
||||
return <VideoCameraIcon className={`${iconClass} text-blue-500`} />;
|
||||
case 'article':
|
||||
return <DocumentTextIcon className={`${iconClass} text-green-500`} />;
|
||||
case 'quiz':
|
||||
return <QuestionMarkCircleIcon className={`${iconClass} text-purple-500`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
interface LectureHeaderProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
export function LectureHeader({
|
||||
lecture,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete
|
||||
}: LectureHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="group/lecture flex items-center gap-4 justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title={isExpanded ? "收起课时" : "展开课时"}
|
||||
leftIcon={<ChevronRightIcon
|
||||
className={`transform transition-transform duration-200 ease-in-out
|
||||
${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>}
|
||||
>
|
||||
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<LectureTypeIcon type={lecture.type} />
|
||||
</div>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5
|
||||
text-xs rounded-full bg-blue-100 text-blue-500">
|
||||
{index + 1}
|
||||
</span>
|
||||
<FormInput viewMode name="title"></FormInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-1 opacity-0 group-hover/lecture:opacity-100
|
||||
transition-all duration-200 ease-in-out">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(lecture.id);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost-danger"
|
||||
leftIcon={<TrashIcon></TrashIcon>}
|
||||
title="删除课时"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface LectureEditorProps {
|
||||
lecture: Lecture;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
|
||||
}
|
||||
export function LectureEditor({ lecture, onUpdate }: LectureEditorProps) {
|
||||
const tabs = [
|
||||
{ key: 'video', icon: VideoCameraIcon, label: '视频' },
|
||||
{ key: 'article', icon: DocumentTextIcon, label: '文章' }
|
||||
];
|
||||
return (
|
||||
<div className=" pt-6 bg-white" >
|
||||
<div className="flex space-x-4 mb-6 px-6">
|
||||
{tabs.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onUpdate({ ...lecture, type: key })}
|
||||
className={`flex items-center space-x-2 px-6 py-2.5 rounded-lg transition-all
|
||||
${lecture.type === key
|
||||
? 'bg-blue-50 text-primary-600 shadow-sm ring-1 ring-blue-100'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 flex flex-col gap-4">
|
||||
{lecture.type === 'video' && (
|
||||
<div className="relative">
|
||||
<FileUploader placeholder="点击或拖拽视频到这里上传" />
|
||||
</div>
|
||||
)}
|
||||
{lecture.type === 'article' && (
|
||||
<div>
|
||||
<FormQuillInput minRows={8} label="文章内容" name="content" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<FileUploader placeholder="点击或拖拽资源到这里上传" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LectureFormItemProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LectureFormItem(props: LectureFormItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const handleToggle = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
className="flex-1 group relative flex flex-col p-4 "
|
||||
>
|
||||
<LectureHeader
|
||||
{...props}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<LectureEditor
|
||||
lecture={props.lecture}
|
||||
onUpdate={props.onUpdate}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
import { Lecture } from "packages/common/dist";
|
||||
import { useCallback } from "react";
|
||||
import { LectureFormItem } from "./LectureFormItem";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
|
||||
interface LectureFormListProps {
|
||||
lectures: Lecture[];
|
||||
sectionId: string;
|
||||
onUpdate: (lectures: Lecture[]) => void;
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
onDelete: (lectureId: string) => void;
|
||||
}
|
||||
|
||||
function SortableItem({ lecture, index, onUpdate, onDelete }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: lecture.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
|
||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<LectureFormItem
|
||||
lecture={lecture}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={
|
||||
|
||||
<Bars3Icon
|
||||
/>
|
||||
} variant="ghost" className="absolute -left-8 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LectureFormList({ lectures, sectionId, onUpdate }: LectureFormListProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback((updatedLecture: Lecture) => {
|
||||
onUpdate(lectures.map(l => l.id === updatedLecture.id ? updatedLecture : l));
|
||||
}, [lectures, onUpdate]);
|
||||
|
||||
const handleDelete = useCallback((lectureId: string) => {
|
||||
onUpdate(lectures.filter(l => l.id !== lectureId));
|
||||
}, [lectures, onUpdate]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = lectures.findIndex((lecture) => lecture.id === active.id);
|
||||
const newIndex = lectures.findIndex((lecture) => lecture.id === over.id);
|
||||
const newLectures = [...lectures];
|
||||
const [removed] = newLectures.splice(oldIndex, 1);
|
||||
newLectures.splice(newIndex, 0, removed);
|
||||
onUpdate(newLectures);
|
||||
}
|
||||
};
|
||||
|
||||
if (lectures.length === 0) {
|
||||
return (
|
||||
<div className="select-none flex items-center justify-center text-tertiary-300">
|
||||
暂无课程内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={lectures.map(lecture => lecture.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{lectures.map((lecture, index) => (
|
||||
<SortableItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
import {
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
ChevronDownIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Section, Lecture } from "@nice/common";
|
||||
import { useState } from "react";
|
||||
import { LectureFormList } from "./LectureFormList";
|
||||
import { cn } from "@web/src/utils/classname";
|
||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
interface SectionProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
onUpdate: (section: Section) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SectionFormItem({ section, index, onUpdate, onDelete }: SectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const handleAddLecture = () => {
|
||||
const newLecture: Lecture = {
|
||||
id: Date.now().toString(),
|
||||
title: '新课时',
|
||||
type: 'video'
|
||||
};
|
||||
onUpdate({
|
||||
...section,
|
||||
lectures: [...section.lectures, newLecture]
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group/section relative flex-1 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
title={isCollapsed ? "展开章节" : "收起章节"}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ChevronDownIcon
|
||||
className={cn(
|
||||
"transition-transform duration-300 ease-out",
|
||||
isCollapsed ? "-rotate-90" : "rotate-0"
|
||||
)}
|
||||
/>}
|
||||
>
|
||||
|
||||
</Button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7
|
||||
bg-blue-50 text-primary-600 text-sm font-semibold rounded">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* <FormInput viewMode name="title"></FormInput> */}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 opacity-0 group-hover/section:opacity-100
|
||||
transition-all duration-200 ease-in-out">
|
||||
<Button
|
||||
onClick={() => onDelete(section.id)}
|
||||
variant="ghost-danger"
|
||||
size="sm"
|
||||
aria-label="删除章节"
|
||||
title="删除章节"
|
||||
leftIcon={<TrashIcon />}
|
||||
>
|
||||
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-out",
|
||||
isCollapsed
|
||||
? "grid-rows-[0fr] opacity-0 invisible"
|
||||
: "grid-rows-[1fr] opacity-100 visible"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
isCollapsed ? "hidden" : "px-10 py-4"
|
||||
)}>
|
||||
<div className="space-y-4">
|
||||
<LectureFormList
|
||||
lectures={section.lectures}
|
||||
sectionId={section.id}
|
||||
onUpdate={(updatedLectures) =>
|
||||
onUpdate({ ...section, lectures: updatedLectures })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleAddLecture}
|
||||
size="md"
|
||||
fullWidth
|
||||
variant="soft-primary"
|
||||
leftIcon={<PlusIcon></PlusIcon>}
|
||||
>
|
||||
|
||||
<span className="font-medium">添加课时</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
import { Section } from "@nice/common";
|
||||
import { SectionFormItem } from "./SectionFormItem";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
|
||||
interface SectionFormListProps {
|
||||
sections: Section[];
|
||||
setSections: React.Dispatch<React.SetStateAction<Section[]>>;
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
onUpdate: (section: Section) => void;
|
||||
onDelete: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableItem({ section, index, onUpdate, onDelete }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<SectionFormItem
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={
|
||||
|
||||
<Bars3Icon
|
||||
/>
|
||||
} variant="ghost" className="absolute -right-10 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
></Button>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SectionFormList({ sections, setSections }: SectionFormListProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 8px的移动距离后才开始拖拽
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const updateSection = (updatedSection: Section) => {
|
||||
setSections(prev => prev.map(section =>
|
||||
section.id === updatedSection.id ? updatedSection : section
|
||||
));
|
||||
};
|
||||
|
||||
const deleteSection = (sectionId: string) => {
|
||||
setSections(prev => prev.filter(section => section.id !== sectionId));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setSections((prev) => {
|
||||
const oldIndex = prev.findIndex((section) => section.id === active.id);
|
||||
const newIndex = prev.findIndex((section) => section.id === over.id);
|
||||
|
||||
const newSections = [...prev];
|
||||
const [removed] = newSections.splice(oldIndex, 1);
|
||||
newSections.splice(newIndex, 0, removed);
|
||||
|
||||
return newSections;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(section => section.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, index) => (
|
||||
<SortableItem
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
onUpdate={updateSection}
|
||||
onDelete={deleteSection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@web/src/components/common/element/Button';
|
||||
import { CourseStatus, CourseStatusLabel } from '@nice/common';
|
||||
import Tag from '@web/src/components/common/element/Tag';
|
||||
import { CourseFormData, useCourseEditor } from '../context/CourseEditorContext';
|
||||
const courseStatusVariant: Record<CourseStatus, string> = {
|
||||
[CourseStatus.DRAFT]: 'default',
|
||||
[CourseStatus.UNDER_REVIEW]: 'warning',
|
||||
[CourseStatus.PUBLISHED]: 'success',
|
||||
[CourseStatus.ARCHIVED]: 'danger'
|
||||
};
|
||||
export default function CourseEditorHeader() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
||||
|
||||
const { onSubmit, course } = useCourseEditor()
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
|
||||
<div className="h-full flex items-center justify-between px-3 md:px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h2 className="font-medium text-gray-900">{course?.title || '新建课程'}</h2>
|
||||
<Tag variant={courseStatusVariant[course?.status || CourseStatus.DRAFT]}>
|
||||
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
|
||||
</Tag>
|
||||
{course?.totalDuration ? (
|
||||
<div className="hidden md:flex items-center text-tertiary-300 text-sm">
|
||||
<ClockIcon className="w-4 h-4 mr-1" />
|
||||
<span>总时长 {course?.totalDuration}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={course ? (!isValid || !isDirty) : !isValid}
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import CourseEditorHeader from "./CourseEditorHeader";
|
||||
import { motion } from "framer-motion";
|
||||
import { NavItem } from "@nice/client"
|
||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
||||
import { CourseFormProvider } from "../context/CourseEditorContext";
|
||||
import { getNavItems } from "../navItems";
|
||||
|
||||
|
||||
export default function CourseEditorLayout() {
|
||||
const { id } = useParams();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
||||
const [navItems, setNavItems] = useState<NavItem[]>(getNavItems(id));
|
||||
useEffect(() => {
|
||||
setNavItems(getNavItems(id))
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
const index = navItems.findIndex(item => item.path === currentPath);
|
||||
if (index !== -1) {
|
||||
setSelectedSection(index);
|
||||
}
|
||||
}, [location.pathname, navItems]);
|
||||
const navigate = useNavigate();
|
||||
const handleNavigation = (item: NavItem, index: number) => {
|
||||
setSelectedSection(index);
|
||||
navigate(item.path, { replace: true });
|
||||
};
|
||||
return (
|
||||
<CourseFormProvider editId={id}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<CourseEditorHeader />
|
||||
<div className="flex pt-16">
|
||||
<CourseEditorSidebar
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
navItems={navItems}
|
||||
selectedSection={selectedSection}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
<motion.main
|
||||
animate={{ marginLeft: isHovered ? "16rem" : "5rem" }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 25, mass: 1 }}
|
||||
className="flex-1 p-8"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
||||
<header className="p-6 border-b border-gray-100">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{navItems[selectedSection]?.label}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<Outlet></Outlet>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
</div>
|
||||
</div>
|
||||
</CourseFormProvider>
|
||||
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { NavItem } from "@nice/client"
|
||||
interface CourseSidebarProps {
|
||||
isHovered: boolean;
|
||||
setIsHovered: (value: boolean) => void;
|
||||
navItems: NavItem[];
|
||||
selectedSection: number;
|
||||
onNavigate: (item: NavItem, index: number) => void;
|
||||
}
|
||||
|
||||
export default function CourseEditorSidebar({
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
navItems,
|
||||
selectedSection,
|
||||
onNavigate
|
||||
}: CourseSidebarProps) {
|
||||
return (
|
||||
<motion.nav
|
||||
initial={{ width: "5rem" }}
|
||||
animate={{ width: isHovered ? "16rem" : "5rem" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 40 }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden"
|
||||
>
|
||||
<div className="p-4">
|
||||
{navItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onNavigate(item, index)}
|
||||
className={`w-full flex ${!isHovered ? 'justify-center' : 'items-center'} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${selectedSection === index
|
||||
? "bg-blue-50 text-primary-600 shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{isHovered && (
|
||||
<>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="ml-3 font-medium flex-1 truncate"
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.nav>
|
||||
);
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
// useEffect(() => {
|
||||
// console.log(watch("audiences"));
|
||||
// }, [watch("audiences")]);
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
label="课程标题"
|
||||
placeholder="请输入课程标题"
|
||||
/>
|
||||
<FormInput
|
||||
maxLength={10}
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
placeholder="请输入课程副标题"
|
||||
/>
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect
|
||||
name="level"
|
||||
label="难度等级"
|
||||
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function CourseContentForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
// useEffect(() => {
|
||||
// console.log(watch("audiences"));
|
||||
// }, [watch("audiences")]);
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
label="课程标题"
|
||||
placeholder="请输入课程标题"
|
||||
/>
|
||||
<FormInput
|
||||
maxLength={10}
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
placeholder="请输入课程副标题"
|
||||
/>
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect
|
||||
name="level"
|
||||
label="难度等级"
|
||||
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { CoursePart } from "../enum";
|
||||
import { CourseBasicForm } from "./CourseBasicForm";
|
||||
import { CourseTargetForm } from "./CourseTargetForm";
|
||||
import { CourseContentForm } from "./CourseContentForm";
|
||||
|
||||
export default function CourseForm() {
|
||||
const { part } = useCourseEditor();
|
||||
if (part === CoursePart.OVERVIEW) {
|
||||
return <CourseBasicForm></CourseBasicForm>;
|
||||
}
|
||||
if (part === CoursePart.TARGET) {
|
||||
return <CourseTargetForm></CourseTargetForm>;
|
||||
}
|
||||
if (part === CoursePart.CONTENT) {
|
||||
return <CourseContentForm></CourseContentForm>;
|
||||
}
|
||||
if (part === CoursePart.SETTING) {
|
||||
return <></>;
|
||||
}
|
||||
return <CourseBasicForm></CourseBasicForm>;
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
|
||||
export function CourseContentForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
label="课程标题"
|
||||
placeholder="请输入课程标题"
|
||||
/>
|
||||
<FormInput
|
||||
maxLength={10}
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
placeholder="请输入课程副标题"
|
||||
/>
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect
|
||||
name="level"
|
||||
label="难度等级"
|
||||
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { FormDynamicInputs } from "@web/src/components/common/form/FormDynamicInputs";
|
||||
|
||||
export function CourseTargetForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormDynamicInputs
|
||||
name="objectives"
|
||||
label="本课的具体学习目标是什么?"
|
||||
// subTitle="学员在完成您的课程后期望掌握的技能"
|
||||
addTitle="目标"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="skills"
|
||||
label="学生将从您的课程中学到什么技能?"
|
||||
subTitle="学员在完成您的课程后期望掌握的技能"
|
||||
addTitle="技能"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="requirements"
|
||||
label="参加课程的要求或基本要求是什么?"
|
||||
subTitle="列出学员在参加课程之前应具备的所需技能、经验、工具或设备。
|
||||
如果没有要求,则可利用此空间作为降低初学者门槛的机会。"
|
||||
addTitle="要求"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="audiences"
|
||||
subTitle="撰写您的课程目标学员的清晰描述,让学员了解您的课程内容很有价值。这将帮助您吸引合适的学员加入您的课程。"
|
||||
addTitle="目标受众"
|
||||
label="此课程的受众是谁?"></FormDynamicInputs>
|
||||
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export enum CoursePart {
|
||||
OVERVIEW = "overview",
|
||||
TARGET = "target",
|
||||
CONTENT = "content",
|
||||
SETTING = "settings",
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from "@heroicons/react/24/outline";
|
||||
import { NavItem } from "@nice/client";
|
||||
|
||||
export const getNavItems = (courseId?: string): (NavItem & { isCompleted?: boolean })[] => [
|
||||
{
|
||||
label: "课程概述",
|
||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor`
|
||||
},
|
||||
{
|
||||
label: "目标学员",
|
||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/goal`
|
||||
},
|
||||
{
|
||||
label: "课程内容",
|
||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/content`
|
||||
},
|
||||
{
|
||||
label: "课程设置",
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/setting`
|
||||
},
|
||||
];
|
|
@ -1,61 +0,0 @@
|
|||
// CourseList.tsx
|
||||
import { motion } from "framer-motion";
|
||||
import { Course, CourseDto } from "@nice/common";
|
||||
import { EmptyState } from "@web/src/components/common/space/Empty";
|
||||
import { Pagination } from "@web/src/components/common/element/Pagination";
|
||||
import React from "react";
|
||||
|
||||
interface CourseListProps {
|
||||
courses?: CourseDto[];
|
||||
renderItem: (course: CourseDto) => React.ReactNode;
|
||||
emptyComponent?: React.ReactNode;
|
||||
// 新增分页相关属性
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const CourseList = ({
|
||||
courses,
|
||||
renderItem,
|
||||
emptyComponent: EmptyComponent,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: CourseListProps) => {
|
||||
if (!courses || courses.length === 0) {
|
||||
return EmptyComponent || <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{courses.map((course) => (
|
||||
<motion.div key={course.id}>
|
||||
{renderItem(course)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -13,20 +13,12 @@ import RoleAdminPage from "../app/admin/role/page";
|
|||
import WithAuth from "../components/utils/with-auth";
|
||||
import BaseSettingPage from "../app/admin/base-setting/page";
|
||||
import { MainLayout } from "../components/layout/main/MainLayout";
|
||||
import HomePage from "../app/main/home/page";
|
||||
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm";
|
||||
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
||||
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||
import WriteLetterPage from "../app/main/letter/write/page";
|
||||
import LetterListPage from "../app/main/letter/list/page";
|
||||
import LetterProgressPage from "../app/main/letter/progress/page";
|
||||
import HelpPage from "../app/main/help/page";
|
||||
import AuthPage from "../app/auth/page";
|
||||
import React from "react";
|
||||
import LetterEditorLayout from "../components/models/post/LetterEditor/layout/LetterEditorLayout";
|
||||
import { LetterBasicForm } from "../components/models/post/LetterEditor/form/LetterBasicForm";
|
||||
import EditorLetterPage from "../app/main/letter/editor/page";
|
||||
import LetterDetailPage from "../app/main/letter/detail/page";
|
||||
|
||||
|
@ -69,7 +61,7 @@ export const routes: CustomRouteObject[] = [
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <HomePage />,
|
||||
element: <LetterListPage></LetterListPage>
|
||||
},
|
||||
{
|
||||
path: ":id?/detail",
|
||||
|
@ -83,10 +75,7 @@ export const routes: CustomRouteObject[] = [
|
|||
path: "write-letter",
|
||||
element: <WriteLetterPage></WriteLetterPage>,
|
||||
},
|
||||
{
|
||||
path: "letter-list",
|
||||
element: <LetterListPage></LetterListPage>
|
||||
}
|
||||
|
||||
, {
|
||||
path: 'letter-progress',
|
||||
element: <LetterProgressPage></LetterProgressPage>
|
||||
|
@ -97,38 +86,6 @@ export const routes: CustomRouteObject[] = [
|
|||
}
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "course",
|
||||
children: [
|
||||
{
|
||||
path: ":id?/editor",
|
||||
element: <CourseEditorLayout></CourseEditorLayout>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <CourseBasicForm></CourseBasicForm>,
|
||||
},
|
||||
{
|
||||
path: "goal",
|
||||
element: <CourseGoalForm></CourseGoalForm>,
|
||||
},
|
||||
{
|
||||
path: "content",
|
||||
element: (
|
||||
<CourseContentForm></CourseContentForm>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "setting",
|
||||
element: (
|
||||
<CourseSettingForm></CourseSettingForm>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
|
@ -8,8 +8,7 @@ export enum PostType {
|
|||
}
|
||||
export enum TaxonomySlug {
|
||||
CATEGORY = "category",
|
||||
UNIT = "unit",
|
||||
TAG = "tag",
|
||||
TAG = "tag"
|
||||
}
|
||||
export enum VisitType {
|
||||
STAR = "star",
|
||||
|
|
|
@ -9,7 +9,7 @@ export function generateColorScale(baseColor: string): ColorScale {
|
|||
const scale = Object.fromEntries(
|
||||
keys.map((key, index) => [
|
||||
key,
|
||||
color.lighten(-steps[index]).hex()
|
||||
color.lighten(-steps[index]).rgb().string()
|
||||
])
|
||||
) as ColorScale;
|
||||
|
||||
|
|
|
@ -18,12 +18,13 @@ export function themeToCssVariables(theme: Theme): Record<string, string> {
|
|||
const flattenedToken = flattenObject(theme.token)
|
||||
console.log(flattenedToken)
|
||||
const cssVars: Record<string, string> = {}
|
||||
for (const [path, value] of Object.entries(flattenedToken)) {
|
||||
|
||||
for (const [path, value] of Object.entries(flattenedToken)) {
|
||||
const cssVarName = createCssVariableName(path.split('.'))
|
||||
|
||||
cssVars[cssVarName] = value
|
||||
}
|
||||
|
||||
return cssVars
|
||||
}
|
||||
|
||||
|
@ -97,4 +98,4 @@ export function createTailwindTheme(theme: Theme): Partial<Config["theme"]> {
|
|||
}
|
||||
console.log(result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue