commit a4fe324efd0dc284a267a4c07de85b6eb504f483 Author: gitadmin Date: Thu May 7 10:25:02 2026 +0800 chore: 初始化仓库 — 资产管理系统 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 ( + + +