From a4fe324efd0dc284a267a4c07de85b6eb504f483 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Thu, 7 May 2026 10:25:02 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=93?= =?UTF-8?q?=E5=BA=93=20=E2=80=94=20=E8=B5=84=E4=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + .gitignore | 15 + CHANGELOG.md | 61 + CLAUDE.md | 226 + Caddyfile | 3 + Dockerfile | 22 + README.md | 61 + docker-compose.yml | 34 + next-env.d.ts | 6 + next.config.ts | 9 + package-lock.json | 4143 +++++++++++++++++ package.json | 38 + postcss.config.mjs | 2 + scripts/import-servers.ts | 303 ++ scripts/init-db.ts | 6 + src/app/(app)/assets/[id]/edit/page.tsx | 50 + src/app/(app)/assets/[id]/page.tsx | 68 + src/app/(app)/assets/advanced-search/page.tsx | 123 + src/app/(app)/assets/batch-edit/page.tsx | 95 + src/app/(app)/assets/import/page.tsx | 27 + src/app/(app)/assets/new/page.tsx | 34 + src/app/(app)/assets/page.tsx | 583 +++ src/app/(app)/dashboard/page.tsx | 49 + src/app/(app)/layout.tsx | 22 + src/app/(app)/settings/api-keys/page.tsx | 116 + src/app/(app)/settings/page.tsx | 37 + src/app/(app)/settings/roles/page.tsx | 46 + src/app/(app)/settings/users/page.tsx | 126 + src/app/(auth)/login/LoginForm.tsx | 46 + src/app/(auth)/login/page.tsx | 12 + src/app/api/api-keys/[id]/route.ts | 27 + src/app/api/api-keys/route.ts | 53 + src/app/api/assets/[id]/route.ts | 111 + src/app/api/assets/batch/route.ts | 63 + src/app/api/assets/export/route.ts | 98 + src/app/api/assets/field-values/route.ts | 43 + src/app/api/assets/import/route.ts | 101 + src/app/api/assets/route.ts | 187 + src/app/api/assets/template/route.ts | 20 + src/app/api/assets/tickets/route.ts | 42 + src/app/api/auth/login/route.ts | 18 + src/app/api/auth/logout/route.ts | 7 + src/app/api/auth/me/route.ts | 17 + src/app/api/stats/route.ts | 52 + src/app/api/users/[id]/route.ts | 69 + src/app/api/users/route.ts | 54 + src/app/globals.css | 32 + src/app/layout.tsx | 20 + src/app/page.tsx | 2 + src/components/assets/AdvancedSearch.tsx | 219 + src/components/assets/AssetDetail.tsx | 151 + src/components/assets/AssetForm.tsx | 189 + src/components/assets/AssetImport.tsx | 85 + src/components/assets/AssetList.tsx | 288 ++ src/components/assets/AssetTicketHistory.tsx | 341 ++ src/components/assets/BatchEditForm.tsx | 71 + src/components/dashboard/RoomChart.tsx | 41 + src/components/dashboard/StatsOverview.tsx | 56 + src/components/dashboard/StatusChart.tsx | 45 + src/components/dashboard/TypeChart.tsx | 41 + src/components/layout/AppShell.tsx | 19 + src/components/layout/Sidebar.tsx | 44 + src/components/layout/TopBar.tsx | 36 + src/components/providers/ThemeProvider.tsx | 33 + src/components/ui/Autocomplete.tsx | 120 + src/components/ui/Badge.tsx | 19 + src/components/ui/Button.tsx | 20 + src/components/ui/Card.tsx | 14 + src/components/ui/FileUpload.tsx | 22 + src/components/ui/Input.tsx | 15 + src/components/ui/Modal.tsx | 22 + src/components/ui/Pagination.tsx | 65 + src/components/ui/Select.tsx | 17 + src/components/ui/Table.tsx | 26 + src/components/ui/Toast.tsx | 38 + src/lib/auth.ts | 49 + src/lib/db-schema.ts | 114 + src/lib/db.ts | 12 + src/lib/excel.ts | 146 + src/lib/issue-client.ts | 131 + src/lib/permissions.ts | 15 + src/middleware.ts | 47 + src/types/index.ts | 51 + tailwind.config.js | 7 + templates-docs/服务器信息-issue.xlsx | Bin 0 -> 50568 bytes tsconfig.json | 14 + 86 files changed, 10011 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 scripts/import-servers.ts create mode 100644 scripts/init-db.ts create mode 100644 src/app/(app)/assets/[id]/edit/page.tsx create mode 100644 src/app/(app)/assets/[id]/page.tsx create mode 100644 src/app/(app)/assets/advanced-search/page.tsx create mode 100644 src/app/(app)/assets/batch-edit/page.tsx create mode 100644 src/app/(app)/assets/import/page.tsx create mode 100644 src/app/(app)/assets/new/page.tsx create mode 100644 src/app/(app)/assets/page.tsx create mode 100644 src/app/(app)/dashboard/page.tsx create mode 100644 src/app/(app)/layout.tsx create mode 100644 src/app/(app)/settings/api-keys/page.tsx create mode 100644 src/app/(app)/settings/page.tsx create mode 100644 src/app/(app)/settings/roles/page.tsx create mode 100644 src/app/(app)/settings/users/page.tsx create mode 100644 src/app/(auth)/login/LoginForm.tsx create mode 100644 src/app/(auth)/login/page.tsx create mode 100644 src/app/api/api-keys/[id]/route.ts create mode 100644 src/app/api/api-keys/route.ts create mode 100644 src/app/api/assets/[id]/route.ts create mode 100644 src/app/api/assets/batch/route.ts create mode 100644 src/app/api/assets/export/route.ts create mode 100644 src/app/api/assets/field-values/route.ts create mode 100644 src/app/api/assets/import/route.ts create mode 100644 src/app/api/assets/route.ts create mode 100644 src/app/api/assets/template/route.ts create mode 100644 src/app/api/assets/tickets/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/stats/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/assets/AdvancedSearch.tsx create mode 100644 src/components/assets/AssetDetail.tsx create mode 100644 src/components/assets/AssetForm.tsx create mode 100644 src/components/assets/AssetImport.tsx create mode 100644 src/components/assets/AssetList.tsx create mode 100644 src/components/assets/AssetTicketHistory.tsx create mode 100644 src/components/assets/BatchEditForm.tsx create mode 100644 src/components/dashboard/RoomChart.tsx create mode 100644 src/components/dashboard/StatsOverview.tsx create mode 100644 src/components/dashboard/StatusChart.tsx create mode 100644 src/components/dashboard/TypeChart.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/layout/TopBar.tsx create mode 100644 src/components/providers/ThemeProvider.tsx create mode 100644 src/components/ui/Autocomplete.tsx create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/FileUpload.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/Pagination.tsx create mode 100644 src/components/ui/Select.tsx create mode 100644 src/components/ui/Table.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db-schema.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/excel.ts create mode 100644 src/lib/issue-client.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/middleware.ts create mode 100644 src/types/index.ts create mode 100644 tailwind.config.js create mode 100644 templates-docs/服务器信息-issue.xlsx create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89c778e --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DATABASE_PATH=./data/assets.db +JWT_SECRET=your-secret-key-change-in-production +NODE_ENV=development + +# issue-ai API 配置(用于故障历史功能) +# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置 +# 本地开发:http://localhost:5176/tickets +# 云上生产:https://issue.tlyq.ai/tickets +NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d85273 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.next/ +out/ +build/ +.DS_Store +*.pem +.env +.env.local +.env.development +.env.production +data/ +.claude/ +db-backups/ +.playwright-mcp/ +*.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..95d9b37 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# 变更日志 + +## 2026-04-30 + +- [新增] 创建 README 文档 + +## 2026-04-29 + +- [新增] 设备管理列表支持鼠标拖拽自定义列宽(首次拖拽自动快照列宽,最小 60px,支持自动换行) +- [优化] 设备状态下拉框改为「腾讯使用/图灵使用/闲置」默认选项,支持自定义新增状态 +- [优化] 数据库 106 台设备状态从「在用」迁移为「腾讯使用」 +- [修复] 设备详情页/列表页「腾讯使用」状态徽章颜色显示为灰色的问题 +- [优化] 列头居中显示,排序/筛选模式切换按钮与排序图标左右对称布局 +- [修复] 表头 `overflow-hidden` 导致筛选下拉框被裁切的问题 +- [优化] 筛选弹框宽度改为自适应(`w-fit min-w-48 max-w-80`) +- [优化] 排序/筛选图标改为可点击,直接点击图标即可触发排序或筛选 +- [修复] 列头排序图标使用 ` + + + + + + + + setShowDelete(false)} title="确认删除"> +

