From 00b9e990f2171fd21d9169d34d5ae98639c1c2d9 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Thu, 7 May 2026 21:47:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=94=B9=E9=80=A0=20+=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=9D=83=E9=99=90=E7=BB=86=E7=B2=92=E5=BA=A6=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 角色页完全重写,支持新建/编辑角色,12个细粒度权限复选框 - 导出权限拆分为 assets:export:selected 和 assets:export:all - 导出按钮显隐由权限驱动,选中设备后区分"导出选中"和"导出全部" - 内置角色权限自动同步最新默认值,自定义角色旧权限自动迁移 - 用户管理页角色下拉改为 API 动态获取 - 设备详情页空字段区块自动隐藏 - API filter 参数增加字段白名单校验 - 导出文件名日期改为本地时间 --- CHANGELOG.md | 11 ++ CLAUDE.md | 10 ++ README.md | 56 ++++--- src/app/(app)/assets/page.tsx | 58 +++++-- src/app/(app)/settings/roles/page.tsx | 227 ++++++++++++++++++++++---- src/app/(app)/settings/users/page.tsx | 28 +++- src/app/api/assets/export/route.ts | 114 ++++++++----- src/app/api/assets/route.ts | 13 +- src/app/api/auth/me/route.ts | 6 +- src/app/api/roles/[id]/route.ts | 64 ++++++++ src/app/api/roles/route.ts | 44 +++++ src/components/assets/AssetDetail.tsx | 215 ++++++++++++++---------- src/components/assets/AssetList.tsx | 4 +- src/lib/db-schema.ts | 19 ++- 14 files changed, 649 insertions(+), 220 deletions(-) create mode 100644 src/app/api/roles/[id]/route.ts create mode 100644 src/app/api/roles/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d9b37..ae848cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 变更日志 +## 2026-05-07 + +- [新增] 导出功能区分"导出选中"和"导出全部"两种模式,有选中时工具栏显示两个按钮独立操作 +- [新增] 角色权限系统完整改造,支持新建角色、编辑角色权限(10 个细粒度权限复选框) +- [新增] 用户管理页角色下拉从 API 动态获取,不再硬编码 +- [优化] 设备详情页空字段区块自动隐藏(如无数据盘 2 则不显示该卡片) +- [优化] 导出权限拆分为 `assets:export:selected`(导出选中)和 `assets:export:all`(导出全部),按钮显隐由权限驱动 +- [优化] 内置角色权限自动同步到最新默认值,自定义角色旧权限自动迁移 +- [修复] 资产列表 API 和导出 API 的 filter 参数增加字段白名单校验 +- [修复] 导出文件名日期改用本地时间,避免 UTC+8 时区偏移 + ## 2026-04-30 - [新增] 创建 README 文档 diff --git a/CLAUDE.md b/CLAUDE.md index 5d9f2f3..92b227a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,16 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets --- +--- + +## 开发规范 + +- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 +- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 +- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` + +--- + ## 故障排查 ```bash diff --git a/README.md b/README.md index f309218..98e8b4f 100644 --- a/README.md +++ b/README.md @@ -13,39 +13,51 @@ ## 功能 -- 设备 CRUD(创建、编辑、删除、搜索、筛选) -- 设备状态管理(腾讯使用 / 已回收(退役) / 备件 / 借用 / 故障) -- 设备详情(含硬件信息、历史工单卡片) +- 设备 CRUD(创建、编辑、删除、搜索、列筛选、高级查询) +- 设备状态管理(腾讯使用 / 图灵使用 / 闲置 / 备用 / 维修中 / 已下线) +- 设备详情(含完整硬件信息、历史工单卡片) +- 批量编辑(多选设备批量修改类型、位置、状态等) +- Excel 导入/导出(模板下载、多选导出、全量导出权限控制) - 工单历史联动(调用 issue-ai API 获取同 IP 历史工单) -- API Key 管理(支持服务间调用认证) -- 用户/角色权限管理 -- Excel 导出设备清单 +- API Key 管理(支持服务间调用认证,细粒度权限控制) +- 用户/角色权限管理(12 个细粒度权限,支持自定义角色) +- 审计日志 ## 设备字段 -| 字段 | 说明 | +| 分类 | 字段 | |------|------| -| node_name | 节点名称 | -| serial_number | 序列号 | -| device_type | 设备类型(GPU服务器 / 存储服务器) | -| business_ip | 业务 IP | -| hdm_ip | HDM 管理 IP | -| manufacturer | 厂商 | -| device_model | 设备型号 | -| status | 设备状态 | -| cabinet | 机柜 | -| asset_number | 资产编号 | -| remark | 备注 | +| 设备标识 | node_name、serial_number、device_type、device_purpose、status | +| 位置信息 | room、rack_position | +| 网络 | business_ip、hdm_ip、NIC×3(型号/类型/速率/数量) | +| 硬件规格 | manufacturer、device_model、warranty_date | +| CPU/内存 | cpu_model、cpu_generation、cpu_cores、cpu_threads、memory_total 等 | +| GPU | gpu_model、gpu_power、gpu_count | +| 存储 | sys_disk、data_disk1/2(型号/规格/容量/类型/协议)、raid_model、raid_spec | +| 电源 | psu1/2_model、psu1/2_power、psu_total_power | + +完整 schema 含 68 列硬件字段,详见 `src/lib/db-schema.ts`。 ## API 路由 | 方法 | 路径 | 说明 | |------|------|------| -| GET/POST | `/api/assets` | 设备列表 / 创建设备 | +| GET/POST | `/api/assets` | 设备列表(分页/搜索/列筛选/排序)/ 创建设备 | | GET/PUT/DELETE | `/api/assets/[id]` | 单个设备操作 | -| GET/POST | `/api/api-keys` | API Key 管理 | -| GET/POST | `/api/users` | 用户管理 | -| GET | `/api/stats` | 统计概览 | +| POST | `/api/assets/batch` | 批量修改设备 | +| POST | `/api/assets/import` | Excel 导入 | +| GET | `/api/assets/export` | Excel 导出 | +| GET | `/api/assets/field-values` | 获取字段可选值(列筛选下拉) | +| GET | `/api/stats` | 统计概览(按状态/类型/厂商/机房) | +| GET/POST | `/api/api-keys` | API Key 列表 / 创建(仅显示一次) | +| DELETE | `/api/api-keys/[id]` | 删除 Key | +| GET/POST | `/api/users` | 用户列表 / 创建 | +| GET/PUT/DELETE | `/api/users/[id]` | 单个用户操作 | +| GET/POST | `/api/roles` | 角色列表 / 创建 | +| PUT/DELETE | `/api/roles/[id]` | 更新角色权限 / 删除角色 | +| POST | `/api/auth/login` | 登录 | +| POST | `/api/auth/logout` | 登出 | +| GET | `/api/auth/me` | 当前用户信息及权限 | …… diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx index 85470f5..3b5600c 100644 --- a/src/app/(app)/assets/page.tsx +++ b/src/app/(app)/assets/page.tsx @@ -19,10 +19,10 @@ const statusColor: Record } 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: 'device_type', label: '设备类型', sortable: true }, { key: 'manufacturer', label: '厂商', sortable: true }, { key: 'device_model', label: '设备型号', sortable: true }, { key: 'serial_number', label: '序列号', sortable: true }, @@ -36,12 +36,14 @@ export default function AssetsPage() { const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [sortKey, setSortKey] = useState('device_type') + const [sortKey, setSortKey] = useState('node_name') 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 [exportMode, setExportMode] = useState<'all' | 'selected'>('all') + const [permissions, setPermissions] = useState([]) // 列头筛选相关状态 const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort') @@ -283,6 +285,12 @@ export default function AssetsPage() { } } + useEffect(() => { + fetch('/api/auth/me').then(r => r.json()).then(d => { + if (d.user?.permissions) setPermissions(d.user.permissions) + }).catch(() => {}) + }, []) + const fetchAssets = useCallback(async () => { setLoading(true) try { @@ -309,7 +317,7 @@ export default function AssetsPage() { setSearch(q); setPage(1) } - function handlePageChange(p: number) { setPage(p) } + function handlePageChange(p: number) { setPage(p); setSelectedIds(new Set()) } function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) } function toggleSelect(id: number) { @@ -337,10 +345,14 @@ export default function AssetsPage() { 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) + if (exportMode === 'selected' && selectedIds.size > 0) { + params.set('ids', [...selectedIds].join(',')) + } else { + 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) @@ -353,6 +365,9 @@ export default function AssetsPage() { // 是否有任何列筛选激活 const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0) + const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all') + const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected') + return (
@@ -371,7 +386,12 @@ export default function AssetsPage() { )} - + {canExportSelected && selectedIds.size > 0 && ( + + )} + {canExportAll && ( + + )}
@@ -519,12 +539,12 @@ export default function AssetsPage() { 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.device_type} {row.manufacturer || '-'} {row.device_model || '-'} {row.serial_number || '-'} @@ -566,12 +586,20 @@ export default function AssetsPage() { {/* 导出确认 */} - setExportModalOpen(false)} title="确认导出"> -

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

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

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

+ setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}> + {exportMode === 'selected' ? ( +

+ 即将导出已选中的 {selectedIds.size} 台设备的资产数据。 +

+ ) : ( + <> +

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

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

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

+ )} + )}
diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx index 6ef10c6..ba17dc2 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -1,46 +1,209 @@ 'use client' -import Card from '@/components/ui/Card' +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, Pencil, Trash2 } from 'lucide-react' -const permissionLabels: Record = { - '*': '所有权限', - 'assets:read': '资产读取', - 'assets:write': '资产写入', - 'assets:delete': '资产删除', - 'users:read': '用户读取', - 'users:write': '用户管理', - 'api-keys:read': 'API Key 读取', - 'api-keys:write': 'API Key 管理', +interface Role { + id: number + name: string + display_name: string + permissions: string + created_at: string } -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 }, +const allPermissions = [ + { key: 'assets:read', label: '查看资产' }, + { key: 'assets:write', label: '编辑资产' }, + { key: 'assets:delete', label: '删除资产' }, + { key: 'assets:export:selected', label: '导出选中资产' }, + { key: 'assets:export:all', label: '导出全部资产' }, + { key: 'users:read', label: '查看用户' }, + { key: 'users:write', label: '管理用户' }, + { key: 'roles:read', label: '查看角色' }, + { key: 'roles:write', label: '管理角色' }, + { key: 'api-keys:read', label: '查看API Key' }, + { key: 'api-keys:write', label: '管理API Key' }, ] +const BUILTIN_ROLES = ['admin', 'editor', 'viewer'] + +const roleColor: Record = { + admin: 'blue', editor: 'green', viewer: 'gray', +} + +function formatPermissions(permStr: string): string { + try { + const perms: string[] = JSON.parse(permStr) + if (perms.includes('*')) return '全部权限' + return perms.map(p => { + const f = allPermissions.find(a => a.key === p) + return f ? f.label : p + }).join(', ') || '无权限' + } catch { return '无权限' } +} + export default function RolesPage() { + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [modalOpen, setModalOpen] = useState(false) + const [editRole, setEditRole] = useState(null) + const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] }) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + const fetchRoles = async () => { + try { + const res = await fetch('/api/roles') + const d = await res.json() + if (d.roles) setRoles(d.roles) + } catch { /* ignore */ } + setLoading(false) + } + + useEffect(() => { fetchRoles() }, []) + + const openCreate = () => { + setEditRole(null) + setForm({ name: '', display_name: '', permissions: [] }) + setError('') + setModalOpen(true) + } + + const openEdit = (r: Role) => { + setEditRole(r) + let perms: string[] = [] + try { perms = JSON.parse(r.permissions) } catch { /* ignore */ } + setForm({ name: r.name, display_name: r.display_name, permissions: perms }) + setError('') + setModalOpen(true) + } + + const togglePermission = (key: string) => { + setForm(prev => ({ + ...prev, + permissions: prev.permissions.includes(key) + ? prev.permissions.filter(p => p !== key) + : [...prev.permissions, key], + })) + } + + const handleSave = async () => { + setError('') + setSaving(true) + try { + if (editRole) { + const res = await fetch(`/api/roles/${editRole.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }), + }) + if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return } + } else { + if (!form.name || !form.display_name) { setError('请填写必填项'); return } + const res = await fetch('/api/roles', { + 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) + fetchRoles() + } catch { setError('操作失败') } + finally { setSaving(false) } + } + + const handleDelete = async (id: number) => { + if (!confirm('确定删除此角色?')) return + const res = await fetch(`/api/roles/${id}`, { method: 'DELETE' }) + if (res.ok) fetchRoles() + else { const d = await res.json(); alert(d.error || '删除失败') } + } + + const columns: Column[] = [ + { key: 'name', title: '角色名', render: (r) => ( + {r.name} + )}, + { key: 'display_name', title: '显示名称', render: (r) => ( +
+ {r.display_name} + {BUILTIN_ROLES.includes(r.name) && ( + 内置 + )} +
+ )}, + { key: 'permissions', title: '权限', render: (r) => ( + {formatPermissions(r.permissions)} + )}, + { key: 'actions', title: '操作', width: '100px', render: (r) => ( +
+ + {!BUILTIN_ROLES.includes(r.name) && ( + + )} +
+ )}, + ] + return (
-

角色权限

-
- {defaultRoles.map(role => ( - -
-
- {role.name} -
-
- {role.permissions.map(p => ( - - {permissionLabels[p] || p} - - ))} -
-
-
- ))} +
+
+

角色权限

+

管理系统角色与权限配置

+
+
+ + {loading ? ( +
加载中...
+ ) : ( + >[]} data={roles as unknown as Record[]} rowKey={(r) => String(r.id)} /> + )} + + setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}> +
+ {!editRole && ( + setForm(p => ({ ...p, name: e.target.value }))} + placeholder="e.g. supervisor" + /> + )} + setForm(p => ({ ...p, display_name: e.target.value }))} + /> +
+ +
+ {allPermissions.map(p => ( + + ))} +
+
+ {error &&

{error}

} +
+ + +
+
+
) } diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx index bb5931c..4f902c0 100644 --- a/src/app/(app)/settings/users/page.tsx +++ b/src/app/(app)/settings/users/page.tsx @@ -13,15 +13,10 @@ interface UserItem { 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 [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([]) const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' }) @@ -34,7 +29,19 @@ export default function UsersPage() { if (res.ok) { const data = await res.json(); setUsers(data.users) } } - useEffect(() => { fetchUsers().finally(() => setLoading(false)) }, []) + async function fetchRoles() { + try { + const res = await fetch('/api/roles') + const d = await res.json() + if (d.roles) { + setRoleOptions(d.roles.map((r: { name: string; display_name: string }) => ({ value: r.name, label: r.display_name }))) + } + } catch { /* ignore */ } + } + + useEffect(() => { + Promise.all([fetchUsers(), fetchRoles()]).finally(() => setLoading(false)) + }, []) function openCreate() { setEditing(null) @@ -78,7 +85,10 @@ export default function UsersPage() { { 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: 'role', title: '角色', render: (r) => { + const option = roleOptions.find(ro => ro.value === r.role) + return {option?.label || r.role} + } }, { key: 'is_active', title: '状态', render: (r) => {r.is_active ? '启用' : '禁用'} }, { key: 'created_at', title: '创建时间' }, { key: 'actions', title: '操作', render: (r) => ( @@ -110,7 +120,7 @@ export default function UsersPage() { setForm(p => ({ ...p, display_name: e.target.value }))} /> setForm(p => ({ ...p, email: e.target.value }))} /> setForm(p => ({ ...p, password: e.target.value }))} /> - setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} /> diff --git a/src/app/api/assets/export/route.ts b/src/app/api/assets/export/route.ts index 237452f..eeaecec 100644 --- a/src/app/api/assets/export/route.ts +++ b/src/app/api/assets/export/route.ts @@ -2,6 +2,7 @@ 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 { exportAssetsToBuffer } from '@/lib/excel' const FILTERABLE_FIELDS = new Set([ @@ -31,68 +32,95 @@ export async function GET(request: Request) { if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) const payload = verifyJwt(token) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 }) const { searchParams } = new URL(request.url) const search = searchParams.get('search') || '' + const idsParam = searchParams.get('ids') || '' + const ids = idsParam ? idsParam.split(',').map(Number).filter(n => !isNaN(n)) : [] + + // 权限控制 + if (ids.length > 0) { + if (!checkPermission(payload.role, 'assets:export:selected')) { + return NextResponse.json({ error: '无导出选中资产权限' }, { status: 403 }) + } + } else { + if (!checkPermission(payload.role, 'assets:export:all')) { + if (!checkPermission(payload.role, 'assets:export:selected')) { + return NextResponse.json({ error: '无导出权限' }, { status: 403 }) + } + return NextResponse.json({ error: '无全量导出权限,请先多选设备后再导出' }, { status: 403 }) + } + } 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) + // 按选中 ID 导出时,直接使用 ID 列表,忽略搜索/筛选条件 + if (ids.length > 0) { + const placeholders = ids.map(() => '?').join(', ') + conditions.push(`id IN (${placeholders})`) + params.push(...ids) + } else { + 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) } - } - // 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 - } + // 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_', '') + if (!FILTERABLE_FIELDS.has(field)) continue + 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) } - } catch { /* ignore */ } + } + + // 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) + const today = new Date() + const dateStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}` 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"`, + 'Content-Disposition': `attachment; filename="assets_export_${dateStr}.xlsx"`, }, }) } diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 266f13e..bd2fc8f 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -4,10 +4,6 @@ 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 @@ -52,14 +48,14 @@ export async function GET(request: Request) { 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 sortKey = searchParams.get('sortKey') || 'node_name' + const sortOrder = searchParams.get('sortOrder') || 'asc' const allowedSortKeys = new Set([ 'id', 'serial_number', 'device_type', 'device_purpose', 'node_name', - 'business_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at' + 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at' ]) - const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'id' + const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'node_name' const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC' const conditions: string[] = [] @@ -75,6 +71,7 @@ export async function GET(request: Request) { const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_')) for (const key of filterKeys) { const field = key.replace('filter_', '') + if (!FILTERABLE_FIELDS.has(field)) continue const allValues = searchParams.getAll(key) if (allValues.length === 0) continue if (allValues.length === 1) { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index cd64e8e..1f42ce1 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -10,8 +10,10 @@ export async function GET() { 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) + const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as Record | undefined if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) - return NextResponse.json({ user }) + const roleRow = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined + const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : [] + return NextResponse.json({ user: { ...user, permissions } }) } catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) } } diff --git a/src/app/api/roles/[id]/route.ts b/src/app/api/roles/[id]/route.ts new file mode 100644 index 0000000..f406fea --- /dev/null +++ b/src/app/api/roles/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, 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 { initDatabase } from '@/lib/db-schema' + +const BUILTIN_ROLES = ['admin', 'editor', 'viewer'] + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + initDatabase() + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const session = verifyJwt(token) + if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + + const { id } = await params + const body = await request.json() + + const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as Record | undefined + if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 }) + + const fields: string[] = [] + const values: unknown[] = [] + + if (body.display_name) { fields.push('display_name = ?'); values.push(body.display_name) } + if (body.permissions) { fields.push('permissions = ?'); values.push(JSON.stringify(body.permissions)) } + + if (fields.length > 0) { + values.push(id) + db.prepare(`UPDATE roles SET ${fields.join(', ')} WHERE id = ?`).run(...values) + } + + const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) + return NextResponse.json({ role }) +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + initDatabase() + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const session = verifyJwt(token) + if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + + const { id } = await params + const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as Record | undefined + if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 }) + if (BUILTIN_ROLES.includes(existing.name as string)) { + return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 }) + } + + db.prepare('DELETE FROM roles WHERE id = ?').run(id) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/roles/route.ts b/src/app/api/roles/route.ts new file mode 100644 index 0000000..22a11ea --- /dev/null +++ b/src/app/api/roles/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, 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 { initDatabase } from '@/lib/db-schema' + +export async function GET() { + initDatabase() + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const session = verifyJwt(token) + if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const roles = db.prepare('SELECT * FROM roles ORDER BY id').all() + return NextResponse.json({ roles }) +} + +export async function POST(request: NextRequest) { + initDatabase() + const cookieStore = await cookies() + const token = cookieStore.get('session_assets')?.value + if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) + const session = verifyJwt(token) + if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + + const body = await request.json() + const { name, display_name, permissions } = body + + if (!name || !display_name) { + return NextResponse.json({ error: '角色名称和显示名称为必填项' }, { status: 400 }) + } + + const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(name) + if (existing) return NextResponse.json({ error: '角色名已存在' }, { status: 400 }) + + const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run( + name, display_name, JSON.stringify(permissions || []) + ) + const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid) + return NextResponse.json({ role }, { status: 201 }) +} diff --git a/src/components/assets/AssetDetail.tsx b/src/components/assets/AssetDetail.tsx index 5de7dd0..3cc360a 100644 --- a/src/components/assets/AssetDetail.tsx +++ b/src/components/assets/AssetDetail.tsx @@ -19,16 +19,35 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) { } function Section({ title, children }: { title: string; children: React.ReactNode }) { - const items = Array.isArray(children) ? children.filter(Boolean) : children return (

{title}

-
{items}
+
{children}
) } +function hasAny(a: Asset, keys: (keyof Asset)[]): boolean { + return keys.some(k => { + const v = a[k] + return v != null && v !== '' + }) +} + export default function AssetDetail({ asset: a }: AssetDetailProps) { + const cpuKeys = ['cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_threads', 'cpu_count', 'cpu_spec'] as (keyof Asset)[] + const memKeys = ['memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total'] as (keyof Asset)[] + const gpuKeys = ['gpu_model', 'gpu_power', 'gpu_count'] as (keyof Asset)[] + const nic1Keys = ['nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count'] as (keyof Asset)[] + const nic2Keys = ['nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count'] as (keyof Asset)[] + const nic3Keys = ['nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count'] as (keyof Asset)[] + const sysDiskKeys = ['sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count'] as (keyof Asset)[] + const dataDisk1Keys = ['data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count'] as (keyof Asset)[] + const dataDisk2Keys = ['data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count'] as (keyof Asset)[] + const raidKeys = ['data_disk_total_space', 'raid_model', 'raid_spec', 'raid_count'] as (keyof Asset)[] + const psuKeys = ['psu1_model', 'psu1_power', 'psu1_count', 'psu2_model', 'psu2_power', 'psu2_count', 'psu_total_power'] as (keyof Asset)[] + const boardKeys = ['board_model', 'board_count'] as (keyof Asset)[] + return (
@@ -50,101 +69,125 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) { -
- - - - - - -
+ {hasAny(a, cpuKeys) && ( +
+ + + + + + +
+ )} -
- - - - - -
+ {hasAny(a, memKeys) && ( +
+ + + + + +
+ )} -
- - - -
+ {hasAny(a, gpuKeys) && ( +
+ + + +
+ )} -
- - - - -
+ {hasAny(a, nic1Keys) && ( +
+ + + + +
+ )} -
- - - - -
+ {hasAny(a, nic2Keys) && ( +
+ + + + +
+ )} -
- - - - -
+ {hasAny(a, nic3Keys) && ( +
+ + + + +
+ )} -
- - - - - - - -
+ {hasAny(a, sysDiskKeys) && ( +
+ + + + + + + +
+ )} -
- - - - - - - -
+ {hasAny(a, dataDisk1Keys) && ( +
+ + + + + + + +
+ )} -
- - - - - - - -
+ {hasAny(a, dataDisk2Keys) && ( +
+ + + + + + + +
+ )} -
- - - - -
+ {hasAny(a, raidKeys) && ( +
+ + + + +
+ )} -
- - - - - - - -
+ {hasAny(a, psuKeys) && ( +
+ + + + + + + +
+ )} -
- - -
+ {hasAny(a, boardKeys) && ( +
+ + +
+ )}
) diff --git a/src/components/assets/AssetList.tsx b/src/components/assets/AssetList.tsx index 20ec785..673164c 100644 --- a/src/components/assets/AssetList.tsx +++ b/src/components/assets/AssetList.tsx @@ -21,9 +21,9 @@ interface AssetListProps { } const COLUMNS = [ - { key: 'device_type', label: '设备类型', sortable: true, filterable: true }, { key: 'node_name', label: '节点名称', sortable: true, filterable: true }, { key: 'business_ip', label: '业务IP', sortable: true, filterable: true }, + { key: 'device_type', label: '设备类型', sortable: true, filterable: true }, { key: 'manufacturer', label: '厂商', sortable: true, filterable: true }, { key: 'device_model', label: '设备型号', sortable: true, filterable: true }, { key: 'serial_number', label: '序列号', sortable: true, filterable: true }, @@ -212,11 +212,11 @@ export default function AssetList({ onToggleSelect(row.id)} className="rounded border-slate-300 dark:border-slate-600" /> -
+ diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index 59b47b0..2110243 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -101,14 +101,31 @@ export function initDatabase() { } const defaultRoles = [ { name: 'admin', display_name: '管理员', permissions: '["*"]' }, - { name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete"]' }, + { name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete","assets:export:selected"]' }, { name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' }, ] + const builtinNames = new Set(defaultRoles.map(r => r.name)) for (const role of defaultRoles) { const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(role.name) if (!existing) { db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)') .run(role.name, role.display_name, role.permissions) + } else { + // 内置角色始终同步到最新默认权限 + db.prepare('UPDATE roles SET permissions = ? WHERE name = ?').run(role.permissions, role.name) + } + } + + // 迁移自定义角色中遗留的旧 assets:export 权限(拆分为 selected/all) + const allRoles = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[] + for (const r of allRoles) { + if (builtinNames.has(r.name)) continue + const perms: string[] = JSON.parse(r.permissions) + if (perms.includes('assets:export')) { + const upgraded = perms.filter(p => p !== 'assets:export') + upgraded.push('assets:export:selected') + if (perms.includes('*')) upgraded.push('assets:export:all') + db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id) } } }
{row.device_type} {row.node_name || '-'} {row.business_ip || '-'}{row.device_type} {row.manufacturer || '-'} {row.device_model || '-'} {row.serial_number || '-'}