feat(vps): визуальный редактор кастомных полей и колонки в таблице
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Заменён JSON-редактор в настройках на UI с drag-reorder, добавлены динамические колонки VPS с picker видимости и типизированные контракты в @cfdm/shared. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
export interface CustomFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'text' | 'number' | 'bool'
|
||||
}
|
||||
|
||||
export function parseCustomFieldDefs(raw: unknown): CustomFieldDef[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => item != null && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
key: String(item.key ?? '').trim(),
|
||||
label: String(item.label ?? item.key ?? '').trim(),
|
||||
type: (item.type as CustomFieldDef['type']) ?? 'text',
|
||||
}))
|
||||
.filter((f) => f.key.length > 0)
|
||||
}
|
||||
|
||||
export function parseCustomData(raw: unknown): Record<string, string | number | boolean> {
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string | number | boolean>
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, string | number | boolean>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export {
|
||||
type CustomFieldDef,
|
||||
type CustomFieldType,
|
||||
parseCustomFieldDefs,
|
||||
parseCustomData,
|
||||
slugifyCustomFieldKey,
|
||||
formatCustomFieldValue,
|
||||
} from '@cfdm/shared/contracts/custom-fields'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
type CustomFieldDef,
|
||||
formatCustomFieldValue,
|
||||
parseCustomData,
|
||||
} from '@cfdm/shared/contracts/custom-fields'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
|
||||
export function buildCustomFieldColumns<T extends { customData?: unknown }>(
|
||||
defs: CustomFieldDef[],
|
||||
): DataTableColumn<T>[] {
|
||||
return defs.map((def) => ({
|
||||
key: `custom_${def.key}`,
|
||||
header: def.label,
|
||||
headerTitle: def.label,
|
||||
enableHiding: true,
|
||||
sortValue: (row) => {
|
||||
const data = parseCustomData(row.customData)
|
||||
const val = data[def.key]
|
||||
if (def.type === 'bool') return val ? 1 : 0
|
||||
if (def.type === 'number') return Number(val) || 0
|
||||
return String(val ?? '')
|
||||
},
|
||||
cell: (row): ReactNode => {
|
||||
const data = parseCustomData(row.customData)
|
||||
const val = data[def.key]
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
if (def.type === 'bool') {
|
||||
return (
|
||||
<Badge variant={val ? 'default' : 'outline'}>
|
||||
{formatCustomFieldValue(def, val)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (def.type === 'number') {
|
||||
return (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatCustomFieldValue(def, val)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const text = formatCustomFieldValue(def, val)
|
||||
return (
|
||||
<span className="block max-w-[200px] truncate text-muted-foreground" title={text}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildCustomFieldColumnVisibility(
|
||||
defs: CustomFieldDef[],
|
||||
): Record<string, boolean> {
|
||||
const visibility: Record<string, boolean> = {}
|
||||
for (const def of defs) {
|
||||
visibility[`custom_${def.key}`] = false
|
||||
}
|
||||
return visibility
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
@@ -100,7 +101,12 @@ export const settingsSchema = z.object({
|
||||
notifyVpsDownEnabled: z.boolean().optional().default(true),
|
||||
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
|
||||
webhookEnabled: z.boolean().optional().default(false),
|
||||
customFieldsJson: z.string().optional().default('[]'),
|
||||
customFields: customFieldsSchema
|
||||
.default([])
|
||||
.refine(
|
||||
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
|
||||
'Ключи кастомных полей должны быть уникальными',
|
||||
),
|
||||
})
|
||||
|
||||
export const projectSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user