Initial commit: PLM Web frontend project
This commit is contained in:
10
next.log
Normal file
10
next.log
Normal 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
|
||||
}
|
||||
@@ -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
76
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
202
src/app/(dashboard)/bom/page.tsx
Normal file
202
src/app/(dashboard)/bom/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
src/app/(dashboard)/categories/page.tsx
Normal file
247
src/app/(dashboard)/categories/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
src/app/(dashboard)/dashboard/page.tsx
Normal file
128
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/app/(dashboard)/layout.tsx
Normal file
124
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/app/(dashboard)/products/new/page.tsx
Normal file
156
src/app/(dashboard)/products/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
src/app/(dashboard)/products/page.tsx
Normal file
183
src/app/(dashboard)/products/page.tsx
Normal 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
109
src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user