Initial commit: PLM Web frontend project

This commit is contained in:
admin
2026-04-06 15:42:11 +08:00
parent ac82b6fb24
commit 54e99a239b
11 changed files with 1239 additions and 63 deletions

10
next.log Normal file
View File

@@ -0,0 +1,10 @@
Failed to start server
Error: listen EADDRINUSE: address already in use :::3000
at <unknown> (Error: listen EADDRINUSE: address already in use :::3000)
at new Promise (<anonymous>) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '::',
port: 3000
}

View File

@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"axios": "^1.14.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"

76
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
axios:
specifier: ^1.14.0
version: 1.14.0
next:
specifier: 16.2.2
version: 16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -771,6 +774,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -779,6 +785,9 @@ packages:
resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==}
engines: {node: '>=4'}
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -844,6 +853,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -900,6 +913,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1126,10 +1143,23 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1511,6 +1541,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1658,6 +1696,10 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -2634,12 +2676,22 @@ snapshots:
async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
axe-core@4.11.2: {}
axios@1.14.0:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
@@ -2703,6 +2755,10 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -2757,6 +2813,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {}
doctrine@2.1.0:
@@ -3131,10 +3189,20 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.15.11: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -3491,6 +3559,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
@@ -3645,6 +3719,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}

View File

@@ -0,0 +1,202 @@
'use client'
import { useEffect, useState } from 'react'
import axios from 'axios'
import Link from 'next/link'
interface BOM {
id: number
bom_number: string
bom_name: string
version: string
status: string
product_name?: string
created_at: string
}
export default function BOMPage() {
const [boms, setBoms] = useState<BOM[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
useEffect(() => {
loadBOMs()
}, [search, statusFilter])
const loadBOMs = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/bom`,
{
headers: { Authorization: `Bearer ${token}` },
params: { keyword: search, status: statusFilter }
}
)
setBoms(response.data.data || response.data.items || [])
} catch {
// 模拟数据
setBoms([
{ id: 1, bom_number: 'BOM-001', bom_name: '智能音箱A1标准BOM', version: 'V1.0', status: 'active', product_name: '智能音箱A1', created_at: '2026-04-01' },
{ id: 2, bom_number: 'BOM-002', bom_name: '智能音箱B2标准BOM', version: 'V1.0', status: 'draft', product_name: '智能音箱B2', created_at: '2026-04-02' },
{ id: 3, bom_number: 'BOM-003', bom_name: '机械键盘K1标准BOM', version: 'V2.0', status: 'active', product_name: '机械键盘K1', created_at: '2026-03-15' },
])
} finally {
setLoading(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该BOM')) return
try {
const token = localStorage.getItem('token')
await axios.delete(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/bom/${id}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
loadBOMs()
} catch {
alert('删除失败')
}
}
const handleActivate = async (id: number) => {
try {
const token = localStorage.getItem('token')
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/bom/${id}/activate`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
)
loadBOMs()
} catch {
alert('激活失败')
}
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
active: 'bg-green-100 text-green-700',
draft: 'bg-gray-100 text-gray-700',
obsolete: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
}
const labels: Record<string, string> = {
active: '已激活',
draft: '草稿',
obsolete: '已作废',
pending: '待审核',
}
return (
<span className={`px-2 py-1 rounded text-xs ${styles[status] || styles.draft}`}>
{labels[status] || status}
</span>
)
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">BOM管理</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Link
href="/bom/new"
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+ BOM
</Link>
</div>
{/* 搜索和筛选 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex gap-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索BOM名称或编号..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
<option value=""></option>
<option value="active"></option>
<option value="draft">稿</option>
<option value="obsolete"></option>
<option value="pending"></option>
</select>
</div>
</div>
{/* BOM列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
{loading ? (
<div className="p-8 text-center text-gray-500">...</div>
) : boms.length === 0 ? (
<div className="p-8 text-center text-gray-500">BOM数据</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700">BOM编号</th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700">BOM名称</th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{boms.map((bom) => (
<tr key={bom.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-indigo-600">
<Link href={`/bom/${bom.id}`}>{bom.bom_number}</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-900">{bom.bom_name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{bom.product_name || '-'}</td>
<td className="px-6 py-4 text-sm text-gray-600">{bom.version}</td>
<td className="px-6 py-4">{getStatusBadge(bom.status)}</td>
<td className="px-6 py-4 text-sm text-gray-600">{bom.created_at}</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link
href={`/bom/${bom.id}`}
className="text-indigo-600 hover:text-indigo-800 text-sm"
>
</Link>
{bom.status === 'draft' && (
<button
onClick={() => handleActivate(bom.id)}
className="text-green-600 hover:text-green-800 text-sm"
>
</button>
)}
<button
onClick={() => handleDelete(bom.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
'use client'
import { useEffect, useState } from 'react'
import axios from 'axios'
interface Category {
id: number
code: string
name: string
parent_id?: number
parent_name?: string
product_count: number
}
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [formData, setFormData] = useState({ code: '', name: '', parent_id: '' })
useEffect(() => {
loadCategories()
}, [])
const loadCategories = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/categories`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setCategories(response.data.data || response.data.items || [])
} catch {
// 模拟数据
setCategories([
{ id: 1, code: 'CAT-001', name: '电子产品', product_count: 5 },
{ id: 2, code: 'CAT-002', name: '配件', parent_id: 1, parent_name: '电子产品', product_count: 3 },
{ id: 3, code: 'CAT-003', name: '软件', product_count: 2 },
{ id: 4, code: 'CAT-004', name: '服务', product_count: 1 },
])
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const token = localStorage.getItem('token')
const data = {
code: formData.code,
name: formData.name,
parent_id: formData.parent_id ? Number(formData.parent_id) : undefined
}
if (editingCategory) {
await axios.put(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/categories/${editingCategory.id}`,
data,
{ headers: { Authorization: `Bearer ${token}` } }
)
} else {
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/categories`,
data,
{ headers: { Authorization: `Bearer ${token}` } }
)
}
setShowModal(false)
setEditingCategory(null)
setFormData({ code: '', name: '', parent_id: '' })
loadCategories()
} catch {
alert('保存失败')
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该分类?')) return
try {
const token = localStorage.getItem('token')
await axios.delete(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/categories/${id}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
loadCategories()
} catch {
alert('删除失败')
}
}
const openEditModal = (category: Category) => {
setEditingCategory(category)
setFormData({
code: category.code,
name: category.name,
parent_id: category.parent_id?.toString() || ''
})
setShowModal(true)
}
const openNewModal = () => {
setEditingCategory(null)
setFormData({ code: '', name: '', parent_id: '' })
setShowModal(true)
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<button
onClick={openNewModal}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+
</button>
</div>
{/* 分类列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
{loading ? (
<div className="p-8 text-center text-gray-500">...</div>
) : categories.length === 0 ? (
<div className="p-8 text-center text-gray-500"></div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{categories.map((category) => (
<tr key={category.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{category.code}</td>
<td className="px-6 py-4 text-sm text-gray-900">{category.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{category.parent_name || '-'}</td>
<td className="px-6 py-4 text-sm text-gray-600">{category.product_count}</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={() => openEditModal(category)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
</button>
<button
onClick={() => handleDelete(category.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* 新建/编辑弹窗 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingCategory ? '编辑分类' : '新建分类'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="如: CAT-001"
className="w-full px-4 py-3 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入分类名称"
className="w-full px-4 py-3 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.parent_id}
onChange={(e) => setFormData({ ...formData, parent_id: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg"
>
<option value=""></option>
{categories
.filter((c) => c.id !== editingCategory?.id)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-4 mt-6">
<button
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
</button>
<button
onClick={handleSave}
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useEffect, useState } from 'react'
interface Stats {
products: number
categories: number
boms: number
users: number
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({
products: 0,
categories: 0,
boms: 0,
users: 0
})
const [loading, setLoading] = useState(true)
useEffect(() => {
// 模拟获取统计数据
setTimeout(() => {
setStats({
products: 12,
categories: 5,
boms: 8,
users: 3
})
setLoading(false)
}, 500)
}, [])
const quickActions = [
{ name: '新建产品', path: '/products/new', icon: '📦', color: 'bg-blue-500' },
{ name: '新建BOM', path: '/bom/new', icon: '🔧', color: 'bg-green-500' },
{ name: '产品分类', path: '/categories', icon: '📁', color: 'bg-yellow-500' },
]
return (
<div className="space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 mt-1">PLM </p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{loading ? '...' : stats.products}
</p>
</div>
<div className="text-4xl">📦</div>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{loading ? '...' : stats.categories}
</p>
</div>
<div className="text-4xl">📁</div>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm">BOM数量</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{loading ? '...' : stats.boms}
</p>
</div>
<div className="text-4xl">🔧</div>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{loading ? '...' : stats.users}
</p>
</div>
<div className="text-4xl">👥</div>
</div>
</div>
</div>
{/* 快捷操作 */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{quickActions.map((action) => (
<a
key={action.path}
href={action.path}
className={`${action.color} text-white p-4 rounded-lg flex items-center gap-3 hover:opacity-90 transition`}
>
<span className="text-2xl">{action.icon}</span>
<span className="font-medium">{action.name}</span>
</a>
))}
</div>
</div>
{/* 系统信息 */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="space-y-3 text-sm text-gray-600">
<p> 版本: v1.0.0 ()</p>
<p> 技术栈: Next.js + React + TypeScript</p>
<p> 上线日期: 2026-04-07</p>
<p> 开发团队: AIFLY</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
interface User {
id: number
username: string
full_name?: string
role: string
}
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
// 检查登录状态
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
if (!token || !userStr) {
router.push('/login')
return
}
setUser(JSON.parse(userStr))
}, [router])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
const menuItems = [
{ name: '工作台', path: '/dashboard', icon: '📊' },
{ name: '产品管理', path: '/products', icon: '📦' },
{ name: '产品分类', path: '/categories', icon: '📁' },
{ name: 'BOM管理', path: '/bom', icon: '🔧' },
{ name: '用户管理', path: '/users', icon: '👥' },
]
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
{/* 顶部导航 */}
<header className="bg-white shadow-sm border-b border-gray-200 fixed top-0 left-0 right-0 z-50">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="text-gray-500 hover:text-gray-700"
>
{sidebarOpen ? '◀' : '▶'}
</button>
<h1 className="text-xl font-bold text-indigo-600">PLM System</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-600">
{user.full_name || user.username}
</span>
<span className="text-xs bg-indigo-100 text-indigo-600 px-2 py-1 rounded">
{user.role}
</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-red-600 transition"
>
退
</button>
</div>
</div>
</header>
{/* 侧边栏 */}
<aside
className={`fixed left-0 top-[65px] bottom-0 bg-white border-r border-gray-200 transition-all duration-300 ${
sidebarOpen ? 'w-64' : 'w-0'
}`}
>
{sidebarOpen && (
<nav className="p-4">
<ul className="space-y-2">
{menuItems.map((item) => (
<li key={item.path}>
<Link
href={item.path}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 transition"
>
<span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.name}</span>
</Link>
</li>
))}
</ul>
</nav>
)}
</aside>
{/* 主内容区 */}
<main
className={`pt-[65px] transition-all duration-300 ${
sidebarOpen ? 'ml-64' : 'ml-0'
}`}
>
<div className="p-6">{children}</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
export default function NewProductPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
code: '',
name: '',
category: '',
description: '',
status: 'draft'
})
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const token = localStorage.getItem('token')
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/products`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
)
router.push('/products')
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.message) {
setError(err.response.data.message)
} else {
setError('创建失败,请稍后重试')
}
setLoading(false)
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 space-y-6">
{/* 产品编号 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="如: PRD-001"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* 产品名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入产品名称"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* 产品分类 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg"
>
<option value=""></option>
<option value="电子产品"></option>
<option value="配件"></option>
<option value="软件"></option>
<option value="服务"></option>
</select>
</div>
{/* 产品描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="请输入产品描述"
rows={4}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* 状态 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg"
>
<option value="draft">稿</option>
<option value="active"></option>
</select>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{/* 按钮 */}
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => router.back()}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{loading ? '创建中...' : '创建产品'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,183 @@
'use client'
import { useEffect, useState } from 'react'
import axios from 'axios'
import Link from 'next/link'
interface Product {
id: number
code: string
name: string
category?: string
status: string
created_at: string
}
export default function ProductsPage() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
useEffect(() => {
loadProducts()
}, [search, statusFilter])
const loadProducts = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/products`,
{
headers: { Authorization: `Bearer ${token}` },
params: {
keyword: search,
status: statusFilter
}
}
)
setProducts(response.data.data || response.data.items || [])
} catch (err) {
// 如果API未就绪显示模拟数据
setProducts([
{ id: 1, code: 'PRD-001', name: '智能音箱A1', category: '电子产品', status: 'active', created_at: '2026-04-01' },
{ id: 2, code: 'PRD-002', name: '智能音箱B2', category: '电子产品', status: 'draft', created_at: '2026-04-02' },
{ id: 3, code: 'PRD-003', name: '机械键盘K1', category: '配件', status: 'active', created_at: '2026-03-15' },
])
} finally {
setLoading(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该产品?')) return
try {
const token = localStorage.getItem('token')
await axios.delete(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/products/${id}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
loadProducts()
} catch {
alert('删除失败')
}
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
active: 'bg-green-100 text-green-700',
draft: 'bg-gray-100 text-gray-700',
inactive: 'bg-red-100 text-red-700',
}
const labels: Record<string, string> = {
active: '已发布',
draft: '草稿',
inactive: '已停用',
}
return (
<span className={`px-2 py-1 rounded text-xs ${styles[status] || styles.draft}`}>
{labels[status] || status}
</span>
)
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Link
href="/products/new"
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+
</Link>
</div>
{/* 搜索和筛选 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex gap-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索产品名称或编号..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
<option value=""></option>
<option value="active"></option>
<option value="draft">稿</option>
<option value="inactive"></option>
</select>
</div>
</div>
{/* 产品列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
{loading ? (
<div className="p-8 text-center text-gray-500">...</div>
) : products.length === 0 ? (
<div className="p-8 text-center text-gray-500"></div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
<th className="px-6 py-3 text-left text-sm font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-indigo-600">
<Link href={`/products/${product.id}`}>{product.code}</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-900">{product.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{product.category || '-'}</td>
<td className="px-6 py-4">{getStatusBadge(product.status)}</td>
<td className="px-6 py-4 text-sm text-gray-600">{product.created_at}</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link
href={`/products/${product.id}`}
className="text-indigo-600 hover:text-indigo-800 text-sm"
>
</Link>
<Link
href={`/products/${product.id}/edit`}
className="text-blue-600 hover:text-blue-800 text-sm"
>
</Link>
<button
onClick={() => handleDelete(product.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}

109
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
export default function LoginPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/auth/login`,
formData
)
const { access_token, user } = response.data
// 存储token
localStorage.setItem('token', access_token)
localStorage.setItem('user', JSON.stringify(user))
// 跳转到首页
router.push('/dashboard')
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.message) {
setError(err.response.data.message)
} else {
setError('登录失败,请检查用户名和密码')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="bg-white p-8 rounded-2xl shadow-xl w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-indigo-600">PLM System</h1>
<p className="text-gray-500 mt-2"></p>
</div>
{/* 登录表单 */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
placeholder="请输入用户名"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
placeholder="请输入密码"
required
/>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{/* 登录按钮 */}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-medium hover:bg-indigo-700 focus:ring-4 focus:ring-indigo-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
{/* 底部信息 */}
<div className="mt-6 text-center text-sm text-gray-500">
<p>© 2026 PLM System - AIFLY</p>
</div>
</div>
</div>
)
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from 'next/navigation'
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
redirect('/login')
}