确定要删除这条资产记录吗?此操作不可恢复。

+
+ + +
+
+ + ) +} diff --git a/src/app/(app)/assets/advanced-search/page.tsx b/src/app/(app)/assets/advanced-search/page.tsx new file mode 100644 index 0000000..8baa736 --- /dev/null +++ b/src/app/(app)/assets/advanced-search/page.tsx @@ -0,0 +1,123 @@ +'use client' +import { useState, useCallback, useEffect } from 'react' +import Link from 'next/link' +import { ArrowLeft, Download } from 'lucide-react' +import AssetList from '@/components/assets/AssetList' +import AdvancedSearch, { type Filter } from '@/components/assets/AdvancedSearch' +import Button from '@/components/ui/Button' +import Modal from '@/components/ui/Modal' +import type { Asset, PaginatedResult } from '@/types' + +export default function AdvancedSearchPage() { + const [result, setResult] = useState>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState([]) + const [sortKey, setSortKey] = useState('id') + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + const [page, setPage] = useState(1) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [searched, setSearched] = useState(false) + const [showExportModal, setShowExportModal] = useState(false) + + const fetchData = useCallback(async () => { + if (filters.length === 0 && !searched) return + setLoading(true) + try { + const params = new URLSearchParams({ + page: String(page), pageSize: '20', sortKey, sortOrder, + ...(filters.length > 0 && { filters: JSON.stringify(filters) }), + }) + const res = await fetch(`/api/assets?${params}`) + if (res.ok) { + const data = await res.json() + setResult(data) + setSelectedIds(prev => { + const next = new Set() + for (const id of prev) { + if (data.data.some((a: Asset) => a.id === id)) next.add(id) + } + return next + }) + } + } finally { setLoading(false) } + }, [page, sortKey, sortOrder, filters, searched]) + + useEffect(() => { fetchData() }, [fetchData]) + + function handleSort(key: string) { + if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc') + else { setSortKey(key); setSortOrder('asc') } + } + + function handleToggleSelect(id: number) { + setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next }) + } + + function handleToggleAll() { + if (result.data.every(a => selectedIds.has(a.id))) setSelectedIds(new Set()) + else setSelectedIds(new Set(result.data.map(a => a.id))) + } + + function handleSearch(f: Filter[]) { + setFilters(f) + setPage(1) + setSearched(true) + } + + function handleExportConfirm() { + setShowExportModal(false) + const params = new URLSearchParams({ filters: JSON.stringify(filters) }) + window.open(`/api/assets/export?${params}`, '_blank') + } + + return ( +
+
+ + + +

高级查询

+ {searched && 共 {result.total} 条} + {searched && result.total > 0 && ( + + )} +
+ + + + {loading ? ( +
加载中...
+ ) : searched ? ( + result.data.length > 0 ? ( + {}} onSort={handleSort} sortKey={sortKey} sortOrder={sortOrder} onPageChange={setPage} /> + ) : ( +
未找到匹配的资产记录
+ ) + ) : ( +
设置查询条件后点击"查询"按钮
+ )} + + setShowExportModal(false)} title="导出资产数据"> +
+

+ 将导出 {result.total} 条匹配的资产记录。 +

