This commit is contained in:
ditiqi 2025-01-24 15:07:50 +08:00
commit b4369a9de5
63 changed files with 598 additions and 2621 deletions

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"marscode.chatLanguage": "cn",
"marscode.codeCompletionPro": {
"enableCodeCompletionPro": true
},
"marscode.enableInlineCommand": true
}

View File

@ -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,

View File

@ -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>

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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()}

View File

@ -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} />
))}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
}

View File

@ -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>
);

View File

@ -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]
)}>

View File

@ -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;

View File

@ -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>

View File

@ -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>)
}

View File

@ -0,0 +1,5 @@
export interface MenuItemType {
icon: JSX.Element;
label: string;
action: () => void;
}

View File

@ -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: "使用帮助" }
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
};

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);

View File

@ -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>
</>
);
};

View File

@ -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>
);

View File

@ -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>
)
);

View File

@ -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>
);

View File

@ -1 +0,0 @@
export * from "./CourseSyllabus";

View File

@ -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;

View File

@ -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;
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,3 +0,0 @@
export default function CourseSettingForm() {
return <>Setting</>
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,6 +0,0 @@
export enum CoursePart {
OVERVIEW = "overview",
TARGET = "target",
CONTENT = "content",
SETTING = "settings",
}

View File

@ -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`
},
];

View File

@ -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>
);
};

View File

@ -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: [

View File

@ -1,6 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -8,8 +8,7 @@ export enum PostType {
}
export enum TaxonomySlug {
CATEGORY = "category",
UNIT = "unit",
TAG = "tag",
TAG = "tag"
}
export enum VisitType {
STAR = "star",

View File

@ -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;

View File

@ -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
}
}