This commit is contained in:
mei 2024-12-13 19:46:49 +08:00
parent f730fa4472
commit f78a466c39
33 changed files with 6954 additions and 1 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,2 +1,36 @@
# bsd
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

12
app/api/config/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { promises as fs } from 'fs'
import { NextResponse } from 'next/server'
export async function GET() {
const file = await fs.readFile(process.cwd() + '/data/config.yaml', 'utf8')
return new NextResponse(file, {
headers: {
'Content-Type': 'text/yaml',
},
})
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
app/fonts/GeistMonoVF.woff Normal file

Binary file not shown.

BIN
app/fonts/GeistVF.woff Normal file

Binary file not shown.

78
app/globals.css Normal file
View File

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

54
app/hooks/useConfig.ts Normal file
View File

@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import yaml from 'yaml';
// 定义活动项的接口
interface ActivityParticipant {
name: string;
}
interface Activity {
title: string;
coverImage: string;
description: string;
date: string;
participants: ActivityParticipant[];
organizer: string;
}
// 定义展示项的接口
interface ShowcaseItem {
title: string;
image: string;
author: string;
date: string;
}
// 定义配置接口
interface Config {
activities: Activity[];
showcase: ShowcaseItem[];
}
export function useConfig() {
const [config, setConfig] = useState<Config>({ activities: [], showcase: [] });
useEffect(() => {
async function loadConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const configYaml = await response.text();
const parsedConfig: Config = yaml.parse(configYaml);
setConfig(parsedConfig);
} catch (error) {
console.error('Failed to load configuration:', error);
}
}
loadConfig();
}, []);
return { config };
}

10
app/inventory/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import InventoryManagement from '@/components/InventoryManagement';
export default function Home() {
return (
<main className="min-h-screen bg-gradient-to-br from-blue-100 to-indigo-100 p-4 sm:p-8">
<InventoryManagement />
</main>
);
}

26
app/layout.tsx Normal file
View File

@ -0,0 +1,26 @@
import './globals.css'
import { Inter } from 'next/font/google'
import Navbar from '@/components/Navbar'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: '影视星河摄影社团',
description: '影视星河摄影社团官方网站',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<Navbar />
{children}
</body>
</html>
)
}

84
app/page.tsx Normal file
View File

@ -0,0 +1,84 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { motion } from 'framer-motion'
import ActivityCard from '@/components/ActivityCard'
import { Input } from "@/components/ui/input"
import { useConfig } from './hooks/useConfig'
export default function Home() {
const { config } = useConfig()
const [searchTerm, setSearchTerm] = useState('')
const videoRef = useRef<HTMLVideoElement>(null)
const filteredActivities = config.activities.filter(activity =>
activity.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
activity.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
activity.organizer.toLowerCase().includes(searchTerm.toLowerCase())
)
useEffect(() => {
if (videoRef.current) {
videoRef.current.play().catch(error => {
console.error("视频自动播放失败:", error)
})
}
}, [])
return (
<main className="min-h-screen">
<section className="h-screen relative overflow-hidden">
<video
ref={videoRef}
autoPlay
loop
muted
playsInline
className="absolute top-0 left-0 w-full h-full object-cover"
>
<source src="https://c.mmeiblog.cn/d/share/newbili/影视星河片头.mp4" type="video/mp4" />
</video>
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50"></div>
<div className="relative z-10 h-full flex flex-col justify-center items-center text-white">
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1 }}
className="text-center"
>
<h1 className="text-4xl font-bold mb-4"></h1>
<p className="text-xl mb-8"></p>
</motion.div>
</div>
</section>
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold mb-8 text-center"></h2>
<div className="mb-6">
<Input
type="text"
placeholder="搜索活动、描述或组织者..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-md mx-auto"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredActivities.map((activity, index) => (
<ActivityCard
key={index}
activity={{
...activity,
participants: activity.participants.map(participant => participant.name)
}}
/>
))}
</div>
</div>
</section>
</main>
)
}

80
app/showcase/page.tsx Normal file
View File