+ {filters.length > 0 && ( +
+

查询条件:

+ {filters.map((f, i) => ( +

• {f.field} {f.op} {f.value}

+ ))} +
+ )} +
+
+ + +
+
+
+ ) +} diff --git a/src/app/(app)/assets/batch-edit/page.tsx b/src/app/(app)/assets/batch-edit/page.tsx new file mode 100644 index 0000000..a192b18 --- /dev/null +++ b/src/app/(app)/assets/batch-edit/page.tsx @@ -0,0 +1,95 @@ +'use client' +import { useState, useEffect, Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import BatchEditForm from '@/components/assets/BatchEditForm' +import Button from '@/components/ui/Button' +import Badge from '@/components/ui/Badge' +import { ArrowLeft } from 'lucide-react' +import type { Asset } from '@/types' + +function BatchEditContent() { + const router = useRouter() + const searchParams = useSearchParams() + const idsParam = searchParams.get('ids') || '' + const ids = idsParam.split(',').map(Number).filter(Boolean) + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState(false) + + useEffect(() => { + async function load() { + const results: Asset[] = [] + for (const id of ids) { + const res = await fetch(`/api/assets/${id}`) + if (res.ok) { const data = await res.json(); results.push(data.asset) } + } + setAssets(results) + } + if (ids.length > 0) load().finally(() => setLoading(false)) + else setLoading(false) + }, [idsParam]) + + async function handleSubmit(fields: Record) { + if (ids.length === 0) return + setSaving(true) + try { + const res = await fetch('/api/assets/batch', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, fields }), + }) + if (!res.ok) { const d = await res.json(); throw new Error(d.error || '批量更新失败') } + setSuccess(true) + } finally { setSaving(false) } + } + + if (ids.length === 0) { + return ( +
+

请先在资产列表中选择设备

+ +
+ ) + } + + if (loading) return
加载中...
+ + if (success) { + return ( +
+
批量更新成功
+

已更新 {assets.length} 台设备

+ +
+ ) + } + + return ( +
+
+ +

批量编辑

+
+ +
+

已选择 {assets.length} 台设备:

+
+ {assets.map(a => ( + {a.node_name || a.serial_number || `#${a.id}`} + ))} +
+
+ + +
+ ) +} + +export default function BatchEditPage() { + return ( + 加载中...}> + + + ) +} diff --git a/src/app/(app)/assets/import/page.tsx b/src/app/(app)/assets/import/page.tsx new file mode 100644 index 0000000..d4de382 --- /dev/null +++ b/src/app/(app)/assets/import/page.tsx @@ -0,0 +1,27 @@ +'use client' +import Link from 'next/link' +import AssetImport from '@/components/assets/AssetImport' +import { ArrowLeft } from 'lucide-react' + +export default function ImportPage() { + async function handleImport(file: File) { + const formData = new FormData() + formData.append('file', file) + const res = await fetch('/api/assets/import', { method: 'POST', body: formData }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || '导入失败') + return data as { created: number; updated: number; errors: string[] } + } + + return ( +
+
+ +

导入资产

+
+
+ +
+
+ ) +} diff --git a/src/app/(app)/assets/new/page.tsx b/src/app/(app)/assets/new/page.tsx new file mode 100644 index 0000000..eab4d15 --- /dev/null +++ b/src/app/(app)/assets/new/page.tsx @@ -0,0 +1,34 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import AssetForm from '@/components/assets/AssetForm' +import { ArrowLeft } from 'lucide-react' + +export default function NewAssetPage() { + const router = useRouter() + const [saving, setSaving] = useState(false) + + async function handleSubmit(data: Record) { + setSaving(true) + try { + const res = await fetch('/api/assets', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + }) + if (!res.ok) { const d = await res.json(); throw new Error(d.error || '创建失败') } + const result = await res.json() + router.push(`/assets/${result.asset.id}`) + router.refresh() + } finally { setSaving(false) } + } + + return ( +
+
+ +

新增资产

+
+ +
+ ) +} diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx new file mode 100644 index 0000000..85470f5 --- /dev/null +++ b/src/app/(app)/assets/page.tsx @@ -0,0 +1,583 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Pagination from '@/components/ui/Pagination' +import Badge from '@/components/ui/Badge' + +import Button from '@/components/ui/Button' +import Modal from '@/components/ui/Modal' +import type { Asset, PaginatedResult } from '@/types' +import { + Plus, Upload, Download, Search, ChevronUp, ChevronDown, ChevronsUpDown, + Eye, Edit, Trash2, Filter, ArrowUpDown, Check, +} from 'lucide-react' + +const statusColor: Record = { + '腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray', +} + +const COLUMNS = [ + { key: 'device_type', label: '设备类型', sortable: true }, + { key: 'node_name', label: '节点名称', sortable: true }, + { key: 'business_ip', label: '业务IP', sortable: true }, + { key: 'hdm_ip', label: 'HDM IP', sortable: true }, + { key: 'manufacturer', label: '厂商', sortable: true }, + { key: 'device_model', label: '设备型号', sortable: true }, + { key: 'serial_number', label: '序列号', sortable: true }, + { key: 'status', label: '状态', sortable: true }, +] + +export default function AssetsPage() { + const router = useRouter() + const [result, setResult] = useState>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [sortKey, setSortKey] = useState('device_type') + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [exportModalOpen, setExportModalOpen] = useState(false) + + // 列头筛选相关状态 + const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort') + const [columnFilterValues, setColumnFilterValues] = useState>({}) + const [openFilterColumn, setOpenFilterColumn] = useState(null) + const [filterOptions, setFilterOptions] = useState>({}) + const [filterLoading, setFilterLoading] = useState>({}) + const [filterSearch, setFilterSearch] = useState>({}) + const filterDropRef = useRef(null) + + // 列宽拖拽调整 + const [colWidths, setColWidths] = useState>({}) + const [resizingCol, setResizingCol] = useState(null) + const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null) + const [hasResized, setHasResized] = useState(false) + + function snapAllColumns() { + if (hasResized) return + setHasResized(true) + const widths: Record = {} + document.querySelectorAll('th[data-col-key]').forEach(th => { + const key = th.getAttribute('data-col-key') + if (key) widths[key] = th.getBoundingClientRect().width + }) + setColWidths(widths) + } + + function onResizeStart(colKey: string, e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + snapAllColumns() + const th = (e.target as HTMLElement).closest('th') + const startWidth = th?.getBoundingClientRect().width || 150 + resizeRef.current = { col: colKey, startX: e.clientX, startWidth } + setResizingCol(colKey) + document.body.style.userSelect = 'none' + document.body.style.cursor = 'col-resize' + } + + useEffect(() => { + if (!resizingCol) return + function onMouseMove(e: MouseEvent) { + if (!resizeRef.current) return + const { col, startX, startWidth } = resizeRef.current + setColWidths(prev => ({ ...prev, [col]: Math.max(60, startWidth + (e.clientX - startX)) })) + } + function onMouseUp() { + setResizingCol(null) + resizeRef.current = null + document.body.style.userSelect = '' + document.body.style.cursor = '' + } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + return () => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + }, [resizingCol]) + + // 关闭筛选下拉 + useEffect(() => { + function handleClick(e: MouseEvent) { + if (filterDropRef.current && !filterDropRef.current.contains(e.target as Node)) { + setOpenFilterColumn(null) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + // 加载某列的唯一值 + const loadColumnOptions = useCallback((field: string) => { + if (filterOptions[field]) return + setFilterLoading(prev => ({ ...prev, [field]: true })) + fetch(`/api/assets/field-values?field=${field}&q=`) + .then(r => r.json()) + .then(d => { + if (d.values) setFilterOptions(prev => ({ ...prev, [field]: d.values })) + }) + .catch(() => {}) + .finally(() => setFilterLoading(prev => ({ ...prev, [field]: false }))) + }, [filterOptions]) + + // 列头点击:排序或打开筛选 + function handleColumnClick(key: string, sortable: boolean) { + if (tableMode === 'sort') { + if (!sortable) return + if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc') + else { setSortKey(key); setSortOrder('asc') } + setPage(1) + } else { + if (openFilterColumn === key) { + setOpenFilterColumn(null) + } else { + setOpenFilterColumn(key) + loadColumnOptions(key) + } + } + } + + // 切换某列的筛选值 + function toggleFilterValue(column: string, value: string) { + setColumnFilterValues(prev => { + const current = prev[column] || [] + const next = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value] + const updated = { ...prev, [column]: next } + if (next.length === 0) delete updated[column] + return updated + }) + setPage(1) + } + + // 清空某列筛选 + function clearColumnFilter(column: string) { + setColumnFilterValues(prev => { + const next = { ...prev } + delete next[column] + return next + }) + setPage(1) + } + + // 排序图标(可点击触发排序/筛选) + function SortIcon({ colKey, sortable }: { colKey: string; sortable: boolean }) { + if (!sortable) return null + const icon = tableMode === 'filter' + ? + : sortKey !== colKey + ? + : sortOrder === 'asc' + ? + : + return ( + { e.stopPropagation(); handleColumnClick(colKey, sortable) }} + className={`ml-1 flex-shrink-0 rounded transition-colors cursor-pointer ${tableMode === 'sort' && sortKey === colKey ? 'text-blue-500' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`} + title={tableMode === 'sort' ? '点击排序' : '点击筛选'} + > + {icon} + + ) + } + + // 列头是否显示筛选图标(已选值时) + function FilterIndicator({ colKey }: { colKey: string }) { + const vals = columnFilterValues[colKey] + if (!vals || vals.length === 0) return null + return ( + + {vals.length} + + ) + } + + // 筛选下拉内容 + function FilterDropdown({ column }: { column: string }) { + const options = filterOptions[column] || [] + const selected = columnFilterValues[column] || [] + const searchVal = filterSearch[column] || '' + const loading = filterLoading[column] + const filtered = searchVal + ? options.filter(o => o.toLowerCase().includes(searchVal.toLowerCase())) + : options + const hasMore = options.length >= 50 + + return ( +
+ {/* 搜索框 */} +
+ setFilterSearch(prev => ({ ...prev, [column]: e.target.value }))} + placeholder="搜索..." + className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {/* 选项列表 */} +
+ {loading ? ( +
加载中...
+ ) : filtered.length === 0 ? ( +
无匹配结果
+ ) : ( + filtered.map(opt => { + const checked = selected.includes(opt) + return ( + + ) + }) + )} +
+ + {/* 底部操作 */} +
+ + {selected.length > 0 ? `已选 ${selected.length} 项${hasMore ? '(最多50项)' : ''}` : `${options.length} 个值`} + + {selected.length > 0 && ( + + )} +
+
+ ) + } + + // 切换模式:清除列筛选 + function switchMode(mode: 'sort' | 'filter') { + setTableMode(mode) + setOpenFilterColumn(null) + if (mode === 'sort') { + // 排序模式不清除筛选状态,保留 columnFilterValues + } + } + + const fetchAssets = useCallback(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + page: String(page), pageSize: String(pageSize), sortKey, sortOrder, + }) + if (search) params.set('search', search) + for (const [field, values] of Object.entries(columnFilterValues)) { + for (const v of values) { + params.append(`filter_${field}`, v) + } + } + const res = await fetch(`/api/assets?${params}`) + if (res.status === 401) { router.push('/login'); return } + const data = await res.json() + setResult(data) + } catch { setResult({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) } + finally { setLoading(false) } + }, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, router]) + + useEffect(() => { fetchAssets() }, [fetchAssets]) + + function handleSearch(q: string) { + setSearch(q); setPage(1) + } + + function handlePageChange(p: number) { setPage(p) } + function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) } + + function toggleSelect(id: number) { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function toggleAll() { + const allIds = result.data.map(a => a.id) + if (selectedIds.size === allIds.length) setSelectedIds(new Set()) + else setSelectedIds(new Set(allIds)) + } + + async function handleDelete(id: number) { + setDeleting(true) + try { + const res = await fetch(`/api/assets/${id}`, { method: 'DELETE' }) + if (res.ok) { setDeleteTarget(null); fetchAssets() } + } finally { setDeleting(false) } + } + + function handleExportConfirm() { + const params = new URLSearchParams() + if (search) params.set('search', search) + for (const [field, values] of Object.entries(columnFilterValues)) { + for (const v of values) { + params.append(`filter_${field}`, v) + } + } + setExportModalOpen(false) + window.open(`/api/assets/export?${params}`, '_blank') + } + + const { data, total, totalPages } = result + const allSelected = data.length > 0 && data.every(a => selectedIds.has(a.id)) + + // 是否有任何列筛选激活 + const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0) + + return ( +
+
+
+

