feat: 角色权限系统改造 + 导出权限细粒度拆分
- 角色页完全重写,支持新建/编辑角色,12个细粒度权限复选框 - 导出权限拆分为 assets:export:selected 和 assets:export:all - 导出按钮显隐由权限驱动,选中设备后区分"导出选中"和"导出全部" - 内置角色权限自动同步最新默认值,自定义角色旧权限自动迁移 - 用户管理页角色下拉改为 API 动态获取 - 设备详情页空字段区块自动隐藏 - API filter 参数增加字段白名单校验 - 导出文件名日期改为本地时间
This commit is contained in:
parent
a4fe324efd
commit
00b9e990f2
11
CHANGELOG.md
11
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 文档
|
||||
|
|
|
|||
10
CLAUDE.md
10
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
|
||||
|
|
|
|||
56
README.md
56
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` | 当前用户信息及权限 |
|
||||
|
||||
……
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ const statusColor: Record<string, 'blue' | 'green' | '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: '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<Set<number>>(new Set())
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [exportMode, setExportMode] = useState<'all' | 'selected'>('all')
|
||||
const [permissions, setPermissions] = useState<string[]>([])
|
||||
|
||||
// 列头筛选相关状态
|
||||
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,12 +345,16 @@ export default function AssetsPage() {
|
|||
|
||||
function handleExportConfirm() {
|
||||
const params = new URLSearchParams()
|
||||
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)
|
||||
window.open(`/api/assets/export?${params}`, '_blank')
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
|
@ -371,7 +386,12 @@ export default function AssetsPage() {
|
|||
)}
|
||||
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
||||
<Link href="/assets/template"><Button variant="secondary" size="sm"><Download size={14} />模板</Button></Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => setExportModalOpen(true)}><Download size={14} />导出</Button>
|
||||
{canExportSelected && selectedIds.size > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />导出选中({selectedIds.size})</Button>
|
||||
)}
|
||||
{canExportAll && (
|
||||
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
|
||||
)}
|
||||
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -519,12 +539,12 @@ export default function AssetsPage() {
|
|||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
|
||||
<td className="px-4 py-3 font-medium text-center break-all">
|
||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.business_ip || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.hdm_ip ? <a href={`http://${row.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{row.hdm_ip}</a> : '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.manufacturer || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_model || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.serial_number || '-'}</td>
|
||||
|
|
@ -566,13 +586,21 @@ export default function AssetsPage() {
|
|||
</Modal>
|
||||
|
||||
{/* 导出确认 */}
|
||||
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title="确认导出">
|
||||
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}>
|
||||
{exportMode === 'selected' ? (
|
||||
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||||
即将导出已选中的 <span className="font-medium">{selectedIds.size}</span> 台设备的资产数据。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||||
即将导出 <span className="font-medium">{total}</span> 台设备的资产数据。
|
||||
</p>
|
||||
{(Object.keys(columnFilterValues).length > 0 || search) && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">当前筛选条件将一并应用,仅导出匹配的数据。</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="secondary" onClick={() => setExportModalOpen(false)}>取消</Button>
|
||||
<Button onClick={handleExportConfirm}><Download size={14} />确认导出</Button>
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'*': '所有权限',
|
||||
'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<string, 'blue' | 'green' | 'gray' | 'yellow'> = {
|
||||
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<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editRole, setEditRole] = useState<Role | null>(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<Role>[] = [
|
||||
{ key: 'name', title: '角色名', render: (r) => (
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{r.name}</span>
|
||||
)},
|
||||
{ key: 'display_name', title: '显示名称', render: (r) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{r.display_name}</span>
|
||||
{BUILTIN_ROLES.includes(r.name) && (
|
||||
<Badge color={roleColor[r.name] || 'gray'}>内置</Badge>
|
||||
)}
|
||||
</div>
|
||||
)},
|
||||
{ key: 'permissions', title: '权限', render: (r) => (
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{formatPermissions(r.permissions)}</span>
|
||||
)},
|
||||
{ key: 'actions', title: '操作', width: '100px', render: (r) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
|
||||
{!BUILTIN_ROLES.includes(r.name) && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
)}
|
||||
</div>
|
||||
)},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{defaultRoles.map(role => (
|
||||
<Card key={role.name} title={role.display_name}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color={role.color}>{role.name}</Badge>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">管理系统角色与权限配置</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{role.permissions.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 text-xs rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">
|
||||
{permissionLabels[p] || p}
|
||||
</span>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} />新建角色</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table columns={columns as unknown as Column<Record<string, unknown>>[]} data={roles as unknown as Record<string, unknown>[]} rowKey={(r) => String(r.id)} />
|
||||
)}
|
||||
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
|
||||
<div className="space-y-4">
|
||||
{!editRole && (
|
||||
<Input
|
||||
label="角色名(英文)"
|
||||
value={form.name}
|
||||
onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. supervisor"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="显示名称"
|
||||
value={form.display_name}
|
||||
onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{allPermissions.map(p => (
|
||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.permissions.includes(p.key) || form.permissions.includes('*')}
|
||||
onChange={() => togglePermission(p.key)}
|
||||
className="rounded border-slate-300 dark:border-slate-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
||||
<span className="text-xs text-slate-400">{p.key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<UserItem | null>(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) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> },
|
||||
{ key: 'email', title: '邮箱', render: (r) => r.email || '-' },
|
||||
{ key: 'role', title: '角色', render: (r) => <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{roles.find(ro => ro.value === r.role)?.label || r.role}</Badge> },
|
||||
{ key: 'role', title: '角色', render: (r) => {
|
||||
const option = roleOptions.find(ro => ro.value === r.role)
|
||||
return <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{option?.label || r.role}</Badge>
|
||||
} },
|
||||
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
|
||||
{ key: 'created_at', title: '创建时间' },
|
||||
{ key: 'actions', title: '操作', render: (r) => (
|
||||
|
|
@ -110,7 +120,7 @@ export default function UsersPage() {
|
|||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Input label={editing ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roles} />
|
||||
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +32,36 @@ 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[] = []
|
||||
|
||||
// 按选中 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}%`
|
||||
|
|
@ -48,6 +72,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) {
|
||||
|
|
@ -84,15 +109,18 @@ export async function GET(request: Request) {
|
|||
}
|
||||
} 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<string, unknown>[]
|
||||
|
||||
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"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, unknown> | 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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white mb-3">{title}</h3>
|
||||
<div className="space-y-0">{items}</div>
|
||||
<div className="space-y-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
|
@ -50,6 +69,7 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="保修到期日" value={a.warranty_date} />
|
||||
</Section>
|
||||
|
||||
{hasAny(a, cpuKeys) && (
|
||||
<Section title="CPU">
|
||||
<Field label="型号" value={a.cpu_model} />
|
||||
<Field label="代数" value={a.cpu_generation} />
|
||||
|
|
@ -58,7 +78,9 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="数量" value={a.cpu_count} />
|
||||
<Field label="规格" value={a.cpu_spec} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, memKeys) && (
|
||||
<Section title="内存">
|
||||
<Field label="型号" value={a.memory_model} />
|
||||
<Field label="频率" value={a.memory_frequency} />
|
||||
|
|
@ -66,34 +88,44 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="条数" value={a.memory_count} />
|
||||
<Field label="总量" value={a.memory_total} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, gpuKeys) && (
|
||||
<Section title="GPU">
|
||||
<Field label="型号" value={a.gpu_model} />
|
||||
<Field label="功耗" value={a.gpu_power} />
|
||||
<Field label="数量" value={a.gpu_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, nic1Keys) && (
|
||||
<Section title="网卡 1">
|
||||
<Field label="型号" value={a.nic1_model} />
|
||||
<Field label="类型" value={a.nic1_type} />
|
||||
<Field label="速率" value={a.nic1_speed} />
|
||||
<Field label="数量" value={a.nic1_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, nic2Keys) && (
|
||||
<Section title="网卡 2">
|
||||
<Field label="型号" value={a.nic2_model} />
|
||||
<Field label="类型" value={a.nic2_type} />
|
||||
<Field label="速率" value={a.nic2_speed} />
|
||||
<Field label="数量" value={a.nic2_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, nic3Keys) && (
|
||||
<Section title="网卡 3">
|
||||
<Field label="型号" value={a.nic3_model} />
|
||||
<Field label="类型" value={a.nic3_type} />
|
||||
<Field label="速率" value={a.nic3_speed} />
|
||||
<Field label="数量" value={a.nic3_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, sysDiskKeys) && (
|
||||
<Section title="系统盘">
|
||||
<Field label="型号" value={a.sys_disk_model} />
|
||||
<Field label="规格" value={a.sys_disk_spec} />
|
||||
|
|
@ -103,7 +135,9 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="速率" value={a.sys_disk_speed} />
|
||||
<Field label="数量" value={a.sys_disk_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, dataDisk1Keys) && (
|
||||
<Section title="数据盘 1">
|
||||
<Field label="型号" value={a.data_disk1_model} />
|
||||
<Field label="规格" value={a.data_disk1_spec} />
|
||||
|
|
@ -113,7 +147,9 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="速率" value={a.data_disk1_speed} />
|
||||
<Field label="数量" value={a.data_disk1_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, dataDisk2Keys) && (
|
||||
<Section title="数据盘 2">
|
||||
<Field label="型号" value={a.data_disk2_model} />
|
||||
<Field label="规格" value={a.data_disk2_spec} />
|
||||
|
|
@ -123,14 +159,18 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="速率" value={a.data_disk2_speed} />
|
||||
<Field label="数量" value={a.data_disk2_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, raidKeys) && (
|
||||
<Section title="RAID / 存储">
|
||||
<Field label="数据盘总空间" value={a.data_disk_total_space} />
|
||||
<Field label="RAID型号" value={a.raid_model} />
|
||||
<Field label="RAID规格" value={a.raid_spec} />
|
||||
<Field label="RAID数量" value={a.raid_count} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, psuKeys) && (
|
||||
<Section title="电源">
|
||||
<Field label="电源1型号" value={a.psu1_model} />
|
||||
<Field label="电源1功率" value={a.psu1_power} />
|
||||
|
|
@ -140,11 +180,14 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
|||
<Field label="电源2数量" value={a.psu2_count} />
|
||||
<Field label="总功率" value={a.psu_total_power} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasAny(a, boardKeys) && (
|
||||
<Section title="主板">
|
||||
<Field label="型号" value={a.board_model} />
|
||||
<Field label="数量" value={a.board_count} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_type}</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.business_ip || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_type}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.manufacturer || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_model || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.serial_number || '-'}</td>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue