update
This commit is contained in:
parent
f730fa4472
commit
f78a466c39
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
|
36
README.md
36
README.md
@ -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
12
app/api/config/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
app/fonts/GeistMonoVF.woff
Normal file
BIN
app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
app/fonts/GeistVF.woff
Normal file
BIN
app/fonts/GeistVF.woff
Normal file
Binary file not shown.
78
app/globals.css
Normal file
78
app/globals.css
Normal 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
54
app/hooks/useConfig.ts
Normal 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
10
app/inventory/page.tsx
Normal 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
26
app/layout.tsx
Normal 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
84
app/page.tsx
Normal 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
80
app/showcase/page.tsx
Normal 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
21
components.json
Normal 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"
|
||||||
|
}
|
81
components/ActivityCard.tsx
Normal file
81
components/ActivityCard.tsx
Normal 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
28
components/Alert.tsx
Normal 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
62
components/DeviceForm.tsx
Normal 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
93
components/DeviceList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
251
components/DistributionRecord.tsx
Normal file
251
components/DistributionRecord.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
152
components/InventoryManagement.tsx
Normal file
152
components/InventoryManagement.tsx
Normal 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
65
components/Navbar.tsx
Normal 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
57
components/ui/button.tsx
Normal 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
22
components/ui/input.tsx
Normal 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
77
data/config.yaml
Normal 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
6
lib/utils.ts
Normal 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
4
next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
5456
package-lock.json
generated
Normal file
5456
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
63
tailwind.config.ts
Normal file
63
tailwind.config.ts
Normal 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
26
tsconfig.json
Normal 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
9
types/activity.ts
Normal 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
20
utils/localStorage.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user