341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
import {
|
|
AdminPanelConfig,
|
|
AdminPanelState,
|
|
UseAdminPanelOptions,
|
|
UseAdminPanelReturn,
|
|
FieldConfig,
|
|
ValidationRule
|
|
} from "@/types/admin-panel";
|
|
|
|
// Helper function to get nested value
|
|
function getNestedValue(obj: any, path: string): any {
|
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
}
|
|
|
|
// Helper function to set nested value
|
|
function setNestedValue(obj: any, path: string, value: any): any {
|
|
const keys = path.split('.');
|
|
const result = { ...obj };
|
|
let current = result;
|
|
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const key = keys[i];
|
|
current[key] = current[key] ? { ...current[key] } : {};
|
|
current = current[key];
|
|
}
|
|
|
|
current[keys[keys.length - 1]] = value;
|
|
return result;
|
|
}
|
|
|
|
// Helper function to validate a field
|
|
function validateField(field: FieldConfig, value: any): string | null {
|
|
if (!field.validation) return null;
|
|
|
|
const { validation } = field;
|
|
|
|
// Required validation
|
|
if (validation.required && (value === undefined || value === null || value === '')) {
|
|
return `${field.label}是必填项`;
|
|
}
|
|
|
|
// Skip other validations if value is empty and not required
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
|
|
// Min/Max validation for numbers
|
|
if (typeof value === 'number') {
|
|
if (validation.min !== undefined && value < validation.min) {
|
|
return `${field.label}不能小于${validation.min}`;
|
|
}
|
|
if (validation.max !== undefined && value > validation.max) {
|
|
return `${field.label}不能大于${validation.max}`;
|
|
}
|
|
}
|
|
|
|
// Length validation for strings
|
|
if (typeof value === 'string') {
|
|
if (validation.minLength !== undefined && value.length < validation.minLength) {
|
|
return `${field.label}长度不能少于${validation.minLength}个字符`;
|
|
}
|
|
if (validation.maxLength !== undefined && value.length > validation.maxLength) {
|
|
return `${field.label}长度不能超过${validation.maxLength}个字符`;
|
|
}
|
|
}
|
|
|
|
// Pattern validation
|
|
if (validation.pattern && typeof value === 'string' && !validation.pattern.test(value)) {
|
|
return `${field.label}格式不正确`;
|
|
}
|
|
|
|
// Custom validation
|
|
if (validation.custom) {
|
|
return validation.custom(value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Get all fields from config
|
|
function getAllFields(config: AdminPanelConfig): FieldConfig[] {
|
|
return config.tabs.flatMap(tab =>
|
|
tab.sections.flatMap(section => section.fields)
|
|
);
|
|
}
|
|
|
|
export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelReturn {
|
|
const { config, initialValues = {}, onSubmit } = options;
|
|
|
|
// State
|
|
const [state, setState] = useState<AdminPanelState>({
|
|
values: initialValues,
|
|
errors: {},
|
|
dirty: {},
|
|
loading: false,
|
|
saving: false,
|
|
});
|
|
|
|
// Auto-save timer
|
|
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
|
const lastSavedValues = useRef<Record<string, any>>(initialValues);
|
|
|
|
// Calculate initial values with memoization to prevent unnecessary recalculations
|
|
const computedInitialValues = React.useMemo(() => {
|
|
const fields = getAllFields(config);
|
|
const values: Record<string, any> = { ...initialValues };
|
|
|
|
fields.forEach(field => {
|
|
if (getNestedValue(values, field.id) === undefined) {
|
|
setNestedValue(values, field.id, field.value);
|
|
}
|
|
});
|
|
|
|
return values;
|
|
}, [config, initialValues]);
|
|
|
|
// Initialize values only when computed initial values change
|
|
useEffect(() => {
|
|
setState(prev => {
|
|
// Only update if values are actually different to prevent loops
|
|
const currentJson = JSON.stringify(prev.values);
|
|
const newJson = JSON.stringify(computedInitialValues);
|
|
if (currentJson !== newJson) {
|
|
lastSavedValues.current = computedInitialValues;
|
|
return { ...prev, values: computedInitialValues };
|
|
}
|
|
return prev;
|
|
});
|
|
}, [computedInitialValues]); // Depend directly on memoized values
|
|
|
|
// Auto-save functionality
|
|
useEffect(() => {
|
|
if (!config.autoSave || !onSubmit) return;
|
|
|
|
const delay = config.autoSaveDelay || 2000;
|
|
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
|
|
// Check if values have changed
|
|
const hasChanges = JSON.stringify(state.values) !== JSON.stringify(lastSavedValues.current);
|
|
|
|
if (hasChanges && Object.keys(state.dirty).length > 0) {
|
|
autoSaveTimer.current = setTimeout(async () => {
|
|
try {
|
|
await onSubmit(state.values);
|
|
lastSavedValues.current = state.values;
|
|
setState(prev => ({ ...prev, dirty: {} }));
|
|
} catch (error) {
|
|
console.error('Auto-save failed:', error);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
return () => {
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
};
|
|
}, [state.values, state.dirty, config.autoSave, config.autoSaveDelay, onSubmit]);
|
|
|
|
// Actions
|
|
const setValue = useCallback((path: string, value: any) => {
|
|
setState(prev => {
|
|
const newValues = setNestedValue(prev.values, path, value);
|
|
const newDirty = { ...prev.dirty, [path]: true };
|
|
|
|
// Clear error for this field
|
|
const newErrors = { ...prev.errors };
|
|
delete newErrors[path];
|
|
|
|
// Validate on change if enabled
|
|
let validationErrors = newErrors;
|
|
if (config.validateOnChange) {
|
|
const fields = getAllFields(config);
|
|
const field = fields.find(f => f.id === path);
|
|
if (field) {
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
validationErrors = { ...validationErrors, [path]: error };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call onChange callback
|
|
if (config.onValueChange) {
|
|
config.onValueChange(path, value, newValues);
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
values: newValues,
|
|
dirty: newDirty,
|
|
errors: validationErrors,
|
|
};
|
|
});
|
|
}, [config]);
|
|
|
|
const setValues = useCallback((values: Record<string, any>) => {
|
|
setState(prev => ({
|
|
...prev,
|
|
values: { ...prev.values, ...values },
|
|
dirty: Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), prev.dirty),
|
|
}));
|
|
}, []);
|
|
|
|
const resetValue = useCallback((path: string) => {
|
|
const fields = getAllFields(config);
|
|
const field = fields.find(f => f.id === path);
|
|
if (field) {
|
|
setValue(path, field.value);
|
|
setState(prev => {
|
|
const newDirty = { ...prev.dirty };
|
|
delete newDirty[path];
|
|
const newErrors = { ...prev.errors };
|
|
delete newErrors[path];
|
|
return { ...prev, dirty: newDirty, errors: newErrors };
|
|
});
|
|
}
|
|
}, [config, setValue]);
|
|
|
|
const resetAll = useCallback(() => {
|
|
setState(prev => ({
|
|
...prev,
|
|
values: computedInitialValues,
|
|
dirty: {},
|
|
errors: {},
|
|
}));
|
|
if (config.onReset) {
|
|
config.onReset();
|
|
}
|
|
}, [computedInitialValues, config]);
|
|
|
|
const validate = useCallback((): boolean => {
|
|
const fields = getAllFields(config);
|
|
const errors: Record<string, string> = {};
|
|
|
|
fields.forEach(field => {
|
|
// Skip validation for disabled or readOnly fields
|
|
if (field.disabled || field.readOnly) return;
|
|
|
|
// Check conditional rendering
|
|
if (field.showWhen && !field.showWhen(state.values)) return;
|
|
|
|
const value = getNestedValue(state.values, field.id);
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
errors[field.id] = error;
|
|
}
|
|
});
|
|
|
|
// Custom validation
|
|
if (config.onValidate) {
|
|
const customErrors = config.onValidate(state.values);
|
|
Object.assign(errors, customErrors);
|
|
}
|
|
|
|
setState(prev => ({ ...prev, errors }));
|
|
return Object.keys(errors).length === 0;
|
|
}, [config, state.values]);
|
|
|
|
const save = useCallback(async () => {
|
|
if (!onSubmit) return;
|
|
|
|
// Validate if required
|
|
if (config.validateOnSubmit !== false) {
|
|
const isValid = validate();
|
|
if (!isValid) return;
|
|
}
|
|
|
|
setState(prev => ({ ...prev, saving: true }));
|
|
|
|
try {
|
|
await onSubmit(state.values);
|
|
lastSavedValues.current = state.values;
|
|
setState(prev => ({
|
|
...prev,
|
|
saving: false,
|
|
dirty: {},
|
|
errors: {}
|
|
}));
|
|
|
|
if (config.onSave) {
|
|
await config.onSave(state.values);
|
|
}
|
|
} catch (error) {
|
|
setState(prev => ({ ...prev, saving: false }));
|
|
throw error;
|
|
}
|
|
}, [config, state.values, onSubmit, validate]);
|
|
|
|
const clearErrors = useCallback(() => {
|
|
setState(prev => ({ ...prev, errors: {} }));
|
|
}, []);
|
|
|
|
// Helpers
|
|
const getValue = useCallback((path: string) => {
|
|
return getNestedValue(state.values, path);
|
|
}, [state.values]);
|
|
|
|
const getError = useCallback((path: string) => {
|
|
return state.errors[path];
|
|
}, [state.errors]);
|
|
|
|
const isDirty = useCallback((path?: string) => {
|
|
if (path) {
|
|
return Boolean(state.dirty[path]);
|
|
}
|
|
return Object.keys(state.dirty).length > 0;
|
|
}, [state.dirty]);
|
|
|
|
const isValid = useCallback((path?: string) => {
|
|
if (path) {
|
|
return !state.errors[path];
|
|
}
|
|
return Object.keys(state.errors).length === 0;
|
|
}, [state.errors]);
|
|
|
|
return {
|
|
state,
|
|
actions: {
|
|
setValue,
|
|
setValues,
|
|
resetValue,
|
|
resetAll,
|
|
save,
|
|
validate,
|
|
clearErrors,
|
|
},
|
|
helpers: {
|
|
getValue,
|
|
getError,
|
|
isDirty,
|
|
isValid,
|
|
},
|
|
};
|
|
} |