mosaicmap/hooks/use-admin-panel.ts
2025-08-14 21:34:16 +08:00

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,
},
};
}