设备管理

+

+ 共 {total} 台设备 + {selectedIds.size > 0 && ,已选中 {selectedIds.size}} +

+
+
+ {selectedIds.size > 0 && ( + + + + )} + + + + +
+
+ + {/* 搜索 + 高级筛选 */} +
+
+
+ + setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch(search)} + placeholder="快速搜索..." + className="w-full pl-9 pr-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + + +
+ + {/* 活跃列筛选标签 */} + {hasActiveColumnFilters && ( +
+ 列筛选: + {Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => { + const colDef = COLUMNS.find(c => c.key === col) + return ( + + {colDef?.label || col} + : {vals.join(', ')} + + + ) + })} + +
+ )} + + {/* 工具栏:每页行数 */} +
+
+ 每页 +
+ {[20, 50, 100].map(s => ( + + ))} +
+ +
+
+ + {/* 表格 */} +
+ + + + {/* 模式切换 + 全选 */} + + {COLUMNS.map(col => ( + + ))} + + + + + {loading ? ( + + ) : data.length === 0 ? ( + + ) : data.map(row => ( + + + + + + + + + + + + + ))} + +
+ + +
+
+ {/* 左侧:模式切换按钮 */} + + {/* 中间:列标题 */} + + {/* 右侧:排序/筛选图标 */} + + +
+ {openFilterColumn === col.key && ( +
+ +
+ )} +
+ {/* 右侧:拖拽手柄(宽点击区 + 细可见线) */} +
onResizeStart(col.key, e)} + > +
+
+
操作
加载中...
暂无数据
+ toggleSelect(row.id)} + className="rounded border-slate-300 dark:border-slate-600" /> + {row.device_type} + {row.node_name || '-'} + {row.business_ip || '-'}{row.hdm_ip ? {row.hdm_ip} : '-'}{row.manufacturer || '-'}{row.device_model || '-'}{row.serial_number || '-'}{row.status ?? '-'} +
+ + + + + + + +
+
+
+ + {/* 分页 */} +
+ +
+ + {/* 删除确认 */} + setDeleteTarget(null)} title="确认删除"> +

确定要删除这台设备吗?此操作不可撤销。

+
+ + +
+
+ + {/* 导出确认 */} + setExportModalOpen(false)} title="确认导出"> +

+ 即将导出 {total} 台设备的资产数据。 +

+ {(Object.keys(columnFilterValues).length > 0 || search) && ( +

当前筛选条件将一并应用,仅导出匹配的数据。

+ )} +
+ + +
+
+
+ ) +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..f1cd866 --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,49 @@ +'use client' +import { useState, useEffect } from 'react' +import StatsOverview from '@/components/dashboard/StatsOverview' +import StatusChart from '@/components/dashboard/StatusChart' +import TypeChart from '@/components/dashboard/TypeChart' +import RoomChart from '@/components/dashboard/RoomChart' + +interface StatsData { + total: number + byStatus: Array<{ status: string; count: number }> + byDeviceType: Array<{ device_type: string; count: number }> + byManufacturer: Array<{ manufacturer: string; count: number }> + byRoom: Array<{ room: string; count: number }> + warrantySoon: number + warrantyExpired: number +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/stats') + .then(r => r.json()) + .then(data => { if (data.total !== undefined) setStats(data) }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + if (loading) return
加载中...
+ + return ( +
+
+

仪表盘

+

资产统计概览

+
+ + + +
+ + +
+ + +
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..748142e --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,22 @@ +export const dynamic = 'force-dynamic' + +import { cookies, headers } from 'next/headers' +import { redirect } from 'next/navigation' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' +import AppShell from '@/components/layout/AppShell' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const cookieStore = await cookies() + const headersList = await headers() + const originalPath = headersList.get('x-original-pathname') || '' + const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '') + + const token = cookieStore.get('session_assets')?.value + if (!token) redirect(loginUrl) + const payload = verifyJwt(token) + if (!payload) redirect(loginUrl) + const user = db.prepare('SELECT display_name, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as { display_name: string; role: string } | undefined + if (!user) redirect(loginUrl) + return {children} +} diff --git a/src/app/(app)/settings/api-keys/page.tsx b/src/app/(app)/settings/api-keys/page.tsx new file mode 100644 index 0000000..858f36e --- /dev/null +++ b/src/app/(app)/settings/api-keys/page.tsx @@ -0,0 +1,116 @@ +'use client' +import { useState, useEffect } from 'react' +import Table, { Column } from '@/components/ui/Table' +import Button from '@/components/ui/Button' +import Input from '@/components/ui/Input' +import Modal from '@/components/ui/Modal' +import Badge from '@/components/ui/Badge' +import { Plus, Trash2, Copy, CheckCircle } from 'lucide-react' + +interface ApiKeyItem { + id: number; name: string; permissions: string; last_used_at: string | null; + expires_at: string | null; is_active: number; created_at: string +} + +export default function ApiKeysPage() { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + const [newKey, setNewKey] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [copied, setCopied] = useState(false) + + async function fetchKeys() { + const res = await fetch('/api/api-keys') + if (res.ok) { const data = await res.json(); setKeys(data.keys) } + } + + useEffect(() => { fetchKeys().finally(() => setLoading(false)) }, []) + + async function handleCreate() { + if (!name.trim()) return + setSaving(true) + try { + const res = await fetch('/api/api-keys', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim() }), + }) + if (res.ok) { + const data = await res.json() + setNewKey(data.key) + setCreateOpen(false) + setName('') + fetchKeys() + } + } finally { setSaving(false) } + } + + async function handleDelete() { + if (!deleteTarget) return + const res = await fetch(`/api/api-keys/${deleteTarget.id}`, { method: 'DELETE' }) + if (res.ok) { setDeleteTarget(null); fetchKeys() } + } + + function copyKey() { + if (newKey) { + navigator.clipboard.writeText(newKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + const columns: Column[] = [ + { key: 'name', title: '名称', render: (r) => {r.name} }, + { key: 'permissions', title: '权限', render: (r) => { + const perms: string[] = JSON.parse(r.permissions) + return
{perms.map(p => {p})}
+ }}, + { key: 'is_active', title: '状态', render: (r) => {r.is_active ? '启用' : '禁用'} }, + { key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' }, + { key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' }, + { key: 'created_at', title: '创建时间' }, + { key: 'actions', title: '操作', render: (r) => ( + + )}, + ] + + return ( +
+
+

API Key 管理

+ +
+ + {newKey && ( +
+

API Key 已创建(仅显示一次,请妥善保存)

+
+ {newKey} + +
+
+ )} + + {loading ?
加载中...
: r.id} />} + + setCreateOpen(false)} title="创建 API Key" footer={ + <> + + + + }> + setName(e.target.value)} placeholder="例如:监控系统" /> + + + setDeleteTarget(null)} title="确认删除"> +

