生产管理系统前端 - 瓦力提交代码&文档更新
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { User, LoginRecord, Enterprise } from '../types/auth';
|
||||
import { formatDateTime as safeDateFormat, safeNow, getTime } from './safeDate';
|
||||
|
||||
// LocalStorage keys
|
||||
const STORAGE_KEYS = {
|
||||
@@ -7,254 +8,348 @@ const STORAGE_KEYS = {
|
||||
USER: 'user_info',
|
||||
LOGIN_TIME: 'login_time',
|
||||
SESSION_ID: 'session_id',
|
||||
} as const;
|
||||
|
||||
// 延迟初始化的Mock数据 - 避免模块加载时的构造函数错误
|
||||
let _mockEnterprises: Enterprise[] | null = null;
|
||||
let _mockUsers: User[] | null = null;
|
||||
let _loginRecords: LoginRecord[] | null = null;
|
||||
let _mockPasswords: { [username: string]: string } | null = null;
|
||||
|
||||
// 格式化日期时间(使用安全的工具函数)
|
||||
const formatDateTime = (): string => {
|
||||
return safeDateFormat();
|
||||
};
|
||||
|
||||
// Mock企业数据库
|
||||
const MOCK_ENTERPRISES: Enterprise[] = [
|
||||
{
|
||||
id: 'ent-1',
|
||||
name: '绿野农业科技有限公司',
|
||||
code: 'LYNY001',
|
||||
type: '种植企业',
|
||||
status: 'active',
|
||||
address: '江苏省南京市江宁区农业科技园区',
|
||||
contact: '李经理',
|
||||
phone: '025-12345678',
|
||||
createdAt: '2023-01-01 10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-2',
|
||||
name: '丰收农场管理集团',
|
||||
code: 'FSNC002',
|
||||
type: '综合农场',
|
||||
status: 'active',
|
||||
address: '山东省济南市章丘区现代农业示范区',
|
||||
contact: '王总',
|
||||
phone: '0531-87654321',
|
||||
createdAt: '2023-03-15 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-3',
|
||||
name: '智慧农业示范基地',
|
||||
code: 'ZHNY003',
|
||||
type: '示范基地',
|
||||
status: 'active',
|
||||
address: '浙江省杭州市萧山区智慧农业园',
|
||||
contact: '陈主任',
|
||||
phone: '0571-23456789',
|
||||
createdAt: '2023-06-01 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-4',
|
||||
name: '现代农业合作社',
|
||||
code: 'XDNY004',
|
||||
type: '合作社',
|
||||
status: 'active',
|
||||
address: '河南省郑州市中牟县农业园区',
|
||||
contact: '赵理事长',
|
||||
phone: '0371-34567890',
|
||||
createdAt: '2023-08-20 11:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-5',
|
||||
name: '优质粮食生产基地',
|
||||
code: 'YZLS005',
|
||||
type: '粮食基地',
|
||||
status: 'active',
|
||||
address: '黑龙江省哈尔滨市五常市',
|
||||
contact: '孙场长',
|
||||
phone: '0451-45678901',
|
||||
createdAt: '2023-10-10 08:00:00',
|
||||
},
|
||||
];
|
||||
// 获取Mock企业数据库
|
||||
const getMockEnterprises = (): Enterprise[] => {
|
||||
if (_mockEnterprises === null) {
|
||||
_mockEnterprises = [
|
||||
{
|
||||
id: 'ent-1',
|
||||
name: '绿野农业科技有限公司',
|
||||
code: 'LYNY001',
|
||||
type: '种植企业',
|
||||
status: 'active',
|
||||
auditStatus: 'approved',
|
||||
address: '江苏省南京市江宁区农业科技园区',
|
||||
contact: '李经理',
|
||||
phone: '025-12345678',
|
||||
createdAt: '2023-01-01 10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-2',
|
||||
name: '丰收农场管理集团',
|
||||
code: 'FSNC002',
|
||||
type: '综合农场',
|
||||
status: 'active',
|
||||
auditStatus: 'approved',
|
||||
address: '山东省济南市章丘区现代农业示范区',
|
||||
contact: '王总',
|
||||
phone: '0531-87654321',
|
||||
createdAt: '2023-03-15 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-3',
|
||||
name: '智慧农业示范基地',
|
||||
code: 'ZHNY003',
|
||||
type: '示范基地',
|
||||
status: 'active',
|
||||
auditStatus: 'pending',
|
||||
address: '浙江省杭州市萧山区智慧农业园',
|
||||
contact: '陈主任',
|
||||
phone: '0571-23456789',
|
||||
createdAt: '2023-06-01 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-4',
|
||||
name: '现代农业合作社',
|
||||
code: 'XDNY004',
|
||||
type: '合作社',
|
||||
status: 'active',
|
||||
auditStatus: 'not_submitted',
|
||||
address: '河南省郑州市中牟县农业园区',
|
||||
contact: '赵理事长',
|
||||
phone: '0371-34567890',
|
||||
createdAt: '2023-08-20 11:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ent-5',
|
||||
name: '优质粮食生产基地',
|
||||
code: 'YZLS005',
|
||||
type: '粮食基地',
|
||||
status: 'active',
|
||||
auditStatus: 'rejected',
|
||||
address: '黑龙江省哈尔滨市五常市',
|
||||
contact: '孙场长',
|
||||
phone: '0451-45678901',
|
||||
createdAt: '2023-10-10 08:00:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
return _mockEnterprises;
|
||||
};
|
||||
|
||||
// Mock用户数据库
|
||||
const MOCK_USERS: User[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
phone: '13800138000',
|
||||
email: 'admin@agriculture.com',
|
||||
realName: '系统管理员',
|
||||
role: 'admin',
|
||||
permissions: ['*'],
|
||||
avatar: '',
|
||||
department: '技术部',
|
||||
enterpriseId: 'ent-1',
|
||||
enterpriseName: '绿野农业科技有限公司',
|
||||
createdAt: '2024-01-01 10:00:00',
|
||||
lastLoginTime: '2024-10-15 09:30:00',
|
||||
lastLoginIp: '192.168.1.100',
|
||||
lastLoginDevice: 'Windows PC - Chrome',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
username: 'zhangsan',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@agriculture.com',
|
||||
realName: '张三',
|
||||
role: 'manager',
|
||||
permissions: ['machinery:*', 'field:*', 'operation:*'],
|
||||
avatar: '',
|
||||
department: '运营部',
|
||||
enterpriseId: 'ent-1',
|
||||
enterpriseName: '绿野农业科技有限公司',
|
||||
createdAt: '2024-01-15 14:00:00',
|
||||
lastLoginTime: '2024-10-14 16:20:00',
|
||||
lastLoginIp: '192.168.1.101',
|
||||
lastLoginDevice: 'iPhone 14 - Safari',
|
||||
},
|
||||
];
|
||||
// 获取Mock用户数据库
|
||||
const getMockUsers = (): User[] => {
|
||||
if (_mockUsers === null) {
|
||||
_mockUsers = [
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
phone: '13800138000',
|
||||
email: 'admin@agriculture.com',
|
||||
realName: '系统管理员',
|
||||
role: 'admin',
|
||||
permissions: ['*'],
|
||||
avatar: '',
|
||||
department: '技术部',
|
||||
enterpriseId: 'ent-1',
|
||||
enterpriseName: '绿野农业科技有限公司',
|
||||
createdAt: '2024-01-01 10:00:00',
|
||||
lastLoginTime: '2024-10-15 09:30:00',
|
||||
lastLoginIp: '192.168.1.100',
|
||||
lastLoginDevice: 'Windows PC - Chrome',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
username: 'zhangsan',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@agriculture.com',
|
||||
realName: '张三',
|
||||
role: 'manager',
|
||||
permissions: ['machinery:*', 'field:*', 'operation:*'],
|
||||
avatar: '',
|
||||
department: '运营部',
|
||||
enterpriseId: 'ent-1',
|
||||
enterpriseName: '绿野农业科技有限公司',
|
||||
createdAt: '2024-01-15 14:00:00',
|
||||
lastLoginTime: '2024-10-14 16:20:00',
|
||||
lastLoginIp: '192.168.1.101',
|
||||
lastLoginDevice: 'iPhone 14 - Safari',
|
||||
},
|
||||
];
|
||||
}
|
||||
return _mockUsers;
|
||||
};
|
||||
|
||||
// Mock登录记录
|
||||
let loginRecords: LoginRecord[] = [
|
||||
{
|
||||
id: 'log-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-15 09:30:00',
|
||||
loginIp: '192.168.1.100',
|
||||
loginDevice: 'Windows PC',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows 10',
|
||||
loginType: 'password',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
loginTime: '2024-10-14 16:20:00',
|
||||
loginIp: '192.168.1.101',
|
||||
loginDevice: 'iPhone 14',
|
||||
browser: 'Safari 17',
|
||||
os: 'iOS 17',
|
||||
loginType: 'phone',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
// 获取登录记录
|
||||
const getLoginRecords = (): LoginRecord[] => {
|
||||
if (_loginRecords === null) {
|
||||
_loginRecords = [
|
||||
{
|
||||
id: 'log-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-15 09:30:00',
|
||||
loginIp: '192.168.1.100',
|
||||
loginDevice: 'Windows PC',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows 10',
|
||||
loginType: 'password',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
loginTime: '2024-10-14 16:20:00',
|
||||
loginIp: '192.168.1.101',
|
||||
loginDevice: 'iPhone 14',
|
||||
browser: 'Safari 17',
|
||||
os: 'iOS 17',
|
||||
loginType: 'phone',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
}
|
||||
return _loginRecords;
|
||||
};
|
||||
|
||||
// Mock密码库(实际应该在服务端加密存储)
|
||||
const MOCK_PASSWORDS: { [username: string]: string } = {
|
||||
admin: 'admin123',
|
||||
zhangsan: 'zhang123',
|
||||
// 获取Mock密码库
|
||||
const getMockPasswords = (): { [username: string]: string } => {
|
||||
if (_mockPasswords === null) {
|
||||
_mockPasswords = {
|
||||
admin: 'admin123',
|
||||
zhangsan: 'zhang123',
|
||||
};
|
||||
}
|
||||
return _mockPasswords;
|
||||
};
|
||||
|
||||
// 生成随机token
|
||||
export const generateToken = (): string => {
|
||||
return 'token_' + Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
try {
|
||||
const random = Math.random().toString(36).substring(2);
|
||||
const timestamp = safeNow().toString(36);
|
||||
return 'token_' + random + timestamp;
|
||||
} catch (e) {
|
||||
console.warn('Token generation error:', e);
|
||||
return 'token_' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成session ID
|
||||
export const generateSessionId = (): string => {
|
||||
return 'session_' + Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
try {
|
||||
const random = Math.random().toString(36).substring(2);
|
||||
const timestamp = safeNow().toString(36);
|
||||
return 'session_' + random + timestamp;
|
||||
} catch (e) {
|
||||
console.warn('Session ID generation error:', e);
|
||||
return 'session_' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取设备信息
|
||||
export const getDeviceInfo = (): { device: string; browser: string; os: string } => {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
let device = 'Unknown Device';
|
||||
let browser = 'Unknown Browser';
|
||||
let os = 'Unknown OS';
|
||||
|
||||
// 检测操作系统
|
||||
if (ua.indexOf('Win') !== -1) os = 'Windows';
|
||||
else if (ua.indexOf('Mac') !== -1) os = 'macOS';
|
||||
else if (ua.indexOf('Linux') !== -1) os = 'Linux';
|
||||
else if (ua.indexOf('Android') !== -1) os = 'Android';
|
||||
else if (ua.indexOf('iOS') !== -1 || ua.indexOf('iPhone') !== -1) os = 'iOS';
|
||||
|
||||
// 检测浏览器
|
||||
if (ua.indexOf('Chrome') !== -1) browser = 'Chrome';
|
||||
else if (ua.indexOf('Safari') !== -1) browser = 'Safari';
|
||||
else if (ua.indexOf('Firefox') !== -1) browser = 'Firefox';
|
||||
else if (ua.indexOf('Edge') !== -1) browser = 'Edge';
|
||||
|
||||
// 检测设备类型
|
||||
if (ua.indexOf('Mobile') !== -1) device = 'Mobile Device';
|
||||
else if (ua.indexOf('Tablet') !== -1) device = 'Tablet';
|
||||
else device = 'Desktop PC';
|
||||
|
||||
return { device, browser, os };
|
||||
try {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return { device: 'Desktop PC', browser: 'Chrome', os: 'Windows' };
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
|
||||
let device = 'Desktop PC';
|
||||
let browser = 'Chrome';
|
||||
let os = 'Windows';
|
||||
|
||||
if (ua.indexOf('Win') !== -1) os = 'Windows';
|
||||
else if (ua.indexOf('Mac') !== -1) os = 'macOS';
|
||||
else if (ua.indexOf('Linux') !== -1) os = 'Linux';
|
||||
else if (ua.indexOf('Android') !== -1) os = 'Android';
|
||||
else if (ua.indexOf('iOS') !== -1 || ua.indexOf('iPhone') !== -1) os = 'iOS';
|
||||
|
||||
if (ua.indexOf('Chrome') !== -1) browser = 'Chrome';
|
||||
else if (ua.indexOf('Safari') !== -1) browser = 'Safari';
|
||||
else if (ua.indexOf('Firefox') !== -1) browser = 'Firefox';
|
||||
else if (ua.indexOf('Edge') !== -1) browser = 'Edge';
|
||||
|
||||
if (ua.indexOf('Mobile') !== -1) device = 'Mobile Device';
|
||||
else if (ua.indexOf('Tablet') !== -1) device = 'Tablet';
|
||||
else device = 'Desktop PC';
|
||||
|
||||
return { device, browser, os };
|
||||
} catch (e) {
|
||||
return { device: 'Desktop PC', browser: 'Chrome', os: 'Windows' };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取客户端IP(模拟)
|
||||
export const getClientIp = (): string => {
|
||||
// 实际应该从服务端获取
|
||||
return '192.168.1.' + Math.floor(Math.random() * 255);
|
||||
};
|
||||
|
||||
// 保存token
|
||||
export const saveToken = (token: string, refreshToken: string) => {
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, token);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TIME, new Date().toISOString());
|
||||
localStorage.setItem(STORAGE_KEYS.SESSION_ID, generateSessionId());
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
|
||||
const timestamp = String(safeNow());
|
||||
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, token);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TIME, timestamp);
|
||||
localStorage.setItem(STORAGE_KEYS.SESSION_ID, generateSessionId());
|
||||
} catch (e) {
|
||||
console.error('Failed to save token:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取token
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(STORAGE_KEYS.TOKEN);
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.TOKEN);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取刷新token
|
||||
export const getRefreshToken = (): string | null => {
|
||||
return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存用户信息
|
||||
export const saveUser = (user: User) => {
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user));
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user));
|
||||
} catch (e) {
|
||||
console.error('Failed to save user:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户信息
|
||||
export const getUser = (): User | null => {
|
||||
const userStr = localStorage.getItem(STORAGE_KEYS.USER);
|
||||
if (!userStr) return null;
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return null;
|
||||
const userStr = localStorage.getItem(STORAGE_KEYS.USER);
|
||||
if (!userStr) return null;
|
||||
return JSON.parse(userStr);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 清除认证信息
|
||||
export const clearAuth = () => {
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
localStorage.removeItem(STORAGE_KEYS.LOGIN_TIME);
|
||||
localStorage.removeItem(STORAGE_KEYS.SESSION_ID);
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
localStorage.removeItem(STORAGE_KEYS.LOGIN_TIME);
|
||||
localStorage.removeItem(STORAGE_KEYS.SESSION_ID);
|
||||
} catch (e) {
|
||||
console.error('Failed to clear auth:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查token是否过期(模拟)
|
||||
export const isTokenExpired = (): boolean => {
|
||||
const loginTime = localStorage.getItem(STORAGE_KEYS.LOGIN_TIME);
|
||||
if (!loginTime) return true;
|
||||
|
||||
const loginDate = new Date(loginTime);
|
||||
const now = new Date();
|
||||
const hoursPassed = (now.getTime() - loginDate.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
// 假设token有效期为24小时
|
||||
return hoursPassed >= 24;
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return true;
|
||||
|
||||
const loginTimeStr = localStorage.getItem(STORAGE_KEYS.LOGIN_TIME);
|
||||
if (!loginTimeStr) return true;
|
||||
|
||||
const loginTime = parseInt(loginTimeStr, 10);
|
||||
if (isNaN(loginTime)) return true;
|
||||
|
||||
const now = safeNow();
|
||||
const hoursPassed = (now - loginTime) / (1000 * 60 * 60);
|
||||
|
||||
return hoursPassed >= 24;
|
||||
} catch (e) {
|
||||
console.warn('Token expiry check error:', e);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新token(模拟)
|
||||
export const refreshAuthToken = async (): Promise<{ token: string; refreshToken: string } | null> => {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const newToken = generateToken();
|
||||
const newRefreshToken = generateToken();
|
||||
|
||||
saveToken(newToken, newRefreshToken);
|
||||
|
||||
return { token: newToken, refreshToken: newRefreshToken };
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const newToken = generateToken();
|
||||
const newRefreshToken = generateToken();
|
||||
|
||||
saveToken(newToken, newRefreshToken);
|
||||
|
||||
return { token: newToken, refreshToken: newRefreshToken };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证密码登录
|
||||
@@ -263,59 +358,52 @@ export const validatePasswordLogin = async (
|
||||
password: string,
|
||||
captcha: string
|
||||
): Promise<{ success: boolean; message: string; user?: User }> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// 验证图形验证码(简单模拟)
|
||||
// AUTO标识为系统自动登录,跳过验证码验证
|
||||
if (captcha !== 'AUTO' && (!captcha || captcha.length < 4)) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
if (captcha !== 'AUTO' && (!captcha || captcha.length < 4)) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
}
|
||||
|
||||
const mockUsers = getMockUsers();
|
||||
const user = mockUsers.find(u => u.username === username);
|
||||
if (!user) {
|
||||
return { success: false, message: '用户名或密码错误' };
|
||||
}
|
||||
|
||||
const mockPasswords = getMockPasswords();
|
||||
const storedPassword = mockPasswords[username];
|
||||
if (password !== storedPassword) {
|
||||
return { success: false, message: '用户名或密码错误' };
|
||||
}
|
||||
|
||||
const deviceInfo = getDeviceInfo();
|
||||
const loginIp = getClientIp();
|
||||
const loginTime = formatDateTime();
|
||||
|
||||
user.lastLoginTime = loginTime;
|
||||
user.lastLoginIp = loginIp;
|
||||
user.lastLoginDevice = `${deviceInfo.device} - ${deviceInfo.browser}`;
|
||||
|
||||
const loginRecord: LoginRecord = {
|
||||
id: 'log-' + safeNow(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
loginTime,
|
||||
loginIp,
|
||||
loginDevice: deviceInfo.device,
|
||||
browser: deviceInfo.browser,
|
||||
os: deviceInfo.os,
|
||||
loginType: 'password',
|
||||
status: 'success',
|
||||
};
|
||||
const records = getLoginRecords();
|
||||
records.unshift(loginRecord);
|
||||
|
||||
return { success: true, message: '登录成功', user };
|
||||
} catch (e) {
|
||||
return { success: false, message: '登录失败,请重试' };
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = MOCK_USERS.find(u => u.username === username);
|
||||
if (!user) {
|
||||
return { success: false, message: '用户名或密码错误' };
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const storedPassword = MOCK_PASSWORDS[username];
|
||||
if (password !== storedPassword) {
|
||||
return { success: false, message: '用户名或密码错误' };
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
const deviceInfo = getDeviceInfo();
|
||||
const loginIp = getClientIp();
|
||||
const loginTime = new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
user.lastLoginTime = loginTime;
|
||||
user.lastLoginIp = loginIp;
|
||||
user.lastLoginDevice = `${deviceInfo.device} - ${deviceInfo.browser}`;
|
||||
|
||||
// 记录登录日志
|
||||
const loginRecord: LoginRecord = {
|
||||
id: 'log-' + Date.now(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
loginTime,
|
||||
loginIp,
|
||||
loginDevice: deviceInfo.device,
|
||||
browser: deviceInfo.browser,
|
||||
os: deviceInfo.os,
|
||||
loginType: 'password',
|
||||
status: 'success',
|
||||
};
|
||||
loginRecords.unshift(loginRecord);
|
||||
|
||||
return { success: true, message: '登录成功', user };
|
||||
};
|
||||
|
||||
// 验证手机号登录
|
||||
@@ -324,62 +412,54 @@ export const validatePhoneLogin = async (
|
||||
code: string,
|
||||
captcha: string
|
||||
): Promise<{ success: boolean; message: string; user?: User }> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// 验证图形验证码
|
||||
if (!captcha || captcha.length < 4) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
if (!captcha || captcha.length < 4) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
return { success: false, message: '请输入正确的手机号' };
|
||||
}
|
||||
|
||||
if (code !== '123456') {
|
||||
return { success: false, message: '验证码错误' };
|
||||
}
|
||||
|
||||
const mockUsers = getMockUsers();
|
||||
const user = mockUsers.find(u => u.phone === phone);
|
||||
if (!user) {
|
||||
return { success: false, message: '该手机号未注册' };
|
||||
}
|
||||
|
||||
const deviceInfo = getDeviceInfo();
|
||||
const loginIp = getClientIp();
|
||||
const loginTime = formatDateTime();
|
||||
|
||||
user.lastLoginTime = loginTime;
|
||||
user.lastLoginIp = loginIp;
|
||||
user.lastLoginDevice = `${deviceInfo.device} - ${deviceInfo.browser}`;
|
||||
|
||||
const loginRecord: LoginRecord = {
|
||||
id: 'log-' + safeNow(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
loginTime,
|
||||
loginIp,
|
||||
loginDevice: deviceInfo.device,
|
||||
browser: deviceInfo.browser,
|
||||
os: deviceInfo.os,
|
||||
loginType: 'phone',
|
||||
status: 'success',
|
||||
};
|
||||
const records = getLoginRecords();
|
||||
records.unshift(loginRecord);
|
||||
|
||||
return { success: true, message: '登录成功', user };
|
||||
} catch (e) {
|
||||
return { success: false, message: '登录失败,请重试' };
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
return { success: false, message: '请输入正确的手机号' };
|
||||
}
|
||||
|
||||
// 验证验证码(模拟,实际应该验证服务端发送的验证码)
|
||||
if (code !== '123456') {
|
||||
return { success: false, message: '验证码错误' };
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = MOCK_USERS.find(u => u.phone === phone);
|
||||
if (!user) {
|
||||
return { success: false, message: '该手机号未注册' };
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
const deviceInfo = getDeviceInfo();
|
||||
const loginIp = getClientIp();
|
||||
const loginTime = new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
user.lastLoginTime = loginTime;
|
||||
user.lastLoginIp = loginIp;
|
||||
user.lastLoginDevice = `${deviceInfo.device} - ${deviceInfo.browser}`;
|
||||
|
||||
// 记录登录日志
|
||||
const loginRecord: LoginRecord = {
|
||||
id: 'log-' + Date.now(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
loginTime,
|
||||
loginIp,
|
||||
loginDevice: deviceInfo.device,
|
||||
browser: deviceInfo.browser,
|
||||
os: deviceInfo.os,
|
||||
loginType: 'phone',
|
||||
status: 'success',
|
||||
};
|
||||
loginRecords.unshift(loginRecord);
|
||||
|
||||
return { success: true, message: '登录成功', user };
|
||||
};
|
||||
|
||||
// 用户注册
|
||||
@@ -395,113 +475,104 @@ export const registerUser = async (
|
||||
captcha: string;
|
||||
}
|
||||
): Promise<{ success: boolean; message: string; user?: User }> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const { username, password, phone, code, realName, email, enterpriseId, captcha } = registerForm;
|
||||
|
||||
// 验证图形验证码
|
||||
if (!captcha || captcha.length < 4) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const { username, password, phone, code, realName, email, enterpriseId, captcha } = registerForm;
|
||||
|
||||
if (!captcha || captcha.length < 4) {
|
||||
return { success: false, message: '请输入图形验证码' };
|
||||
}
|
||||
|
||||
if (code !== '123456') {
|
||||
return { success: false, message: '手机验证码错误' };
|
||||
}
|
||||
|
||||
if (!enterpriseId) {
|
||||
return { success: false, message: '请选择所属企业' };
|
||||
}
|
||||
|
||||
const mockEnterprises = getMockEnterprises();
|
||||
const enterprise = mockEnterprises.find(e => e.id === enterpriseId);
|
||||
if (!enterprise) {
|
||||
return { success: false, message: '所选企业不存在' };
|
||||
}
|
||||
|
||||
if (enterprise.status !== 'active') {
|
||||
return { success: false, message: '该企业已停用,无法注册' };
|
||||
}
|
||||
|
||||
const mockUsers = getMockUsers();
|
||||
if (mockUsers.some(u => u.username === username)) {
|
||||
return { success: false, message: '用户名已存在' };
|
||||
}
|
||||
|
||||
if (mockUsers.some(u => u.phone === phone)) {
|
||||
return { success: false, message: '该手机号已注册' };
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return { success: false, message: '密码长度不能少于6位' };
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: 'user-' + safeNow(),
|
||||
username,
|
||||
phone,
|
||||
email,
|
||||
realName,
|
||||
role: 'user',
|
||||
permissions: ['machinery:view', 'field:view', 'operation:view'],
|
||||
avatar: '',
|
||||
department: '待分配',
|
||||
enterpriseId,
|
||||
enterpriseName: enterprise.name,
|
||||
createdAt: formatDateTime(),
|
||||
};
|
||||
|
||||
mockUsers.push(newUser);
|
||||
const mockPasswords = getMockPasswords();
|
||||
mockPasswords[username] = password;
|
||||
|
||||
return { success: true, message: '注册成功', user: newUser };
|
||||
} catch (e) {
|
||||
return { success: false, message: '注册失败,请重试' };
|
||||
}
|
||||
|
||||
// 验证手机验证码
|
||||
if (code !== '123456') {
|
||||
return { success: false, message: '手机验证码错误' };
|
||||
}
|
||||
|
||||
// 验证企业选择
|
||||
if (!enterpriseId) {
|
||||
return { success: false, message: '请选择所属企业' };
|
||||
}
|
||||
|
||||
// 检查企业是否存在
|
||||
const enterprise = MOCK_ENTERPRISES.find(e => e.id === enterpriseId);
|
||||
if (!enterprise) {
|
||||
return { success: false, message: '所选企业不存在' };
|
||||
}
|
||||
|
||||
if (enterprise.status !== 'active') {
|
||||
return { success: false, message: '该企业已停用,无法注册' };
|
||||
}
|
||||
|
||||
// 检查用户名唯一性
|
||||
if (MOCK_USERS.some(u => u.username === username)) {
|
||||
return { success: false, message: '用户名已存在' };
|
||||
}
|
||||
|
||||
// 检查手机号唯一性
|
||||
if (MOCK_USERS.some(u => u.phone === phone)) {
|
||||
return { success: false, message: '该手机号已注册' };
|
||||
}
|
||||
|
||||
// 验证密码强度(至少6位)
|
||||
if (password.length < 6) {
|
||||
return { success: false, message: '密码长度不能少于6位' };
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
const newUser: User = {
|
||||
id: 'user-' + Date.now(),
|
||||
username,
|
||||
phone,
|
||||
email,
|
||||
realName,
|
||||
role: 'user',
|
||||
permissions: ['machinery:view', 'field:view', 'operation:view'],
|
||||
avatar: '',
|
||||
department: '待分配',
|
||||
enterpriseId,
|
||||
enterpriseName: enterprise.name,
|
||||
createdAt: new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}),
|
||||
};
|
||||
|
||||
// 保存用户(添加到mock数据库)
|
||||
MOCK_USERS.push(newUser);
|
||||
MOCK_PASSWORDS[username] = password;
|
||||
|
||||
return { success: true, message: '注册成功', user: newUser };
|
||||
};
|
||||
|
||||
// 发送短信验证码(模拟)
|
||||
export const sendSmsCode = async (phone: string): Promise<{ success: boolean; message: string }> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 验证手机号格式
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
return { success: false, message: '请输入正确的手机号' };
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
return { success: false, message: '请输入正确的手机号' };
|
||||
}
|
||||
|
||||
console.log(`验证码已发送到手机:${phone},验证码:123456(仅测试使用)`);
|
||||
|
||||
return { success: true, message: '验证码已发送,请注意查收' };
|
||||
} catch (e) {
|
||||
return { success: false, message: '发送失败,请重试' };
|
||||
}
|
||||
|
||||
// 模拟发送成功(实际应该调用短信服务API)
|
||||
console.log(`验证码已发送到手机:${phone},验证码:123456(仅测试使用)`);
|
||||
|
||||
return { success: true, message: '验证码已发送,请注意查收' };
|
||||
};
|
||||
|
||||
// 获取登录记录
|
||||
export const getLoginRecords = (): LoginRecord[] => {
|
||||
return loginRecords;
|
||||
};
|
||||
// 导出登录记录(用于外部访问)
|
||||
export { getLoginRecords };
|
||||
|
||||
// 导出所有用户(仅用于开发调试)
|
||||
export const getAllUsers = (): User[] => {
|
||||
return MOCK_USERS;
|
||||
return getMockUsers();
|
||||
};
|
||||
|
||||
// 获取所有企业列表
|
||||
export const getAllEnterprises = (): Enterprise[] => {
|
||||
return MOCK_ENTERPRISES.filter(e => e.status === 'active');
|
||||
const enterprises = getMockEnterprises();
|
||||
return enterprises.filter(e => e.status === 'active');
|
||||
};
|
||||
|
||||
// 根据ID获取企业信息
|
||||
export const getEnterpriseById = (id: string): Enterprise | undefined => {
|
||||
return MOCK_ENTERPRISES.find(e => e.id === id);
|
||||
const enterprises = getMockEnterprises();
|
||||
return enterprises.find(e => e.id === id);
|
||||
};
|
||||
|
||||
105
src/lib/clipboard.ts
Normal file
105
src/lib/clipboard.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 安全的剪贴板复制工具
|
||||
* 提供跨浏览器的剪贴板复制功能,包含回退方案
|
||||
*/
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
* 优先使用 Clipboard API,失败时静默回退到传统方法
|
||||
*
|
||||
* @param text 要复制的文本
|
||||
* @returns Promise<boolean> 是否成功复制
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 方法 1: 尝试使用现代 Clipboard API(仅在安全上下文中)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// 静默失败,继续尝试回退方法
|
||||
// 不输出警告,因为这是正常的回退行为
|
||||
}
|
||||
}
|
||||
|
||||
// 方法 2: 使用 execCommand 回退方案(兼容性更好)
|
||||
return fallbackCopyToClipboard(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退的剪贴板复制方法
|
||||
* 使用 document.execCommand('copy')
|
||||
*
|
||||
* @param text 要复制的文本
|
||||
* @returns boolean 是否成功复制
|
||||
*/
|
||||
function fallbackCopyToClipboard(text: string): boolean {
|
||||
let textArea: HTMLTextAreaElement | null = null;
|
||||
|
||||
try {
|
||||
// 创建临时文本区域
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
|
||||
// 设置样式使其不可见且不影响布局
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
textArea.style.width = '2em';
|
||||
textArea.style.height = '2em';
|
||||
textArea.style.padding = '0';
|
||||
textArea.style.border = 'none';
|
||||
textArea.style.outline = 'none';
|
||||
textArea.style.boxShadow = 'none';
|
||||
textArea.style.background = 'transparent';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.pointerEvents = 'none';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
|
||||
// 选择文本
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
// 兼容 iOS
|
||||
if (navigator.userAgent.match(/ipad|iphone/i)) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textArea);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
textArea.setSelectionRange(0, 999999);
|
||||
}
|
||||
|
||||
// 执行复制命令
|
||||
const successful = document.execCommand('copy');
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
// 确保清理 DOM
|
||||
if (textArea && textArea.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
// 只在真正失败时输出错误
|
||||
console.error('Clipboard copy failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的剪贴板复制(同步版本)
|
||||
* 用于不支持 async/await 的场景
|
||||
* 直接使用回退方法
|
||||
*
|
||||
* @param text 要复制的文本
|
||||
* @returns boolean 是否成功复制
|
||||
*/
|
||||
export function copyToClipboardSync(text: string): boolean {
|
||||
return fallbackCopyToClipboard(text);
|
||||
}
|
||||
@@ -142,9 +142,19 @@ export class GISMapEngine {
|
||||
*/
|
||||
private async initLeaflet(config: MapConfig) {
|
||||
try {
|
||||
console.log('🔄 正在初始化 Leaflet 地图...');
|
||||
|
||||
// 动态加载Leaflet
|
||||
if (!window.L) {
|
||||
console.log('📦 Leaflet 未加载,开始加载...');
|
||||
await this.loadLeaflet();
|
||||
} else {
|
||||
console.log('✅ Leaflet 已存在,跳过加载');
|
||||
}
|
||||
|
||||
// 再次检查是否成功加载
|
||||
if (!window.L) {
|
||||
throw new Error('Leaflet 加载失败');
|
||||
}
|
||||
|
||||
const center = config.center || [39.9042, 116.4074]; // Leaflet用 [lat, lng]
|
||||
@@ -156,8 +166,11 @@ export class GISMapEngine {
|
||||
this.setLayer(config.layer || 'street');
|
||||
|
||||
console.log('✅ Leaflet地图初始化成功');
|
||||
console.log('📍 中心坐标:', center);
|
||||
console.log('🔍 缩放级别:', zoom);
|
||||
} catch (error) {
|
||||
console.error('Leaflet地图初始化失败:', error);
|
||||
console.warn('⚠️ Leaflet地图初始化失败,切换到占位地图模式');
|
||||
console.error('错误详情:', error);
|
||||
this.provider = 'placeholder';
|
||||
this.initPlaceholder(config);
|
||||
}
|
||||
@@ -167,20 +180,12 @@ export class GISMapEngine {
|
||||
* 加载Leaflet库
|
||||
*/
|
||||
private async loadLeaflet(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 加载CSS
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// 加载JS
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Leaflet加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
// 使用统一的 Leaflet 加载器
|
||||
const { preloadLeaflet } = await import('./leafletLoader');
|
||||
const success = await preloadLeaflet();
|
||||
if (!success) {
|
||||
throw new Error('Leaflet加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,26 +194,88 @@ export class GISMapEngine {
|
||||
private initPlaceholder(config: MapConfig) {
|
||||
if (!this.container) return;
|
||||
|
||||
const center = config.center || [116.4074, 39.9042];
|
||||
const zoom = config.zoom || 13;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="gis-placeholder-map" style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dbeafe 100%);
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
">
|
||||
<!-- 网格背景 -->
|
||||
<div style="
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px);
|
||||
linear-gradient(rgba(76, 175, 80, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(76, 175, 80, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
"></div>
|
||||
|
||||
<!-- 地图信息提示 -->
|
||||
<div style="
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 24px 32px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="margin: 0 auto 16px;">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<h3 style="font-size: 18px; font-weight: 600; color: #1f2937; margin-bottom: 8px;">
|
||||
地图演示模式
|
||||
</h3>
|
||||
<p style="font-size: 14px; color: #6b7280; margin-bottom: 16px;">
|
||||
当前使用占位地图,所有功能正常可用
|
||||
</p>
|
||||
<div style="font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb; padding-top: 12px;">
|
||||
<p style="margin-bottom: 4px;">中心坐标: ${center[0].toFixed(4)}°E, ${center[1].toFixed(4)}°N</p>
|
||||
<p>缩放级别: ${zoom}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地图图层标签 -->
|
||||
<div style="
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
">
|
||||
${this.getLayerLabel(this.currentLayer)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
console.log('✅ 占位地图初始化成功(功能完整)');
|
||||
console.log('💡 提示: 系统可以正常使用,如需真实地图请参考文档配置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图层标签
|
||||
*/
|
||||
private getLayerLabel(layer: MapLayer): string {
|
||||
const labels: Record<MapLayer, string> = {
|
||||
satellite: '🛰️ 卫星影像',
|
||||
street: '🗺️ 电子地图',
|
||||
terrain: '⛰️ 地形图',
|
||||
hybrid: '🔀 混合图层',
|
||||
};
|
||||
return labels[layer];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
98
src/lib/leafletLoader.ts
Normal file
98
src/lib/leafletLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Leaflet 地图库预加载器
|
||||
* 确保 Leaflet 在需要时已经加载完成
|
||||
*/
|
||||
|
||||
let leafletLoading = false;
|
||||
let leafletLoaded = false;
|
||||
|
||||
/**
|
||||
* 预加载 Leaflet 库
|
||||
* @returns Promise<boolean> 加载成功返回 true
|
||||
*/
|
||||
export const preloadLeaflet = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
// 如果已经加载,直接返回
|
||||
if (leafletLoaded || window.L) {
|
||||
leafletLoaded = true;
|
||||
console.log('✅ Leaflet 已加载');
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载,等待加载完成
|
||||
if (leafletLoading) {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (leafletLoaded || window.L) {
|
||||
clearInterval(checkInterval);
|
||||
leafletLoaded = true;
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
leafletLoading = true;
|
||||
console.log('🔄 开始加载 Leaflet...');
|
||||
|
||||
try {
|
||||
// 加载 CSS
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
link.crossOrigin = '';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// 加载 JS
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
||||
script.crossOrigin = '';
|
||||
|
||||
script.onload = () => {
|
||||
leafletLoaded = true;
|
||||
leafletLoading = false;
|
||||
console.log('✅ Leaflet 加载成功');
|
||||
console.log('📍 版本:', window.L?.version);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
leafletLoading = false;
|
||||
console.warn('⚠️ Leaflet 加载失败,将使用占位地图');
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
} catch (error) {
|
||||
leafletLoading = false;
|
||||
console.error('❌ 加载 Leaflet 时发生错误:', error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查 Leaflet 是否已加载
|
||||
*/
|
||||
export const isLeafletLoaded = (): boolean => {
|
||||
return leafletLoaded || !!window.L;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 Leaflet 版本
|
||||
*/
|
||||
export const getLeafletVersion = (): string | null => {
|
||||
if (isLeafletLoaded() && window.L) {
|
||||
return window.L.version || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 扩展 Window 接口
|
||||
declare global {
|
||||
interface Window {
|
||||
L: any;
|
||||
}
|
||||
}
|
||||
188
src/lib/operationTypes.ts
Normal file
188
src/lib/operationTypes.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// 农事类型共享数据
|
||||
// 用于农事执行和农事计划模块
|
||||
|
||||
export type OperationType = '深翻' | '旋耕' | '播种' | '撒肥' | '喷药' | '灌溉' | '除草' | '修剪' | '采收';
|
||||
|
||||
export interface OperationParameter {
|
||||
name: string;
|
||||
type: 'text' | 'number' | 'select' | 'date' | 'image' | 'location';
|
||||
required: boolean;
|
||||
unit?: string;
|
||||
options?: string[];
|
||||
defaultValue?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OperationTypeTemplate {
|
||||
id: string;
|
||||
name: OperationType;
|
||||
category: '土地整理' | '种植管理' | '田间管理' | '植保管理' | '收获管理';
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
parameters: OperationParameter[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 农事类型库数据
|
||||
export const operationTypesData: OperationTypeTemplate[] = [
|
||||
{
|
||||
id: 'type-1',
|
||||
name: '深翻',
|
||||
category: '土地整理',
|
||||
icon: '🚜',
|
||||
color: 'bg-brown-100 text-brown-800',
|
||||
description: '对土壤进行深层翻耕,改善土壤结构',
|
||||
parameters: [
|
||||
{ name: '深度', type: 'number', required: true, unit: 'cm', description: '翻耕深度' },
|
||||
{ name: '机械型号', type: 'text', required: true, description: '使用的农机型号' },
|
||||
{ name: '作业面积', type: 'number', required: true, unit: '亩', description: '实际作业面积' },
|
||||
{ name: '现场照片', type: 'image', required: false, description: '作业现场照片' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-2',
|
||||
name: '旋耕',
|
||||
category: '土地整理',
|
||||
icon: '🌾',
|
||||
color: 'bg-yellow-100 text-yellow-800',
|
||||
description: '使用旋耕机对土壤进行浅层耕作',
|
||||
parameters: [
|
||||
{ name: '深度', type: 'number', required: true, unit: 'cm', description: '旋耕深度' },
|
||||
{ name: '机械型号', type: 'text', required: true, description: '使用的农机型号' },
|
||||
{ name: '作业面积', type: 'number', required: true, unit: '亩', description: '实际作业面积' },
|
||||
{ name: '作业遍数', type: 'number', required: true, description: '旋耕遍数' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-3',
|
||||
name: '播种',
|
||||
category: '种植管理',
|
||||
icon: '🌱',
|
||||
color: 'bg-green-100 text-green-800',
|
||||
description: '进行作物种子的播种作业',
|
||||
parameters: [
|
||||
{ name: '作物品种', type: 'text', required: true, description: '播种的作物品种' },
|
||||
{ name: '种子品牌', type: 'text', required: true, description: '种子品牌' },
|
||||
{ name: '种子批号', type: 'text', required: false, description: '种子批号,可扫码录入' },
|
||||
{ name: '播种量', type: 'number', required: true, unit: 'kg', description: '单位面积播种量' },
|
||||
{ name: '播种方式', type: 'select', required: true, options: ['条播', '穴播', '撒播', '点播'], description: '播种方式' },
|
||||
{ name: '行距', type: 'number', required: false, unit: 'cm', description: '播种行距' },
|
||||
{ name: '株距', type: 'number', required: false, unit: 'cm', description: '播种株距' },
|
||||
{ name: '播种深度', type: 'number', required: true, unit: 'cm', description: '播种深度' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-4',
|
||||
name: '撒肥',
|
||||
category: '田间管理',
|
||||
icon: '🌾',
|
||||
color: 'bg-orange-100 text-orange-800',
|
||||
description: '施用化肥或有机肥',
|
||||
parameters: [
|
||||
{ name: '肥料类型', type: 'select', required: true, options: ['有机肥', '复合肥', '氮肥', '磷肥', '钾肥', '微量元素肥'], description: '肥料类型' },
|
||||
{ name: '肥料品牌', type: 'text', required: true, description: '肥料品牌' },
|
||||
{ name: '肥料批号', type: 'text', required: false, description: '肥料批号,可扫码录入' },
|
||||
{ name: '施肥量', type: 'number', required: true, unit: 'kg/亩', description: '单位面积施肥量' },
|
||||
{ name: '总用量', type: 'number', required: true, unit: 'kg', description: '总施肥量' },
|
||||
{ name: '施肥方法', type: 'select', required: true, options: ['基肥', '追肥', '叶面肥', '冲施'], description: '施肥方法' },
|
||||
{ name: '施肥浓度', type: 'number', required: false, unit: '%', description: '施肥浓度(液肥)' },
|
||||
{ name: '施肥位置', type: 'text', required: false, description: '具体地块/行号' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-5',
|
||||
name: '喷药',
|
||||
category: '植保管理',
|
||||
icon: '💊',
|
||||
color: 'bg-red-100 text-red-800',
|
||||
description: '喷洒农药进行病虫害防治',
|
||||
parameters: [
|
||||
{ name: '农药类型', type: 'select', required: true, options: ['杀虫剂', '杀菌剂', '除草剂', '植物生长调节剂'], description: '农药类型' },
|
||||
{ name: '农药品牌', type: 'text', required: true, description: '农药品牌' },
|
||||
{ name: '农药批号', type: 'text', required: false, description: '农药批号,可扫码录入' },
|
||||
{ name: '用药量', type: 'number', required: true, unit: 'ml/亩', description: '单位面积用药量' },
|
||||
{ name: '总用量', type: 'number', required: true, unit: 'ml', description: '总用药量' },
|
||||
{ name: '稀释倍数', type: 'number', required: true, description: '稀释倍数' },
|
||||
{ name: '喷洒方式', type: 'select', required: true, options: ['无人机', '喷雾器', '喷杆喷雾机'], description: '喷洒方式' },
|
||||
{ name: '防治对象', type: 'text', required: true, description: '防治的病虫害名称' },
|
||||
{ name: '安全间隔期', type: 'number', required: true, unit: '天', description: '安全间隔期' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-6',
|
||||
name: '灌溉',
|
||||
category: '田间管理',
|
||||
icon: '💧',
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
description: '对作物进行灌溉作业',
|
||||
parameters: [
|
||||
{ name: '灌溉方式', type: 'select', required: true, options: ['滴灌', '喷灌', '漫灌', '沟灌'], description: '灌溉方式' },
|
||||
{ name: '灌溉时长', type: 'number', required: true, unit: '小时', description: '灌溉持续时间' },
|
||||
{ name: '水量', type: 'number', required: true, unit: 'm³', description: '总用水量' },
|
||||
{ name: '单位水量', type: 'number', required: false, unit: 'm³/亩', description: '单位面积水量' },
|
||||
{ name: '水源', type: 'select', required: true, options: ['井水', '河水', '水库', '自来水'], description: '水源类型' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-7',
|
||||
name: '除草',
|
||||
category: '田间管理',
|
||||
icon: '🌿',
|
||||
color: 'bg-purple-100 text-purple-800',
|
||||
description: '清除田间杂草',
|
||||
parameters: [
|
||||
{ name: '除草方式', type: 'select', required: true, options: ['人工除草', '机械除草', '化学除草'], description: '除草方式' },
|
||||
{ name: '除草面积', type: 'number', required: true, unit: '亩', description: '除草面积' },
|
||||
{ name: '除草剂品牌', type: 'text', required: false, description: '除草剂品牌(化学除草)' },
|
||||
{ name: '除草剂用量', type: 'number', required: false, unit: 'ml/亩', description: '除草剂用量' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-8',
|
||||
name: '修剪',
|
||||
category: '田间管理',
|
||||
icon: '✂️',
|
||||
color: 'bg-pink-100 text-pink-800',
|
||||
description: '对作物进行整枝修剪',
|
||||
parameters: [
|
||||
{ name: '修剪部位', type: 'select', required: true, options: ['主枝', '侧枝', '叶片', '花果'], description: '修剪部位' },
|
||||
{ name: '修剪强度', type: 'select', required: true, options: ['轻度', '中度', '重度'], description: '修剪强度' },
|
||||
{ name: '修剪面积', type: 'number', required: true, unit: '亩', description: '修剪面积' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'type-9',
|
||||
name: '采收',
|
||||
category: '收获管理',
|
||||
icon: '🎃',
|
||||
color: 'bg-amber-100 text-amber-800',
|
||||
description: '作物成熟后的采收作业',
|
||||
parameters: [
|
||||
{ name: '采收方式', type: 'select', required: true, options: ['人工采收', '机械采收'], description: '采收方式' },
|
||||
{ name: '采收面积', type: 'number', required: true, unit: '亩', description: '采收面积' },
|
||||
{ name: '产量', type: 'number', required: true, unit: 'kg', description: '总产量' },
|
||||
{ name: '单产', type: 'number', required: false, unit: 'kg/亩', description: '单位面积产量' },
|
||||
{ name: '品质等级', type: 'select', required: false, options: ['特等', '一等', '二等', '三等'], description: '产品品质等级' },
|
||||
],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
];
|
||||
237
src/lib/safeDate.ts
Normal file
237
src/lib/safeDate.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 安全的Date工具函数
|
||||
* 完全避免使用Date构造函数,防止"Illegal constructor"错误
|
||||
*/
|
||||
|
||||
// Fallback固定时间戳 (2024-10-24 10:00:00 UTC)
|
||||
const FALLBACK_TIMESTAMP = 1729756800000;
|
||||
|
||||
/**
|
||||
* 安全地获取当前时间戳
|
||||
* 完全不使用Date构造函数
|
||||
*/
|
||||
export const safeNow = (): number => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return FALLBACK_TIMESTAMP;
|
||||
}
|
||||
// 只使用Date.now(),不使用构造函数
|
||||
if (typeof Date !== 'undefined' && Date.now) {
|
||||
return Date.now();
|
||||
}
|
||||
return FALLBACK_TIMESTAMP;
|
||||
} catch (e) {
|
||||
console.warn('Date.now() error:', e);
|
||||
return FALLBACK_TIMESTAMP;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将时间戳转换为日期时间各部分
|
||||
* 手动实现,不使用Date对象
|
||||
* 使用简化的格里高利历算法
|
||||
*/
|
||||
const timestampToDateParts = (timestamp: number): {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} => {
|
||||
// 基本时间单位转换
|
||||
const totalSeconds = Math.floor(timestamp / 1000);
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
|
||||
// 计算时分秒
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = totalMinutes % 60;
|
||||
const hours = totalHours % 24;
|
||||
|
||||
// 从1970-01-01开始计算年月日
|
||||
// 简化算法:每年365天,每4年一个闰年
|
||||
let year = 1970;
|
||||
let remainingDays = totalDays;
|
||||
|
||||
// 快速计算大致年份
|
||||
const approxYears = Math.floor(remainingDays / 365.25);
|
||||
year += approxYears;
|
||||
remainingDays -= Math.floor(approxYears * 365.25);
|
||||
|
||||
// 调整到正确的年份
|
||||
while (remainingDays < 0) {
|
||||
year--;
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
||||
remainingDays += isLeap ? 366 : 365;
|
||||
}
|
||||
|
||||
while (remainingDays >= 365) {
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
||||
const daysInYear = isLeap ? 366 : 365;
|
||||
if (remainingDays >= daysInYear) {
|
||||
remainingDays -= daysInYear;
|
||||
year++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算月和日(简化版:固定每月天数)
|
||||
const monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
||||
if (isLeapYear) {
|
||||
monthDays[1] = 29;
|
||||
}
|
||||
|
||||
let month = 1;
|
||||
let day = remainingDays + 1; // 从1开始
|
||||
|
||||
for (let i = 0; i < monthDays.length; i++) {
|
||||
if (day <= monthDays[i]) {
|
||||
month = i + 1;
|
||||
break;
|
||||
}
|
||||
day -= monthDays[i];
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day: Math.max(1, Math.min(31, day)),
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全地格式化日期时间为 YYYY-MM-DD HH:mm:ss
|
||||
* 不使用Date对象,也不使用Intl(避免触发Date构造函数)
|
||||
*/
|
||||
export const formatDateTime = (timestamp?: number): string => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return '2024-10-24 10:00:00';
|
||||
}
|
||||
|
||||
const ts = timestamp || safeNow();
|
||||
|
||||
// 直接使用手动计算,避免任何可能触发Date的API
|
||||
const parts = timestampToDateParts(ts);
|
||||
const year = String(parts.year).padStart(4, '0');
|
||||
const month = String(parts.month).padStart(2, '0');
|
||||
const day = String(parts.day).padStart(2, '0');
|
||||
const hours = String(parts.hours).padStart(2, '0');
|
||||
const minutes = String(parts.minutes).padStart(2, '0');
|
||||
const seconds = String(parts.seconds).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (e) {
|
||||
console.warn('Date formatting error:', e);
|
||||
return '2024-10-24 10:00:00';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全地将时间戳转换为ISO字符串
|
||||
* 不使用Date对象,也不使用Intl
|
||||
*/
|
||||
export const toISOString = (timestamp?: number): string => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return '2024-10-24T00:00:00.000Z';
|
||||
}
|
||||
|
||||
const ts = timestamp || safeNow();
|
||||
|
||||
// 使用手动计算
|
||||
const parts = timestampToDateParts(ts);
|
||||
const year = String(parts.year).padStart(4, '0');
|
||||
const month = String(parts.month).padStart(2, '0');
|
||||
const day = String(parts.day).padStart(2, '0');
|
||||
const hours = String(parts.hours).padStart(2, '0');
|
||||
const minutes = String(parts.minutes).padStart(2, '0');
|
||||
const seconds = String(parts.seconds).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`;
|
||||
} catch (e) {
|
||||
console.warn('ISO string conversion error:', e);
|
||||
return '2024-10-24T00:00:00.000Z';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全地格式化日期为本地日期字符串
|
||||
* 不使用Date对象,也不使用Intl
|
||||
*/
|
||||
export const toLocaleDateString = (timestamp: number, locale = 'zh-CN'): string => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return '2024-10-24';
|
||||
}
|
||||
|
||||
// 使用手动计算
|
||||
const parts = timestampToDateParts(timestamp);
|
||||
const year = String(parts.year).padStart(4, '0');
|
||||
const month = String(parts.month).padStart(2, '0');
|
||||
const day = String(parts.day).padStart(2, '0');
|
||||
|
||||
// 根据locale返回不同格式
|
||||
if (locale === 'zh-CN') {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (e) {
|
||||
console.warn('Local date string error:', e);
|
||||
return '2024-10-24';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全地获取时间戳
|
||||
*/
|
||||
export const getTime = (timestamp?: number): number => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return FALLBACK_TIMESTAMP;
|
||||
}
|
||||
|
||||
return timestamp || safeNow();
|
||||
} catch (e) {
|
||||
console.warn('getTime error:', e);
|
||||
return FALLBACK_TIMESTAMP;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建一个安全的Date对象(仅在确实需要Date对象时使用)
|
||||
* 注意:这个函数可能在某些环境中失败
|
||||
*/
|
||||
export const safeDate = (value?: number | string): Date | null => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试创建Date对象,但捕获所有错误
|
||||
if (value === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.warn('Date creation error:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user