@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useConfig } from '../hooks/useConfig'
const ITEMS_PER_PAGE = 6
export default function Showcase() {
const { config } = useConfig()
const [currentPage, setCurrentPage] = useState(1)
const [searchTerm, setSearchTerm] = useState('')
const filteredShowcase = config.showcase.filter(photo =>
photo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
photo.author.toLowerCase().includes(searchTerm.toLowerCase())
)
const totalPages = Math.ceil(filteredShowcase.length / ITEMS_PER_PAGE)
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
const currentPhotos = filteredShowcase.slice(startIndex, endIndex)
return (
<main className="min-h-screen bg-gray-100 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-8 text-center"></h1>
<div className="mb-6">
<Input
type="text"
placeholder="搜索作品或作者..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-md mx-auto"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{currentPhotos.map((photo, index) => (
<div key={index} className="bg-white shadow-lg rounded-lg overflow-hidden">
<div className="relative h-64">
<Image
src={photo.image}
alt={photo.title}
layout="fill"
objectFit="cover"
/>
</div>
<div className="p-6">
<h2 className="text-xl font-semibold mb-2">{photo.title}</h2>
<p className="text-gray-600 mb-2">{photo.author}</p>
<p className="text-gray-500 text-sm">{photo.date}</p>
</div>
</div>
))}
</div>
<div className="mt-8 flex justify-center items-center space-x-4">
<Button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4 mr-2" />
</Button>
<span> {currentPage} {totalPages} </span>
<Button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
</main>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,81 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
interface ActivityProps {
activity: {
title: string
coverImage: string
description: string
date: string
participants: string[]
organizer: string
}
}
const ActivityCard: React.FC<ActivityProps> = ({ activity }) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white shadow-lg rounded-lg overflow-hidden">
<div className="relative h-48">
<Image
src={activity.coverImage}
alt={activity.title}
layout="fill"
objectFit="cover"
/>
</div>
<div className="p-6">
<h3 className="text-xl font-semibold mb-2">{activity.title}</h3>
<p className="text-gray-600 mb-2">{activity.description}</p>
<p className="text-sm text-gray-500 mb-2">{activity.date}</p>
<p className="text-sm text-gray-500 mb-2">{activity.organizer}</p>
<div className="mt-4">
<p className="text-sm font-semibold mb-1"></p>
{activity.participants.length <= 3 ? (
<ul className="list-disc list-inside">
{activity.participants.map((participant, index) => (
<li key={index} className="text-sm text-gray-600">{participant}</li>
))}
</ul>
) : (
<div>
<ul className="list-disc list-inside">
{activity.participants.slice(0, 3).map((participant, index) => (
<li key={index} className="text-sm text-gray-600">{participant}</li>
))}
</ul>
{isExpanded && (
<ul className="list-disc list-inside mt-2">
{activity.participants.slice(3).map((participant, index) => (
<li key={index + 3} className="text-sm text-gray-600">{participant}</li>
))}
</ul>
)}
<button
className="mt-2 text-sm text-blue-600 hover:text-blue-800 flex items-center"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<>
<ChevronUp className="ml-1 w-4 h-4" />
</>
) : (
<>
({activity.participants.length - 3}) <ChevronDown className="ml-1 w-4 h-4" />
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
)
}
export default ActivityCard

28
components/Alert.tsx Normal file
View File

@ -0,0 +1,28 @@
import { useEffect } from 'react';
interface AlertProps {
message: string;
type: 'success' | 'error' | 'warning';
onClose: () => void;
}
export default function Alert({ message, type, onClose }: AlertProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 5000);
return () => clearTimeout(timer);
}, [onClose]);
const bgColor = type === 'success' ? 'bg-green-100' : type === 'error' ? 'bg-red-100' : 'bg-yellow-100';
const textColor = type === 'success' ? 'text-green-700' : type === 'error' ? 'text-red-700' : 'text-yellow-700';
const borderColor = type === 'success' ? 'border-green-500' : type === 'error' ? 'border-red-500' : 'border-yellow-500';
return (
<div className={`fixed top-4 right-4 p-4 rounded-lg shadow-lg ${bgColor} ${textColor} border-l-4 ${borderColor}`}>
<p>{message}</p>
</div>
);
}

62
components/DeviceForm.tsx Normal file
View File

@ -0,0 +1,62 @@
import { useState } from 'react';
import { Device } from './InventoryManagement';
interface DeviceFormProps {
onAddDevice: (device: Device) => void;
}
export default function DeviceForm({ onAddDevice }: DeviceFormProps) {
const [name, setName] = useState('');
const [quantity, setQuantity] = useState('');
const [serialNumber, setSerialNumber] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newDevice: Device = {
id: Date.now().toString(),
name,
quantity: parseInt(quantity),
serialNumber,
};
onAddDevice(newDevice);
setName('');
setQuantity('');
setSerialNumber('');
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-4 text-indigo-700"></h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="设备名称"
required
className="border rounded p-2 w-full"
/>
<input
type="number"
value={quantity}
onChange={e => setQuantity(e.target.value)}
placeholder="数量"
required
className="border rounded p-2 w-full"
/>
<input
type="text"
value={serialNumber}
onChange={e => setSerialNumber(e.target.value)}
placeholder="序列号"
required
className="border rounded p-2 w-full"
/>
<button type="submit" className="bg-indigo-500 text-white px-4 py-2 rounded-full hover:bg-indigo-600 transition duration-300">
</button>
</div>
</form>
);
}

93
components/DeviceList.tsx Normal file
View File

@ -0,0 +1,93 @@
import { useState } from 'react';
import { Device } from './InventoryManagement';
interface DeviceListProps {
devices: Device[];
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
showAlert: (message: string, type: 'success' | 'error' | 'warning') => void;
}
export default function DeviceList({ devices, onUpdateDevice, onDeleteDevice, showAlert }: DeviceListProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const handleEdit = (device: Device) => {
setEditingId(device.id);
};
const handleSave = (device: Device) => {
onUpdateDevice(device);
setEditingId(null);
};
const handleDelete = (id: string) => {
if (window.confirm('确定要删除这个设备吗?')) {
onDeleteDevice(id);
showAlert('设备已成功删除', 'success');
}
};
return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4 text-indigo-700"></h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-indigo-100 text-indigo-700">
<th className="p-2 text-left rounded-tl-lg"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left rounded-tr-lg"></th>
</tr>
</thead>
<tbody>
{devices.map(device => (
<tr key={device.id} className="border-b border-indigo-100 hover:bg-indigo-50 transition duration-300">
<td className="p-2">
{editingId === device.id ? (
<input
type="text"
value={device.name}
onChange={e => onUpdateDevice({ ...device, name: e.target.value })}
className="border rounded p-1 w-full"
/>
) : (
device.name
)}
</td>
<td className="p-2">
{editingId === device.id ? (
<input
type="number"
value={device.quantity}
onChange={e => onUpdateDevice({ ...device, quantity: parseInt(e.target.value) })}
className="border rounded p-1 w-20"
/>
) : (
device.quantity
)}
</td>
<td className="p-2">{device.serialNumber}</td>
<td className="p-2">
{editingId === device.id ? (
<button onClick={() => handleSave(device)} className="bg-green-500 text-white px-3 py-1 rounded-full mr-2 hover:bg-green-600 transition duration-300">
</button>
) : (
<button onClick={() => handleEdit(device)} className="bg-blue-500 text-white px-3 py-1 rounded-full mr-2 hover:bg-blue-600 transition duration-300">
</button>
)}
<button onClick={() => handleDelete(device.id)} className="bg-red-500 text-white px-3 py-1 rounded-full hover:bg-red-600 transition duration-300">
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,251 @@
import { useState } from 'react';
import { Device, Distribution } from './InventoryManagement';
interface DistributionRecordProps {
devices: Device[];
distributions: Distribution[];
onAddDistribution: (distribution: Distribution) => void;
onUpdateDistribution: (distribution: Distribution) => void;
onDeleteDistribution: (id: string) => void;
showAlert: (message: string, type: 'success' | 'error' | 'warning') => void;
}
export default function DistributionRecord({
devices,
distributions,
onAddDistribution,
onUpdateDistribution,
onDeleteDistribution,
showAlert,
}: DistributionRecordProps) {
const [deviceId, setDeviceId] = useState('');
const [recipient, setRecipient] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [quantity, setQuantity] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newDistribution: Distribution = {
id: Date.now().toString(),
deviceId,
recipient,
startDate,
endDate,
quantity: parseInt(quantity),
};
if (isDeviceAvailable(deviceId, parseInt(quantity))) {
onAddDistribution(newDistribution);
resetForm();
showAlert('设备分发成功', 'success');
} else {
showAlert('设备数量不足,无法分发', 'error');
}
};
const resetForm = () => {
setDeviceId('');
setRecipient('');
setStartDate('');
setEndDate('');
setQuantity('');
};
const handleEdit = (distribution: Distribution) => {
setEditingId(distribution.id);
setDeviceId(distribution.deviceId);
setRecipient(distribution.recipient);
setStartDate(distribution.startDate);
setEndDate(distribution.endDate);
setQuantity(distribution.quantity.toString());
};
const handleSave = () => {
if (editingId) {
const updatedDistribution: Distribution = {
id: editingId,
deviceId,
recipient,
startDate,
endDate,
quantity: parseInt(quantity),
};
onUpdateDistribution(updatedDistribution);
setEditingId(null);
resetForm();
}
};
const handleDelete = (id: string) => {
if (window.confirm('确定要删除这条分发记录吗?')) {
onDeleteDistribution(id);
showAlert('分发记录已成功删除', 'success');
}
};
const isDeviceAvailable = (deviceId: string, quantity: number) => {
const device = devices.find(d => d.id === deviceId);
return device ? device.quantity >= quantity : false;
};
return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4 text-indigo-700"></h2>
<form onSubmit={handleSubmit} className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
<select
value={deviceId}
onChange={e => setDeviceId(e.target.value)}
required
className="border rounded p-2 w-full"
>
<option value=""></option>
{devices.map(device => (
<option key={device.id} value={device.id}>
{device.name} (: {device.quantity})
</option>
))}
</select>
<input
type="text"
value={recipient}
onChange={e => setRecipient(e.target.value)}
placeholder="领用人"
required
className="border rounded p-2 w-full"
/>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
required
className="border rounded p-2 w-full"
/>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
required
className="border rounded p-2 w-full"
/>
<input
type="number"
value={quantity}
onChange={e => setQuantity(e.target.value)}
placeholder="数量"
required
className="border rounded p-2 w-full"
/>
<button
type="submit"
className="bg-indigo-500 text-white px-4 py-2 rounded-full hover:bg-indigo-600 transition duration-300"
disabled={!isDeviceAvailable(deviceId, parseInt(quantity))}
>
</button>
</div>
</form>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-indigo-100 text-indigo-700">
<th className="p-2 text-left rounded-tl-lg"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left rounded-tr-lg"></th>
</tr>
</thead>
<tbody>
{distributions.map(distribution => (
<tr key={distribution.id} className="border-b border-indigo-100 hover:bg-indigo-50 transition duration-300">
<td className="p-2">
{editingId === distribution.id ? (
<select
value={deviceId}
onChange={e => setDeviceId(e.target.value)}
className="border rounded p-1 w-full"
>
{devices.map(device => (
<option key={device.id} value={device.id}>
{device.name}
</option>
))}
</select>
) : (
devices.find(d => d.id === distribution.deviceId)?.name
)}
</td>
<td className="p-2">
{editingId === distribution.id ? (
<input
type="text"
value={recipient}
onChange={e => setRecipient(e.target.value)}
className="border rounded p-1 w-full"
/>
) : (
distribution.recipient
)}
</td>
<td className="p-2">
{editingId === distribution.id ? (
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
className="border rounded p-1 w-full"
/>
) : (
distribution.startDate
)}
</td>
<td className="p-2">
{editingId === distribution.id ? (
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
className="border rounded p-1 w-full"
/>
) : (
distribution.endDate
)}
</td>
<td className="p-2">
{editingId === distribution.id ? (
<input
type="number"
value={quantity}
onChange={e => setQuantity(e.target.value)}
className="border rounded p-1 w-20"
/>
) : (
distribution.quantity
)}
</td>
<td className="p-2">
{editingId === distribution.id ? (
<button onClick={handleSave} className="bg-green-500 text-white px-3 py-1 rounded-full mr-2 hover:bg-green-600 transition duration-300">
</button>
) : (
<button onClick={() => handleEdit(distribution)} className="bg-blue-500 text-white px-3 py-1 rounded-full mr-2 hover:bg-blue-600 transition duration-300">
</button>
)}
<button onClick={() => handleDelete(distribution.id)} className="bg-red-500 text-white px-3 py-1 rounded-full hover:bg-red-600 transition duration-300">
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
'use client'
import { useState, useEffect } from 'react';
import DeviceList from './DeviceList';
import DeviceForm from './DeviceForm';
import DistributionRecord from './DistributionRecord';
import { saveToLocalStorage, getFromLocalStorage, exportData } from '../utils/localStorage';
import Alert from './Alert';
export interface Device {
id: string;
name: string;
quantity: number;
serialNumber: string;
}
export interface Distribution {
id: string;
deviceId: string;
recipient: string;
startDate: string;
endDate: string;
quantity: number;
}
export default function Device() {
const [devices, setDevices] = useState<Device[]>([]);
const [distributions, setDistributions] = useState<Distribution[]>([]);
const [alert, setAlert] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
useEffect(() => {
const savedDevices = getFromLocalStorage('devices');
const savedDistributions = getFromLocalStorage('distributions');
if (savedDevices) setDevices(savedDevices);
if (savedDistributions) setDistributions(savedDistributions);
}, []);
useEffect(() => {
saveToLocalStorage('devices', devices);
}, [devices]);
useEffect(() => {
saveToLocalStorage('distributions', distributions);
}, [distributions]);
const handleAddDevice = (device: Device) => {
setDevices([...devices, device]);
};
const handleUpdateDevice = (updatedDevice: Device) => {
setDevices(devices.map(d => d.id === updatedDevice.id ? updatedDevice : d));
};
const handleDeleteDevice = (id: string) => {
setDevices(devices.filter(d => d.id !== id));
setDistributions(distributions.filter(d => d.deviceId !== id));
showAlert('设备已成功删除', 'success');
};
const handleAddDistribution = (distribution: Distribution) => {
const device = devices.find(d => d.id === distribution.deviceId);
if (device && device.quantity >= distribution.quantity) {
setDistributions([...distributions, distribution]);
handleUpdateDevice({ ...device, quantity: device.quantity - distribution.quantity });
showAlert('设备分发成功', 'success');
} else {
showAlert('设备数量不足,无法分发', 'error');
}
};
const handleUpdateDistribution = (updatedDistribution: Distribution) => {
const oldDistribution = distributions.find(d => d.id === updatedDistribution.id);
if (oldDistribution) {
const device = devices.find(d => d.id === updatedDistribution.deviceId);
if (device) {
const quantityDiff = oldDistribution.quantity - updatedDistribution.quantity;
handleUpdateDevice({ ...device, quantity: device.quantity + quantityDiff });
}
}
setDistributions(distributions.map(d => d.id === updatedDistribution.id ? updatedDistribution : d));
};
const handleDeleteDistribution = (id: string) => {
const distribution = distributions.find(d => d.id === id);
if (distribution) {
const device = devices.find(d => d.id === distribution.deviceId);
if (device) {
handleUpdateDevice({ ...device, quantity: device.quantity + distribution.quantity });
}
}
setDistributions(distributions.filter(d => d.id !== id));
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const importedData = JSON.parse(content);
if (importedData.devices) setDevices(importedData.devices);
if (importedData.distributions) setDistributions(importedData.distributions);
};
reader.readAsText(file);
}
};
const handleExport = () => {
exportData({ devices, distributions }, 'inventory_data.json');
};
const showAlert = (message: string, type: 'success' | 'error' | 'warning') => {
setAlert({ message, type });
};
return (
<div className="container mx-auto bg-white rounded-lg shadow-lg p-6 space-y-8">
<h1 className="text-3xl font-bold text-center text-indigo-700 mb-8"></h1>
<div className="flex justify-center space-x-4 mb-8">
<label className="bg-blue-500 text-white px-4 py-2 rounded-full cursor-pointer hover:bg-blue-600 transition duration-300">
<input type="file" onChange={handleImport} className="hidden" />
</label>
<button onClick={handleExport} className="bg-green-500 text-white px-4 py-2 rounded-full hover:bg-green-600 transition duration-300">
</button>
</div>
<DeviceForm onAddDevice={handleAddDevice} />
<DeviceList
devices={devices}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}
showAlert={showAlert} // 修复这里
/>
<DistributionRecord
devices={devices}
distributions={distributions}
onAddDistribution={handleAddDistribution}
onUpdateDistribution={handleUpdateDistribution}
onDeleteDistribution={handleDeleteDistribution}
showAlert={showAlert} // 修复这里
/>
{alert && (
<Alert
message={alert.message}
type={alert.type}
onClose={() => setAlert(null)}
/>
)}
</div>
);
}

65
components/Navbar.tsx Normal file
View File

@ -0,0 +1,65 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
const Navbar = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<nav className="bg-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Link href="/" className="text-xl font-bold text-gray-800">
</Link>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link href="/" className="text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 border-transparent hover:border-gray-300">
</Link>
<Link href="/showcase" className="text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 border-transparent hover:border-gray-300">
</Link>
<Link href="/inventory" className="text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 border-transparent hover:border-gray-300">
</Link>
</div>
</div>
<div className="sm:hidden flex items-center">
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
>
<span className="sr-only"></span>
{isOpen ? (
<X className="block h-6 w-6" aria-hidden="true" />
) : (
<Menu className="block h-6 w-6" aria-hidden="true" />
)}
</button>
</div>
</div>
</div>
{isOpen && (
<div className="sm:hidden">
<div className="pt-2 pb-3 space-y-1">
<Link href="/" className="text-gray-900 block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-50">
</Link>
<Link href="/showcase" className="text-gray-900 block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-50">
</Link>
</div>
</div>
)}
</nav>
)
}
export default Navbar

57
components/ui/button.tsx Normal file
View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

22
components/ui/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

77
data/config.yaml Normal file
View File

@ -0,0 +1,77 @@
activities:
- title: 月度摄影漫步
coverImage: /images/photo-walk.jpg
description: 加入我们的月度城市摄影漫步。捕捉城市景观和街头场景。
date: 2023-07-15
participants:
- 张三
- 李四
- 王五
- 赵六
- 钱七
organizer: 刘老师
- title: 人像摄影工作坊
coverImage: /images/portrait-workshop.jpg
description: 在专业摄影师的指导下,学习人像摄影的艺术。
date: 2023-07-22
participants:
- 陈一
- 周二
- 吴三
organizer: 黄教授
- title: 自然摄影探险
coverImage: /images/nature-expedition.jpg
description: 探索大自然,在周末的自然摄影探险中捕捉壮丽的风景。
date: 2023-08-05
participants:
- 郑八
- 孙九
- 杨十
- 朱十一
- 秦十二
- 许十三
organizer: 林导师
- title: 照片编辑大师班
coverImage: /images/editing-masterclass.jpg
description: 通过我们的照片编辑大师班,使用行业标准软件提升您的后期处理技能。
date: 2023-08-12
participants:
- 冯一
- 蒋二
- 沈三
organizer: 马老师
showcase:
- title: 海滩日落
image: /images/sunset-beach.jpg
author: 张美丽
date: 2023-05-15
- title: 城市倒影
image: /images/urban-reflections.jpg
author: 李强
date: 2023-04-22
- title: 山峰之巅
image: /images/mountain-majesty.jpg
author: 王小明
date: 2023-06-01
- title: 陌生人的肖像
image: /images/portrait-stranger.jpg
author: 刘静
date: 2023-05-07
- title: 秋色
image: /images/autumn-colors.jpg
author: 陈雨
date: 2023-10-10
- title: 城市夜景
image: /images/night-city.jpg
author: 赵阳
date: 2023-07-18

6
lib/utils.ts Normal file
View File

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

4
next.config.mjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

5456
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "bsd-sy",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.14.3",
"fs": "^0.0.1-security",
"lucide-react": "^0.468.0",
"next": "14.2.16",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.6.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.16",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

63
tailwind.config.ts Normal file
View File

@ -0,0 +1,63 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

9
types/activity.ts Normal file
View File

@ -0,0 +1,9 @@
export interface Activity {
title: string
coverImage: string
description: string
date: string
participants: string[]
organizer: string
}

20
utils/localStorage.ts Normal file
View File

@ -0,0 +1,20 @@
export const saveToLocalStorage = (key: string, data: any) => {
localStorage.setItem(key, JSON.stringify(data));
};
export const getFromLocalStorage = (key: string) => {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
};
export const exportData = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};