确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法访问。

+
+ + +
+
+ + ) +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx new file mode 100644 index 0000000..fad700e --- /dev/null +++ b/src/app/(app)/settings/page.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link' +import Card from '@/components/ui/Card' +import { Users, Shield, KeyRound } from 'lucide-react' + +const items = [ + { href: '/settings/users', label: '用户管理', desc: '管理系统用户和访问权限', icon: Users }, + { href: '/settings/roles', label: '角色权限', desc: '配置角色和权限策略', icon: Shield }, + { href: '/settings/api-keys', label: 'API Key', desc: '管理 API 访问密钥', icon: KeyRound }, +] + +export default function SettingsPage() { + return ( +
+

系统设置

+
+ {items.map(item => { + const Icon = item.icon + return ( + + +
+
+ +
+
+

{item.label}

+

{item.desc}

+
+
+
+ + ) + })} +
+
+ ) +} diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx new file mode 100644 index 0000000..6ef10c6 --- /dev/null +++ b/src/app/(app)/settings/roles/page.tsx @@ -0,0 +1,46 @@ +'use client' +import Card from '@/components/ui/Card' +import Badge from '@/components/ui/Badge' + +const permissionLabels: Record = { + '*': '所有权限', + 'assets:read': '资产读取', + 'assets:write': '资产写入', + 'assets:delete': '资产删除', + 'users:read': '用户读取', + 'users:write': '用户管理', + 'api-keys:read': 'API Key 读取', + 'api-keys:write': 'API Key 管理', +} + +const defaultRoles = [ + { name: 'admin', display_name: '管理员', permissions: ['*'], color: 'blue' as const }, + { name: 'editor', display_name: '编辑者', permissions: ['assets:read', 'assets:write', 'assets:delete'], color: 'green' as const }, + { name: 'viewer', display_name: '查看者', permissions: ['assets:read'], color: 'gray' as const }, +] + +export default function RolesPage() { + return ( +
+

角色权限

+
+ {defaultRoles.map(role => ( + +
+
+ {role.name} +
+
+ {role.permissions.map(p => ( + + {permissionLabels[p] || p} + + ))} +
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx new file mode 100644 index 0000000..bb5931c --- /dev/null +++ b/src/app/(app)/settings/users/page.tsx @@ -0,0 +1,126 @@ +'use client' +import { useState, useEffect } from 'react' +import Table, { Column } from '@/components/ui/Table' +import Button from '@/components/ui/Button' +import Input from '@/components/ui/Input' +import Select from '@/components/ui/Select' +import Modal from '@/components/ui/Modal' +import Badge from '@/components/ui/Badge' +import { Plus, Edit, Trash2 } from 'lucide-react' + +interface UserItem { + id: number; username: string; display_name: string; email: string | null; + role: string; is_active: number; created_at: string +} + +const roles = [ + { value: 'admin', label: '管理员' }, + { value: 'editor', label: '编辑者' }, + { value: 'viewer', label: '查看者' }, +] + +export default function UsersPage() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' }) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + + async function fetchUsers() { + const res = await fetch('/api/users') + if (res.ok) { const data = await res.json(); setUsers(data.users) } + } + + useEffect(() => { fetchUsers().finally(() => setLoading(false)) }, []) + + function openCreate() { + setEditing(null) + setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' }) + setError('') + setModalOpen(true) + } + + function openEdit(user: UserItem) { + setEditing(user) + setForm({ username: user.username, password: '', display_name: user.display_name, email: user.email || '', role: user.role }) + setError('') + setModalOpen(true) + } + + async function handleSave() { + setSaving(true); setError('') + try { + if (editing) { + const body: Record = { display_name: form.display_name, email: form.email, role: form.role } + if (form.password) body.password = form.password + const res = await fetch(`/api/users/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + if (!res.ok) { const d = await res.json(); setError(d.error); return } + } else { + if (!form.username || !form.password) { setError('用户名和密码不能为空'); return } + const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }) + if (!res.ok) { const d = await res.json(); setError(d.error); return } + } + setModalOpen(false) + fetchUsers() + } finally { setSaving(false) } + } + + async function handleDelete() { + if (!deleteTarget) return + const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' }) + if (res.ok) { setDeleteTarget(null); fetchUsers() } + } + + const columns: Column[] = [ + { key: 'username', title: '用户名' }, + { key: 'display_name', title: '显示名称', render: (r) => {r.display_name} }, + { key: 'email', title: '邮箱', render: (r) => r.email || '-' }, + { key: 'role', title: '角色', render: (r) => {roles.find(ro => ro.value === r.role)?.label || r.role} }, + { key: 'is_active', title: '状态', render: (r) => {r.is_active ? '启用' : '禁用'} }, + { key: 'created_at', title: '创建时间' }, + { key: 'actions', title: '操作', render: (r) => ( +
+ + +
+ )}, + ] + + return ( +
+
+

用户管理

+ +
+ + {loading ?
加载中...
:
r.id} />} + + setModalOpen(false)} title={editing ? '编辑用户' : '新建用户'} footer={ + <> + + + + }> + {error &&
{error}
} +
+ {!editing && setForm(p => ({ ...p, username: e.target.value }))} />} + setForm(p => ({ ...p, display_name: e.target.value }))} /> + setForm(p => ({ ...p, email: e.target.value }))} /> + setForm(p => ({ ...p, password: e.target.value }))} /> + setUsername(e.target.value)} placeholder="请输入用户名" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+
+ + setPassword(e.target.value)} placeholder="请输入密码" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+ + + + + ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..b85471f --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react' +import { LoginForm } from './LoginForm' + +export const dynamic = 'force-dynamic' + +export default function LoginPage() { + return ( +
加载中...
}> + +
+ ) +} diff --git a/src/app/api/api-keys/[id]/route.ts b/src/app/api/api-keys/[id]/route.ts new file mode 100644 index 0000000..67e4a14 --- /dev/null +++ b/src/app/api/api-keys/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'api-keys:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + const existing = db.prepare('SELECT id FROM api_keys WHERE id = ?').get(id) + if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 }) + + db.prepare('DELETE FROM api_keys WHERE id = ?').run(id) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/api-keys/route.ts b/src/app/api/api-keys/route.ts new file mode 100644 index 0000000..7e24e3d --- /dev/null +++ b/src/app/api/api-keys/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt, generateApiKey, hashApiKey } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function GET() { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'api-keys:read')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const keys = db.prepare('SELECT id, name, permissions, last_used_at, expires_at, is_active, created_at FROM api_keys ORDER BY id DESC').all() + return NextResponse.json({ keys }) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'api-keys:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + try { + const body = await request.json() + const { name, permissions, expires_at } = body + if (!name) { + return NextResponse.json({ error: '名称不能为空' }, { status: 400 }) + } + + const key = generateApiKey() + const keyHash = hashApiKey(key) + const perms = JSON.stringify(permissions || ['assets:read']) + + const result = db.prepare('INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)') + .run(name, keyHash, perms, expires_at || null, session.userId) + + const apiKey = db.prepare('SELECT id, name, permissions, expires_at, is_active, created_at FROM api_keys WHERE id = ?').get(result.lastInsertRowid) + + return NextResponse.json({ key, apiKey }, { status: 201 }) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建 API Key 失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/assets/[id]/route.ts b/src/app/api/assets/[id]/route.ts new file mode 100644 index 0000000..783c02f --- /dev/null +++ b/src/app/api/assets/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt, verifyApiKey } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +function getApiKeyAuth(request: Request) { + const auth = request.headers.get('Authorization') || '' + if (!auth.startsWith('Bearer ')) return null + return verifyApiKey(auth.slice(7)) +} + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + const apiKey = getApiKeyAuth(request) + if (!session && !apiKey) return NextResponse.json({ error: '未授权' }, { status: 401 }) + + const { id } = await params + const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) + if (!asset) return NextResponse.json({ error: '资产不存在' }, { status: 404 }) + return NextResponse.json({ asset }) +} + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'assets:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + const existing = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) + if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 }) + + try { + const body = await request.json() + const fields = [ + 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', + 'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', + 'status', 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total', + 'gpu_model', 'gpu_power', 'gpu_count', + 'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count', + 'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count', + 'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count', + 'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count', + 'data_disk_total_space', + 'raid_model', 'raid_spec', 'raid_count', + 'psu1_model', 'psu1_power', 'psu1_count', + 'psu2_model', 'psu2_power', 'psu2_count', + 'psu_total_power', + 'board_model', 'board_count', + 'raw_data', + ] + + const updates: string[] = [] + const values: unknown[] = [] + for (const f of fields) { + if (body[f] !== undefined) { + updates.push(`${f} = ?`) + values.push(body[f] === '' ? null : body[f]) + } + } + + if (updates.length === 0) { + return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 }) + } + + updates.push("updated_at = datetime('now')") + values.push(id) + + db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values) + + db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'update', 'asset', ?, ?)`) + .run(session.userId, id, null) + + const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) + return NextResponse.json({ asset }) + } catch (e) { + const msg = e instanceof Error ? e.message : '更新失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'assets:delete')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + const existing = db.prepare('SELECT id FROM assets WHERE id = ?').get(id) + if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 }) + + db.prepare('DELETE FROM assets WHERE id = ?').run(id) + db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'delete', 'asset', ?, ?)`) + .run(session.userId, id, null) + + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/assets/batch/route.ts b/src/app/api/assets/batch/route.ts new file mode 100644 index 0000000..583ee0c --- /dev/null +++ b/src/app/api/assets/batch/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +const UPDATABLE_FIELDS = [ + 'device_type', 'device_purpose', 'room', 'rack_position', 'status', + 'manufacturer', 'device_model', 'warranty_date', +] + +export async function POST(request: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'assets:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + try { + const body = await request.json() + const { ids, fields } = body as { ids: number[]; fields: Record } + + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: '请选择设备' }, { status: 400 }) + } + if (!fields || typeof fields !== 'object' || Object.keys(fields).length === 0) { + return NextResponse.json({ error: '请指定要修改的字段' }, { status: 400 }) + } + + const updates: string[] = [] + const values: unknown[] = [] + for (const [key, value] of Object.entries(fields)) { + if (!UPDATABLE_FIELDS.includes(key)) continue + updates.push(`${key} = ?`) + values.push(value === '' ? null : value) + } + + if (updates.length === 0) { + return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 }) + } + + updates.push("updated_at = datetime('now')") + + const placeholders = ids.map(() => '?').join(', ') + const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`) + const result = stmt.run(...values, ...ids) + + db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'batch_update', 'asset', ?, ?)`) + .run(session.userId, JSON.stringify({ ids, fields }), null) + + return NextResponse.json({ updated: result.changes }) + } catch (e) { + const msg = e instanceof Error ? e.message : '批量更新失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/assets/export/route.ts b/src/app/api/assets/export/route.ts new file mode 100644 index 0000000..237452f --- /dev/null +++ b/src/app/api/assets/export/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' +import { exportAssetsToBuffer } from '@/lib/excel' + +const FILTERABLE_FIELDS = new Set([ + 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', + 'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status', + 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total', + 'gpu_model', 'gpu_power', + 'nic1_model', 'nic1_type', 'nic1_speed', + 'nic2_model', 'nic2_type', 'nic2_speed', + 'nic3_model', 'nic3_type', 'nic3_speed', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', + 'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', + 'data_disk_total_space', + 'raid_model', 'raid_spec', + 'psu1_model', 'psu1_power', + 'psu2_model', 'psu2_power', + 'psu_total_power', + 'board_model', +]) + +export async function GET(request: Request) { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const payload = verifyJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const { searchParams } = new URL(request.url) + const search = searchParams.get('search') || '' + + const conditions: string[] = [] + const params: unknown[] = [] + + if (search) { + conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`) + const s = `%${search}%` + params.push(s, s, s, s, s) + } + + // Generic filter_field=value params (multi-select: multiple params → IN clause) + const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_')) + for (const key of filterKeys) { + const field = key.replace('filter_', '') + const allValues = searchParams.getAll(key) + if (allValues.length === 0) continue + if (allValues.length === 1) { + conditions.push(`${field} = ?`); params.push(allValues[0]) + } else { + const placeholders = allValues.map(() => '?').join(', ') + conditions.push(`${field} IN (${placeholders})`) + params.push(...allValues) + } + } + + // Advanced search filters JSON + const filtersRaw = searchParams.get('filters') + if (filtersRaw) { + try { + const filters = JSON.parse(filtersRaw) as Array<{ field: string; op: string; value: string }> + for (const f of filters) { + if (!f.field || !FILTERABLE_FIELDS.has(f.field)) continue + const val = (f.value || '').trim() + switch (f.op) { + case 'contains': + conditions.push(`${f.field} LIKE ?`); params.push(`%${val}%`); break + case 'equals': + conditions.push(`${f.field} = ?`); params.push(val); break + case 'starts_with': + conditions.push(`${f.field} LIKE ?`); params.push(`${val}%`); break + case 'ends_with': + conditions.push(`${f.field} LIKE ?`); params.push(`%${val}`); break + case 'not_empty': + conditions.push(`${f.field} IS NOT NULL AND ${f.field} != ''`); break + case 'empty': + conditions.push(`(${f.field} IS NULL OR ${f.field} = '')`); break + } + } + } catch { /* ignore */ } + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const assets = db.prepare(`SELECT * FROM assets ${where} ORDER BY id DESC`).all(...params) as Record[] + + const buffer = exportAssetsToBuffer(assets) + return new NextResponse(new Uint8Array(buffer), { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="assets_export_${new Date().toISOString().slice(0, 10)}.xlsx"`, + }, + }) +} diff --git a/src/app/api/assets/field-values/route.ts b/src/app/api/assets/field-values/route.ts new file mode 100644 index 0000000..43b74d7 --- /dev/null +++ b/src/app/api/assets/field-values/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' + +const ALLOWED_FIELDS = new Set([ + 'device_type', 'device_purpose', 'room', 'rack_position', 'node_name', + 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'serial_number', + 'status', 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total', + 'gpu_model', 'gpu_power', + 'nic1_model', 'nic1_type', 'nic1_speed', + 'nic2_model', 'nic2_type', 'nic2_speed', + 'nic3_model', 'nic3_type', 'nic3_speed', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_type', 'data_disk1_protocol', + 'data_disk_total_space', + 'psu1_model', 'psu1_power', 'psu_total_power', + 'raid_model', 'board_model', +]) + +export async function GET(request: Request) { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const payload = verifyJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const { searchParams } = new URL(request.url) + const field = searchParams.get('field') || '' + const q = searchParams.get('q') || '' + + if (!ALLOWED_FIELDS.has(field)) { + return NextResponse.json({ values: [] }) + } + + const where = q ? `WHERE ${field} LIKE ? AND ${field} IS NOT NULL AND ${field} != ''` : `WHERE ${field} IS NOT NULL AND ${field} != ''` + const params = q ? [`%${q}%`] : [] + + const rows = db.prepare(`SELECT DISTINCT ${field} as val FROM assets ${where} ORDER BY ${field} LIMIT 20`).all(...params) as { val: string }[] + return NextResponse.json({ values: rows.map(r => r.val) }) +} diff --git a/src/app/api/assets/import/route.ts b/src/app/api/assets/import/route.ts new file mode 100644 index 0000000..d513d92 --- /dev/null +++ b/src/app/api/assets/import/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' +import { parseImportBuffer } from '@/lib/excel' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'assets:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + try { + const formData = await request.formData() + const file = formData.get('file') as File | null + if (!file) return NextResponse.json({ error: '请上传文件' }, { status: 400 }) + + const buffer = Buffer.from(await file.arrayBuffer()) + const { rows, errors } = parseImportBuffer(buffer) + + if (errors.length > 0 && rows.length === 0) { + return NextResponse.json({ errors }, { status: 400 }) + } + + const allFields = [ + 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', + 'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', + 'status', 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total', + 'gpu_model', 'gpu_power', 'gpu_count', + 'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count', + 'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count', + 'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count', + 'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count', + 'data_disk_total_space', + 'raid_model', 'raid_spec', 'raid_count', + 'psu1_model', 'psu1_power', 'psu1_count', + 'psu2_model', 'psu2_power', 'psu2_count', + 'psu_total_power', + 'board_model', 'board_count', + ] + + let created = 0 + let updated = 0 + + const insertTransaction = db.transaction(() => { + for (const row of rows) { + const sn = row.serial_number as string | null + if (sn) { + const existing = db.prepare('SELECT id FROM assets WHERE serial_number = ?').get(sn) as { id: number } | undefined + if (existing) { + const updates: string[] = [] + const values: unknown[] = [] + for (const f of allFields) { + if (row[f] !== undefined) { + updates.push(`${f} = ?`) + values.push(row[f]) + } + } + if (updates.length > 0) { + updates.push("updated_at = datetime('now')") + values.push(existing.id) + db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values) + updated++ + } + continue + } + } + + const present = allFields.filter(f => row[f] !== undefined && row[f] !== null) + if (present.length === 0) continue + const placeholders = present.map(() => '?').join(', ') + const values = present.map(f => row[f]) + db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values) + created++ + } + }) + + insertTransaction() + + db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'import', 'asset', ?, ?)`) + .run(session.userId, JSON.stringify({ created, updated }), null) + + return NextResponse.json({ created, updated, errors }) + } catch (e) { + const msg = e instanceof Error ? e.message : '导入失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts new file mode 100644 index 0000000..266f13e --- /dev/null +++ b/src/app/api/assets/route.ts @@ -0,0 +1,187 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt, verifyApiKey } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +function getUserFromCookie() { + return null as { userId: number; username: string; role: string } | null +} + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +function getApiKeyAuth(request: Request) { + const auth = request.headers.get('Authorization') || '' + if (!auth.startsWith('Bearer ')) return null + return verifyApiKey(auth.slice(7)) +} + +// 允许高级查询的字段 +const FILTERABLE_FIELDS = new Set([ + 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', + 'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status', + 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total', + 'gpu_model', 'gpu_power', + 'nic1_model', 'nic1_type', 'nic1_speed', + 'nic2_model', 'nic2_type', 'nic2_speed', + 'nic3_model', 'nic3_type', 'nic3_speed', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', + 'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', + 'data_disk_total_space', + 'raid_model', 'raid_spec', + 'psu1_model', 'psu1_power', + 'psu2_model', 'psu2_power', + 'psu_total_power', + 'board_model', +]) + +export async function GET(request: Request) { + const session = await getSession() + const apiKey = getApiKeyAuth(request) + if (!session && !apiKey) return NextResponse.json({ error: '未授权' }, { status: 401 }) + + const { searchParams } = new URL(request.url) + const page = Math.max(1, parseInt(searchParams.get('page') || '1')) + const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20'))) + const search = searchParams.get('search') || '' + const sortKey = searchParams.get('sortKey') || 'id' + const sortOrder = searchParams.get('sortOrder') || 'desc' + + const allowedSortKeys = new Set([ + 'id', 'serial_number', 'device_type', 'device_purpose', 'node_name', + 'business_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at' + ]) + const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'id' + const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC' + + const conditions: string[] = [] + const params: unknown[] = [] + + if (search) { + conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`) + const s = `%${search}%` + params.push(s, s, s, s, s) + } + + // Generic filter_field=value params (multi-select: multiple params with same key → IN clause) + const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_')) + for (const key of filterKeys) { + const field = key.replace('filter_', '') + const allValues = searchParams.getAll(key) + if (allValues.length === 0) continue + if (allValues.length === 1) { + conditions.push(`${field} = ?`); params.push(allValues[0]) + } else { + const placeholders = allValues.map(() => '?').join(', ') + conditions.push(`${field} IN (${placeholders})`) + params.push(...allValues) + } + } + + // 高级查询 filters: JSON encoded array of {field, op, value} + const filtersRaw = searchParams.get('filters') + if (filtersRaw) { + try { + const filters = JSON.parse(filtersRaw) as Array<{ field: string; op: string; value: string }> + for (const f of filters) { + if (!f.field || !FILTERABLE_FIELDS.has(f.field)) continue + const val = (f.value || '').trim() + switch (f.op) { + case 'contains': + conditions.push(`${f.field} LIKE ?`) + params.push(`%${val}%`) + break + case 'equals': + conditions.push(`${f.field} = ?`) + params.push(val) + break + case 'starts_with': + conditions.push(`${f.field} LIKE ?`) + params.push(`${val}%`) + break + case 'ends_with': + conditions.push(`${f.field} LIKE ?`) + params.push(`%${val}`) + break + case 'not_empty': + conditions.push(`${f.field} IS NOT NULL AND ${f.field} != ''`) + break + case 'empty': + conditions.push(`(${f.field} IS NULL OR ${f.field} = '')`) + break + } + } + } catch { /* ignore invalid filters JSON */ } + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const offset = (page - 1) * pageSize + + const total = (db.prepare(`SELECT COUNT(*) as count FROM assets ${where}`).get(...params) as { count: number }).count + const data = db.prepare(`SELECT * FROM assets ${where} ORDER BY ${safeSortKey} ${safeSortOrder} LIMIT ? OFFSET ?`).all(...params, pageSize, offset) + + return NextResponse.json({ + data, total, page, pageSize, + totalPages: Math.ceil(total / pageSize), + }) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'assets:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + try { + const body = await request.json() + if (!body.device_type) { + return NextResponse.json({ error: '设备类型不能为空' }, { status: 400 }) + } + + const fields = [ + 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', + 'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', + 'status', 'warranty_date', + 'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec', + 'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total', + 'gpu_model', 'gpu_power', 'gpu_count', + 'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count', + 'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count', + 'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count', + 'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count', + 'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count', + 'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count', + 'data_disk_total_space', + 'raid_model', 'raid_spec', 'raid_count', + 'psu1_model', 'psu1_power', 'psu1_count', + 'psu2_model', 'psu2_power', 'psu2_count', + 'psu_total_power', + 'board_model', 'board_count', + 'raw_data', + ] + + const present = fields.filter(f => body[f] !== undefined) + const placeholders = present.map(() => '?').join(', ') + const values = present.map(f => body[f] === '' ? null : body[f]) + + const result = db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values) + + db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'create', 'asset', ?, ?)`) + .run(session.userId, result.lastInsertRowid, null) + + const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid) + return NextResponse.json({ asset }, { status: 201 }) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/assets/template/route.ts b/src/app/api/assets/template/route.ts new file mode 100644 index 0000000..6ba0e24 --- /dev/null +++ b/src/app/api/assets/template/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { verifyJwt } from '@/lib/auth' +import { generateTemplateBuffer } from '@/lib/excel' + +export async function GET() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const payload = verifyJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const buffer = generateTemplateBuffer() + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': 'attachment; filename="asset_import_template.xlsx"', + }, + }) +} diff --git a/src/app/api/assets/tickets/route.ts b/src/app/api/assets/tickets/route.ts new file mode 100644 index 0000000..34b24f3 --- /dev/null +++ b/src/app/api/assets/tickets/route.ts @@ -0,0 +1,42 @@ +/** + * 代理路由:/api/assets/tickets + * 将请求转发至 issue-ai 的 /api/tickets/by-asset API + * + * GET /api/assets/tickets?deviceIp=xxx&deviceSn=xxx + * + * 环境变量: + * ISSUE_API_URL — issue-ai API 地址(默认 http://localhost:6176/api) + * ISSUE_API_KEY — API 密钥 + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getTicketsByAsset } from '@/lib/issue-client' + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const deviceIp = searchParams.get('deviceIp') || undefined + const deviceSn = searchParams.get('deviceSn') || undefined + + if (!deviceIp && !deviceSn) { + return NextResponse.json( + { error: '缺少必要参数:deviceIp 或 deviceSn' }, + { status: 400 } + ) + } + + try { + // 转发用户 cookie 用于 issue-ai 认证 + const cookie = request.headers.get('cookie') || undefined + const result = await getTicketsByAsset({ + ip: deviceIp, + sn: deviceSn, + cookie, + }) + return NextResponse.json(result) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '获取工单列表失败' + const cause = err instanceof Error ? err.cause : undefined + console.error('[/api/assets/tickets]', message, cause) + return NextResponse.json({ error: message, detail: String(cause) }, { status: 502 }) + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..e76980c --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyPassword, signJwt } from '@/lib/auth' +import type { User } from '@/types' + +export async function POST(request: Request) { + try { + const { username, password } = await request.json() + if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) + const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as User | undefined + if (!user || !verifyPassword(password, user.password_hash)) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) + const token = signJwt({ userId: user.id, username: user.username, role: user.role }) + const cookieStore = await cookies() + cookieStore.set('session_assets', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 86400, path: '/' }) + return NextResponse.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role } }) + } catch { return NextResponse.json({ error: '登录失败' }, { status: 500 }) } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..8ff59fc --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +export async function POST() { + const cookieStore = await cookies() + cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..cd64e8e --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' + +export async function GET() { + try { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const payload = verifyJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) + if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) + return NextResponse.json({ user }) + } catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) } +} diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..794bac8 --- /dev/null +++ b/src/app/api/stats/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt } from '@/lib/auth' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function GET() { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const total = (db.prepare('SELECT COUNT(*) as c FROM assets').get() as any).c + + const byStatus = db.prepare( + 'SELECT status, COUNT(*) as count FROM assets GROUP BY status ORDER BY count DESC' + ).all() + + const byDeviceType = db.prepare( + 'SELECT device_type, COUNT(*) as count FROM assets GROUP BY device_type ORDER BY count DESC' + ).all() + + const byManufacturer = db.prepare( + "SELECT manufacturer, COUNT(*) as count FROM assets WHERE manufacturer IS NOT NULL AND manufacturer != '' GROUP BY manufacturer ORDER BY count DESC" + ).all() + + const warrantySoon = (db.prepare( + "SELECT COUNT(*) as c FROM assets WHERE warranty_date IS NOT NULL AND warranty_date != '' AND date(warranty_date) <= date('now', '+90 days') AND date(warranty_date) >= date('now')" + ).get() as any).c + + const warrantyExpired = (db.prepare( + "SELECT COUNT(*) as c FROM assets WHERE warranty_date IS NOT NULL AND warranty_date != '' AND date(warranty_date) < date('now')" + ).get() as any).c + + const byRoom = db.prepare( + "SELECT room, COUNT(*) as count FROM assets WHERE room IS NOT NULL AND room != '' GROUP BY room ORDER BY count DESC LIMIT 10" + ).all() + + return NextResponse.json({ + total, + byStatus, + byDeviceType, + byManufacturer, + byRoom, + warrantySoon, + warrantyExpired, + }) +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..233335f --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt, hashPassword } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'users:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) + if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) + + try { + const body = await request.json() + const updates: string[] = [] + const values: unknown[] = [] + + if (body.display_name !== undefined) { updates.push('display_name = ?'); values.push(body.display_name) } + if (body.email !== undefined) { updates.push('email = ?'); values.push(body.email || null) } + if (body.role !== undefined) { updates.push('role = ?'); values.push(body.role) } + if (body.is_active !== undefined) { updates.push('is_active = ?'); values.push(body.is_active ? 1 : 0) } + if (body.password) { updates.push('password_hash = ?'); values.push(hashPassword(body.password)) } + + if (updates.length === 0) { + return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 }) + } + + updates.push("updated_at = datetime('now')") + values.push(id) + + db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values) + const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id) + return NextResponse.json({ user }) + } catch (e) { + const msg = e instanceof Error ? e.message : '更新用户失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'users:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + if (String(id) === String(session.userId)) { + return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 }) + } + + const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) + if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) + + db.prepare('DELETE FROM users WHERE id = ?').run(id) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..87562ef --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import db from '@/lib/db' +import { verifyJwt, hashPassword } from '@/lib/auth' +import { checkPermission } from '@/lib/permissions' + +async function getSession() { + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return null + return verifyJwt(token) +} + +export async function GET() { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'users:read')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all() + return NextResponse.json({ users }) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) + if (!checkPermission(session.role, 'users:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + try { + const body = await request.json() + const { username, password, display_name, email, role } = body + if (!username || !password || !display_name) { + return NextResponse.json({ error: '用户名、密码和显示名称不能为空' }, { status: 400 }) + } + + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username) + if (existing) { + return NextResponse.json({ error: '用户名已存在' }, { status: 409 }) + } + + const passwordHash = hashPassword(password) + const result = db.prepare('INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)') + .run(username, passwordHash, display_name, email || null, role || 'viewer') + + const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) + return NextResponse.json({ user }, { status: 201 }) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建用户失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..4984b64 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,32 @@ +@import "tailwindcss"; +@config "../../tailwind.config.js"; + +@layer base { + body { + @apply bg-slate-50 text-slate-900; + } + html.dark body { + @apply bg-slate-950 text-white; + } +} + +@layer components { + .card-label { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.48); + margin: 0; + } + html.dark .card-label { + color: rgba(255, 255, 255, 0.48); + } + .card-value { + font-size: 1.5rem; + font-weight: 700; + margin-top: 0.25rem; + color: #1d1d1f; + line-height: 1.2; + } + html.dark .card-value { + color: #ffffff; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..2132d02 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: '资产管理系统', + description: '设备资产管理系统', +} + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + +