添加管理端和移动端的多个新功能模块,包括文件上传、在线地图、用户认证、系统设置等,优化代码结构,提升可维护性和用户体验。
This commit is contained in:
18
client/mobile/deno.json
Normal file
18
client/mobile/deno.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.d8d.fun/react@19.0.0?deps=react@19.0.0,react-dom@19.0.0",
|
||||
"react-dom": "https://esm.d8d.fun/react-dom@19.0.0?deps=react@19.0.0,react-dom@19.0.0",
|
||||
"react-dom/client": "https://esm.d8d.fun/react-dom@19.0.0/client?deps=react@19.0.0,react-dom@19.0.0",
|
||||
"react-router": "https://esm.d8d.fun/react-router@7.3.0?deps=react@19.0.0,react-dom@19.0.0",
|
||||
"@heroicons/react/24/outline": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/outline?deps=react@19.0.0",
|
||||
"@heroicons/react/24/solid": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/solid?deps=react@19.0.0",
|
||||
"axios": "https://esm.d8d.fun/axios@1.6.7",
|
||||
"dayjs": "https://esm.d8d.fun/dayjs@1.11.13",
|
||||
"dayjs/plugin/weekday": "https://esm.d8d.fun/dayjs@1.11.13/plugin/weekday",
|
||||
"dayjs/plugin/localeData": "https://esm.d8d.fun/dayjs@1.11.13/plugin/localeData",
|
||||
"dayjs/locale/zh-cn": "https://esm.d8d.fun/dayjs@1.11.13/locale/zh-cn",
|
||||
"@tanstack/react-query": "https://esm.d8d.fun/@tanstack/react-query@5.67.1?deps=react@19.0.0,react-dom@19.0.0",
|
||||
"@d8d-appcontainer/api": "https://esm.d8d.fun/@d8d-appcontainer/api@3.0.47",
|
||||
"@d8d-appcontainer/types": "https://esm.d8d.fun/@d8d-appcontainer/types@3.0.47"
|
||||
}
|
||||
}
|
||||
271
client/mobile/hooks.tsx
Normal file
271
client/mobile/hooks.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { getLocalStorageWithExpiry, setLocalStorageWithExpiry } from './utils.ts';
|
||||
import type { User, AuthContextType, ThemeContextType, ThemeSettings } from '../share/types.ts';
|
||||
import { ThemeMode, FontSize, CompactMode } from '../share/types.ts';
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: window.CONFIG?.API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
// 请求拦截器添加token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getLocalStorageWithExpiry('token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 清除本地存储并刷新页面
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/mobile/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 默认主题设置
|
||||
const defaultThemeSettings: ThemeSettings = {
|
||||
user_id: 0,
|
||||
theme_mode: ThemeMode.LIGHT,
|
||||
primary_color: '#3B82F6', // 蓝色
|
||||
background_color: '#F9FAFB',
|
||||
text_color: '#111827',
|
||||
border_radius: 8,
|
||||
font_size: FontSize.MEDIUM,
|
||||
is_compact: CompactMode.NORMAL
|
||||
};
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
token: null,
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
isAuthenticated: false,
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
// 创建主题上下文
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
isDark: false,
|
||||
currentTheme: defaultThemeSettings,
|
||||
updateTheme: () => {},
|
||||
saveTheme: async () => defaultThemeSettings,
|
||||
resetTheme: async () => defaultThemeSettings,
|
||||
toggleTheme: () => {}
|
||||
});
|
||||
|
||||
// 认证提供者组件
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 初始化时从本地存储获取用户信息和令牌
|
||||
useEffect(() => {
|
||||
const storedToken = getLocalStorageWithExpiry('token');
|
||||
const storedUser = getLocalStorageWithExpiry('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 登录函数
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login', { username, password });
|
||||
const { token, user } = response.data;
|
||||
|
||||
// 保存到状态和本地存储
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
setLocalStorageWithExpiry('token', token, 24); // 24小时过期
|
||||
setLocalStorageWithExpiry('user', user, 24);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 登出函数
|
||||
const logout = async () => {
|
||||
try {
|
||||
// 调用登出API
|
||||
await api.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('登出API调用失败:', error);
|
||||
} finally {
|
||||
// 无论API调用成功与否,都清除本地状态
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!token,
|
||||
isLoading
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 主题提供者组件
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeSettings>(() => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
return storedTheme ? JSON.parse(storedTheme) : defaultThemeSettings;
|
||||
});
|
||||
|
||||
const isDark = currentTheme.theme_mode === ThemeMode.DARK;
|
||||
|
||||
// 更新主题(实时预览)
|
||||
const updateTheme = (theme: Partial<ThemeSettings>) => {
|
||||
setCurrentTheme(prev => {
|
||||
const updatedTheme = { ...prev, ...theme };
|
||||
localStorage.setItem('theme', JSON.stringify(updatedTheme));
|
||||
return updatedTheme;
|
||||
});
|
||||
};
|
||||
|
||||
// 保存主题到后端
|
||||
const saveTheme = async (theme: Partial<ThemeSettings>): Promise<ThemeSettings> => {
|
||||
try {
|
||||
const updatedTheme = { ...currentTheme, ...theme };
|
||||
const { data } = await api.post('/theme/save', updatedTheme);
|
||||
|
||||
setCurrentTheme(data);
|
||||
localStorage.setItem('theme', JSON.stringify(data));
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('保存主题失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置主题
|
||||
const resetTheme = async (): Promise<ThemeSettings> => {
|
||||
try {
|
||||
const { data } = await api.post('/theme/reset');
|
||||
|
||||
setCurrentTheme(data);
|
||||
localStorage.setItem('theme', JSON.stringify(data));
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('重置主题失败:', error);
|
||||
|
||||
// 如果API失败,至少重置到默认主题
|
||||
setCurrentTheme(defaultThemeSettings);
|
||||
localStorage.setItem('theme', JSON.stringify(defaultThemeSettings));
|
||||
|
||||
return defaultThemeSettings;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换主题模式(亮色/暗色)
|
||||
const toggleTheme = () => {
|
||||
const newMode = isDark ? ThemeMode.LIGHT : ThemeMode.DARK;
|
||||
const updatedTheme = {
|
||||
...currentTheme,
|
||||
theme_mode: newMode,
|
||||
// 暗色和亮色模式下自动调整背景色和文字颜色
|
||||
background_color: newMode === ThemeMode.DARK ? '#121212' : '#F9FAFB',
|
||||
text_color: newMode === ThemeMode.DARK ? '#E5E7EB' : '#111827'
|
||||
};
|
||||
|
||||
setCurrentTheme(updatedTheme);
|
||||
localStorage.setItem('theme', JSON.stringify(updatedTheme));
|
||||
};
|
||||
|
||||
// 主题变化时应用CSS变量
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--primary-color', currentTheme.primary_color);
|
||||
document.documentElement.style.setProperty('--background-color', currentTheme.background_color || '#F9FAFB');
|
||||
document.documentElement.style.setProperty('--text-color', currentTheme.text_color || '#111827');
|
||||
document.documentElement.style.setProperty('--border-radius', `${currentTheme.border_radius || 8}px`);
|
||||
|
||||
// 设置字体大小
|
||||
let rootFontSize = '16px'; // 默认中等字体
|
||||
if (currentTheme.font_size === FontSize.SMALL) {
|
||||
rootFontSize = '14px';
|
||||
} else if (currentTheme.font_size === FontSize.LARGE) {
|
||||
rootFontSize = '18px';
|
||||
}
|
||||
document.documentElement.style.setProperty('--font-size', rootFontSize);
|
||||
|
||||
// 设置暗色模式类
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [currentTheme, isDark]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
isDark,
|
||||
currentTheme,
|
||||
updateTheme,
|
||||
saveTheme,
|
||||
resetTheme,
|
||||
toggleTheme
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 主题hook
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
// 认证hook
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
// API hook
|
||||
export const useApi = () => {
|
||||
const { token } = useAuth();
|
||||
|
||||
return {
|
||||
api,
|
||||
isAuthenticated: !!token
|
||||
};
|
||||
};
|
||||
313
client/mobile/mobile_app.tsx
Normal file
313
client/mobile/mobile_app.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
Outlet,
|
||||
Navigate,
|
||||
useLocation
|
||||
} from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, ThemeProvider, useAuth } from './hooks.tsx';
|
||||
import HomePage from './pages/index.tsx';
|
||||
import LoginPage from './pages/login.tsx';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
// 设置中文语言
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建QueryClient实例
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// 添加全局CSS(使用TailwindCSS的类)
|
||||
const injectGlobalStyles = () => {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
:root {
|
||||
--primary-color: #3B82F6;
|
||||
--background-color: #F9FAFB;
|
||||
--text-color: #111827;
|
||||
--border-radius: 8px;
|
||||
--font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: var(--font-size);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 暗色模式支持 */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #121212;
|
||||
color: #E5E7EB;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #BFDBFE;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #93C5FD;
|
||||
}
|
||||
|
||||
/* 移动端点击高亮颜色 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// 授权路由守卫
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/mobile/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 页面组件
|
||||
const PageNotFound = () => (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||
<div className="text-6xl font-bold text-blue-600 mb-4">404</div>
|
||||
<h1 className="text-2xl font-medium mb-2">页面不存在</h1>
|
||||
<p className="text-gray-500 mb-6">您访问的页面不存在或已被移除</p>
|
||||
<a
|
||||
href="/mobile"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 添加个人页面组件
|
||||
const ProfilePage = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">我的</h1>
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mr-4">
|
||||
<span className="text-2xl text-blue-600">用户</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">用户名</h2>
|
||||
<p className="text-gray-500">个人信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<span className="font-medium">设置</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<span>账号安全</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
</div>
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<span>通知设置</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
</div>
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<span>隐私</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
</div>
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<span>关于</span>
|
||||
<span className="text-gray-400">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 添加通知页面组件
|
||||
const NotificationsPage = () => (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">通知</h1>
|
||||
<div className="bg-white rounded-lg shadow divide-y">
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium">系统通知</h3>
|
||||
<p className="text-gray-500 text-sm mt-1">欢迎使用移动应用!</p>
|
||||
<p className="text-xs text-gray-400 mt-2">今天 10:00</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium">活动提醒</h3>
|
||||
<p className="text-gray-500 text-sm mt-1">您有一个新的活动邀请</p>
|
||||
<p className="text-xs text-gray-400 mt-2">昨天 14:30</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端布局组件 - 包含底部导航
|
||||
const MobileLayout = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-1 pb-16">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 底部导航栏 */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg">
|
||||
<div className="flex justify-around">
|
||||
<a
|
||||
href="/mobile"
|
||||
className={`flex flex-col items-center py-2 px-4 ${
|
||||
location.pathname === '/mobile' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xl mb-1">🏠</div>
|
||||
<span className="text-xs">首页</span>
|
||||
</a>
|
||||
<a
|
||||
href="/mobile/notifications"
|
||||
className={`flex flex-col items-center py-2 px-4 ${
|
||||
location.pathname === '/mobile/notifications' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xl mb-1">🔔</div>
|
||||
<span className="text-xs">通知</span>
|
||||
</a>
|
||||
<a
|
||||
href="/mobile/profile"
|
||||
className={`flex flex-col items-center py-2 px-4 ${
|
||||
location.pathname === '/mobile/profile' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xl mb-1">👤</div>
|
||||
<span className="text-xs">我的</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 主应用组件
|
||||
const App = () => {
|
||||
// 创建路由器配置
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/mobile" replace />
|
||||
},
|
||||
{
|
||||
path: '/mobile/login',
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: '/mobile',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MobileLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
element: <NotificationsPage />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <PageNotFound />
|
||||
}
|
||||
]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
// 渲染应用到DOM
|
||||
const initApp = () => {
|
||||
// 注入全局样式
|
||||
injectGlobalStyles();
|
||||
|
||||
// 渲染应用
|
||||
const root = createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// 初始化应用
|
||||
initApp();
|
||||
336
client/mobile/pages/index.tsx
Normal file
336
client/mobile/pages/index.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import {
|
||||
HomeIcon,
|
||||
UserIcon,
|
||||
NewspaperIcon,
|
||||
BellIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../hooks.tsx';
|
||||
import { formatRelativeTime } from '../utils.ts';
|
||||
|
||||
interface BannerItem {
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface NewsItem {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
publish_date: string;
|
||||
cover?: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface NoticeItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
// 首页组件
|
||||
const HomePage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [banners, setBanners] = useState<BannerItem[]>([]);
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [notices, setNotices] = useState<NoticeItem[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('news');
|
||||
|
||||
// 模拟加载数据
|
||||
useEffect(() => {
|
||||
// 模拟API请求
|
||||
setTimeout(() => {
|
||||
// 模拟轮播图数据
|
||||
setBanners([
|
||||
{
|
||||
id: 1,
|
||||
title: '欢迎使用移动端应用',
|
||||
image: 'https://images.unsplash.com/photo-1518655048521-f130df041f66?ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8cG9ydGZvbGlvJTIwYmFja2dyb3VuZHxlbnwwfHwwfHw%3D&ixlib=rb-1.2.1&w=1000&q=80',
|
||||
link: '/welcome'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '新功能上线了',
|
||||
image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8cG9ydGZvbGlvJTIwYmFja2dyb3VuZHxlbnwwfHwwfHw%3D&ixlib=rb-1.2.1&w=1000&q=80',
|
||||
link: '/new-features'
|
||||
}
|
||||
]);
|
||||
|
||||
// 模拟新闻数据
|
||||
setNews([
|
||||
{
|
||||
id: 1,
|
||||
title: '用户体验升级,新版本发布',
|
||||
summary: '我们很高兴地宣布,新版本已经发布,带来了更好的用户体验和更多新功能。',
|
||||
publish_date: '2023-05-01T08:30:00',
|
||||
cover: 'https://images.unsplash.com/photo-1496171367470-9ed9a91ea931?ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTB8fHRlY2h8ZW58MHx8MHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60',
|
||||
category: '产品更新'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '新的数据分析功能上线',
|
||||
summary: '新的数据分析功能让您更深入地了解您的业务数据,提供更好的决策支持。',
|
||||
publish_date: '2023-04-25T14:15:00',
|
||||
cover: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTJ8fGNoYXJ0fGVufDB8fDB8fA%3D%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60',
|
||||
category: '功能介绍'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '如何提高工作效率的5个小技巧',
|
||||
summary: '这篇文章分享了5个可以立即实施的小技巧,帮助您提高日常工作效率。',
|
||||
publish_date: '2023-04-20T09:45:00',
|
||||
category: '使用技巧'
|
||||
}
|
||||
]);
|
||||
|
||||
// 模拟通知数据
|
||||
setNotices([
|
||||
{
|
||||
id: 1,
|
||||
title: '系统维护通知',
|
||||
content: '我们将于本周六凌晨2点至4点进行系统维护,期间系统可能会出现短暂不可用。',
|
||||
created_at: '2023-05-02T10:00:00',
|
||||
is_read: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '您的账户信息已更新',
|
||||
content: '您的账户信息已成功更新,如非本人操作,请及时联系客服。',
|
||||
created_at: '2023-05-01T16:30:00',
|
||||
is_read: true
|
||||
}
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// 处理轮播图点击
|
||||
const handleBannerClick = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
// 处理新闻点击
|
||||
const handleNewsClick = (id: number) => {
|
||||
navigate(`/news/${id}`);
|
||||
};
|
||||
|
||||
// 处理通知点击
|
||||
const handleNoticeClick = (id: number) => {
|
||||
navigate(`/notices/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
{/* 顶部用户信息 */}
|
||||
<div className="bg-blue-600 text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user?.nickname || user?.username || '用户'}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">
|
||||
{user ? `您好,${user.nickname || user.username}` : '您好,游客'}
|
||||
</h2>
|
||||
<p className="text-sm text-white/80">
|
||||
{user ? '欢迎回来' : '请登录体验更多功能'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<BellIcon className="w-6 h-6" />
|
||||
{notices.some(notice => !notice.is_read) && (
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 轮播图 */}
|
||||
{!loading && banners.length > 0 && (
|
||||
<div className="relative w-full h-40 overflow-hidden mt-2">
|
||||
<div className="flex transition-transform duration-300"
|
||||
style={{ transform: `translateX(-${0 * 100}%)` }}>
|
||||
{banners.map((banner) => (
|
||||
<div
|
||||
key={banner.id}
|
||||
className="w-full h-40 flex-shrink-0 relative"
|
||||
onClick={() => handleBannerClick(banner.link)}
|
||||
>
|
||||
<img
|
||||
src={banner.image}
|
||||
alt={banner.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<h3 className="text-white font-medium">{banner.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center space-x-1">
|
||||
{banners.map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full ${index === 0 ? 'bg-white' : 'bg-white/50'}`}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<div className="grid grid-cols-4 gap-2 p-4 bg-white rounded-lg shadow mt-4 mx-2">
|
||||
{[
|
||||
{ icon: <HomeIcon className="w-6 h-6" />, name: '首页', path: '/' },
|
||||
{ icon: <NewspaperIcon className="w-6 h-6" />, name: '资讯', path: '/news' },
|
||||
{ icon: <BellIcon className="w-6 h-6" />, name: '通知', path: '/notices' },
|
||||
{ icon: <UserIcon className="w-6 h-6" />, name: '我的', path: '/profile' }
|
||||
].map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-center p-2"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 mb-1">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-sm">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容标签页 */}
|
||||
<div className="mt-4 mx-2">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-2 text-center ${activeTab === 'news' ? 'text-blue-600 border-b-2 border-blue-600 font-medium' : 'text-gray-500'}`}
|
||||
onClick={() => setActiveTab('news')}
|
||||
>
|
||||
最新资讯
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2 text-center ${activeTab === 'notices' ? 'text-blue-600 border-b-2 border-blue-600 font-medium' : 'text-gray-500'}`}
|
||||
onClick={() => setActiveTab('notices')}
|
||||
>
|
||||
通知公告
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{activeTab === 'news' ? (
|
||||
loading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="w-6 h-6 border-2 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{news.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white p-3 rounded-lg shadow flex items-start space-x-3"
|
||||
onClick={() => handleNewsClick(item.id)}
|
||||
>
|
||||
{item.cover && (
|
||||
<img
|
||||
src={item.cover}
|
||||
alt={item.title}
|
||||
className="w-20 h-20 object-cover rounded-md flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className={item.cover ? '' : 'w-full'}>
|
||||
<h3 className="font-medium text-gray-900 line-clamp-2">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{item.summary}</p>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded-full">
|
||||
{item.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(item.publish_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
loading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="w-6 h-6 border-2 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notices.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white p-3 rounded-lg shadow"
|
||||
onClick={() => handleNoticeClick(item.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className={`font-medium ${item.is_read ? 'text-gray-700' : 'text-blue-600'}`}>
|
||||
{!item.is_read && (
|
||||
<span className="inline-block w-2 h-2 bg-blue-600 rounded-full mr-2"></span>
|
||||
)}
|
||||
{item.title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 mt-1">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2 line-clamp-2">{item.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部导航 */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around py-2">
|
||||
{[
|
||||
{ icon: <HomeIcon className="w-6 h-6" />, name: '首页', path: '/' },
|
||||
{ icon: <NewspaperIcon className="w-6 h-6" />, name: '资讯', path: '/news' },
|
||||
{ icon: <BellIcon className="w-6 h-6" />, name: '通知', path: '/notices' },
|
||||
{ icon: <UserIcon className="w-6 h-6" />, name: '我的', path: '/profile' }
|
||||
].map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<div className={`${location.pathname === item.path ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className={`text-xs mt-1 ${location.pathname === item.path ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
144
client/mobile/pages/login.tsx
Normal file
144
client/mobile/pages/login.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowRightIcon, LockClosedIcon, UserIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../hooks.tsx';
|
||||
import { handleApiError } from '../utils.ts';
|
||||
|
||||
// 登录页面组件
|
||||
const LoginPage: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('用户名和密码不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-blue-500 to-blue-700 p-6">
|
||||
{/* 顶部Logo和标题 */}
|
||||
<div className="flex flex-col items-center justify-center mt-10 mb-8">
|
||||
<div className="w-20 h-20 bg-white rounded-2xl flex items-center justify-center shadow-lg mb-4">
|
||||
<svg className="w-12 h-12 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" />
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{window.CONFIG?.APP_NAME || '移动应用'}
|
||||
</h1>
|
||||
<p className="text-blue-100 mt-2">登录您的账户</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg mb-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="username">
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-medium mb-2" htmlFor="password">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<LockClosedIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<ArrowRightIcon className="h-5 w-5 mr-2" />
|
||||
)}
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
onClick={() => navigate('/register')}
|
||||
>
|
||||
注册账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
忘记密码?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部文本 */}
|
||||
<div className="mt-auto pt-8 text-center text-blue-100 text-sm">
|
||||
© {new Date().getFullYear()} {window.CONFIG?.APP_NAME || '移动应用'}
|
||||
<p className="mt-1">保留所有权利</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
169
client/mobile/utils.ts
Normal file
169
client/mobile/utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { EnableStatus, DeleteStatus, AuditStatus } from '../share/types.ts';
|
||||
|
||||
// 日期格式化
|
||||
export const formatDate = (date: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
if (!date) return '-';
|
||||
return dayjs(date).format(format);
|
||||
};
|
||||
|
||||
// 格式化时间为相对时间(如:3小时前)
|
||||
export const formatRelativeTime = (date: string | Date): string => {
|
||||
if (!date) return '-';
|
||||
const now = dayjs();
|
||||
const dateObj = dayjs(date);
|
||||
const diffInSeconds = now.diff(dateObj, 'second');
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return `${diffInSeconds}秒前`;
|
||||
} else if (diffInSeconds < 3600) {
|
||||
return `${Math.floor(diffInSeconds / 60)}分钟前`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
return `${Math.floor(diffInSeconds / 3600)}小时前`;
|
||||
} else if (diffInSeconds < 2592000) {
|
||||
return `${Math.floor(diffInSeconds / 86400)}天前`;
|
||||
} else {
|
||||
return formatDate(date, 'YYYY-MM-DD');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取枚举的选项(用于下拉菜单等)
|
||||
export const getEnumOptions = (enumObj: Record<string | number, string | number>) => {
|
||||
return Object.entries(enumObj)
|
||||
.filter(([key]) => !isNaN(Number(key))) // 过滤掉映射对象中的字符串键
|
||||
.map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label: String(label)
|
||||
}));
|
||||
};
|
||||
|
||||
// 获取启用状态选项
|
||||
export const getEnableStatusOptions = () => {
|
||||
return [
|
||||
{ value: EnableStatus.ENABLED, label: '启用' },
|
||||
{ value: EnableStatus.DISABLED, label: '禁用' }
|
||||
];
|
||||
};
|
||||
|
||||
// 获取删除状态选项
|
||||
export const getDeleteStatusOptions = () => {
|
||||
return [
|
||||
{ value: DeleteStatus.NOT_DELETED, label: '未删除' },
|
||||
{ value: DeleteStatus.DELETED, label: '已删除' }
|
||||
];
|
||||
};
|
||||
|
||||
// 获取审核状态选项
|
||||
export const getAuditStatusOptions = () => {
|
||||
return [
|
||||
{ value: AuditStatus.PENDING, label: '待审核' },
|
||||
{ value: AuditStatus.APPROVED, label: '已通过' },
|
||||
{ value: AuditStatus.REJECTED, label: '已拒绝' }
|
||||
];
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 处理API错误
|
||||
export const handleApiError = (error: any): string => {
|
||||
if (error.response) {
|
||||
// 服务器响应错误
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
if (status === 401) {
|
||||
return '您的登录已过期,请重新登录';
|
||||
} else if (status === 403) {
|
||||
return '您没有权限执行此操作';
|
||||
} else if (status === 404) {
|
||||
return '请求的资源不存在';
|
||||
} else if (status === 422) {
|
||||
// 表单验证错误
|
||||
return data.message || '输入数据无效';
|
||||
} else {
|
||||
return data.message || `服务器错误 (${status})`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求发送成功但没有收到响应
|
||||
return '网络连接错误,请检查您的网络连接';
|
||||
} else {
|
||||
// 请求设置错误
|
||||
return '应用程序错误,请稍后再试';
|
||||
}
|
||||
};
|
||||
|
||||
// 复制文本到剪贴板
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('复制到剪贴板失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 防抖函数
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: number | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
}, wait) as unknown as number;
|
||||
};
|
||||
};
|
||||
|
||||
// 生成随机颜色
|
||||
export const getRandomColor = (): string => {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
};
|
||||
|
||||
// 获取本地存储值,带过期检查
|
||||
export const getLocalStorageWithExpiry = (key: string) => {
|
||||
const itemStr = localStorage.getItem(key);
|
||||
|
||||
if (!itemStr) return null;
|
||||
|
||||
const item = JSON.parse(itemStr);
|
||||
const now = new Date();
|
||||
|
||||
if (item.expiry && now.getTime() > item.expiry) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
};
|
||||
|
||||
// 设置本地存储值,带过期时间
|
||||
export const setLocalStorageWithExpiry = (key: string, value: any, expiryHours = 24) => {
|
||||
const now = new Date();
|
||||
const item = {
|
||||
value: value,
|
||||
expiry: now.getTime() + expiryHours * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(item));
|
||||
};
|
||||
@@ -308,4 +308,103 @@ export enum AllowedFileType {
|
||||
}
|
||||
|
||||
// 允许的文件类型列表(用于系统设置)
|
||||
export const ALLOWED_FILE_TYPES = Object.values(AllowedFileType).join(',');
|
||||
export const ALLOWED_FILE_TYPES = Object.values(AllowedFileType).join(',');
|
||||
|
||||
// 文件库接口
|
||||
export interface FileLibrary {
|
||||
/** 主键ID */
|
||||
id: number;
|
||||
|
||||
/** 文件名称 */
|
||||
file_name: string;
|
||||
|
||||
/** 原始文件名 */
|
||||
original_filename?: string;
|
||||
|
||||
/** 文件路径 */
|
||||
file_path: string;
|
||||
|
||||
/** 文件类型 */
|
||||
file_type: string;
|
||||
|
||||
/** 文件大小(字节) */
|
||||
file_size: number;
|
||||
|
||||
/** 上传用户ID */
|
||||
uploader_id?: number;
|
||||
|
||||
/** 上传者名称 */
|
||||
uploader_name?: string;
|
||||
|
||||
/** 文件分类 */
|
||||
category_id?: number;
|
||||
|
||||
/** 文件标签 */
|
||||
tags?: string;
|
||||
|
||||
/** 文件描述 */
|
||||
description?: string;
|
||||
|
||||
/** 下载次数 */
|
||||
download_count: number;
|
||||
|
||||
/** 是否禁用 (0否 1是) */
|
||||
is_disabled?: EnableStatus;
|
||||
|
||||
/** 是否被删除 (0否 1是) */
|
||||
is_deleted?: DeleteStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 文件分类接口
|
||||
export interface FileCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_deleted?: DeleteStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
// 知识库表
|
||||
export interface KnowInfo {
|
||||
/** 主键ID */
|
||||
id: number;
|
||||
|
||||
/** 文章的标题 */
|
||||
title?: string;
|
||||
|
||||
/** 文章的标签 */
|
||||
tags?: string;
|
||||
|
||||
/** 文章的内容 */
|
||||
content?: string;
|
||||
|
||||
/** 文章的作者 */
|
||||
author?: string;
|
||||
|
||||
/** 文章的分类 */
|
||||
category?: string;
|
||||
|
||||
/** 文章的封面图片URL */
|
||||
cover_url?: string;
|
||||
|
||||
/** 审核状态 */
|
||||
audit_status?: number;
|
||||
|
||||
/** 是否被删除 (0否 1是) */
|
||||
is_deleted?: number;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at: Date;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import { APIClient } from '@d8d-appcontainer/api'
|
||||
import debug from "debug"
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import type { SystemSettingRecord, GlobalConfig } from './asset/share/types.ts';
|
||||
import { SystemSettingKey, OssType, MapMode } from './asset/share/types.ts';
|
||||
import type { SystemSettingRecord, GlobalConfig } from '../client/share/types.ts';
|
||||
import { SystemSettingKey, OssType, MapMode } from '../client/share/types.ts';
|
||||
|
||||
import {
|
||||
createKnowInfoRoutes,
|
||||
@@ -388,7 +388,7 @@ export default function({ apiClient, app, moduleDir }: ModuleParams) {
|
||||
<title>{title}</title>
|
||||
|
||||
{isProd ? (
|
||||
<script type="module" src={scriptConfig.prodSrc || `/asset_dist/${scriptConfig.prodPath}`}></script>
|
||||
<script type="module" src={scriptConfig.prodSrc || `/client_dist/${scriptConfig.prodPath}`}></script>
|
||||
) : (
|
||||
<script src={scriptConfig.src} href={scriptConfig.href} deno-json={scriptConfig.denoJson} refresh={scriptConfig.refresh}></script>
|
||||
)}
|
||||
@@ -425,16 +425,16 @@ export default function({ apiClient, app, moduleDir }: ModuleParams) {
|
||||
// 后台管理路由
|
||||
honoApp.get('/admin', createHtmlWithConfig({
|
||||
src: "https://esm.d8d.fun/xb",
|
||||
href: "/asset/admin/web_app.tsx",
|
||||
denoJson: "/asset/admin/deno.json",
|
||||
href: "/client/admin/web_app.tsx",
|
||||
denoJson: "/client/admin/deno.json",
|
||||
refresh: true,
|
||||
prodPath: "admin/web_app.js"
|
||||
}, GLOBAL_CONFIG.APP_NAME))
|
||||
|
||||
honoApp.get('/admin/*', createHtmlWithConfig({
|
||||
src: "https://esm.d8d.fun/xb",
|
||||
href: "/asset/admin/web_app.tsx",
|
||||
denoJson: "/asset/admin/deno.json",
|
||||
href: "/client/admin/web_app.tsx",
|
||||
denoJson: "/client/admin/deno.json",
|
||||
refresh: true,
|
||||
prodPath: "admin/web_app.js"
|
||||
}, GLOBAL_CONFIG.APP_NAME))
|
||||
@@ -462,10 +462,10 @@ export default function({ apiClient, app, moduleDir }: ModuleParams) {
|
||||
})
|
||||
|
||||
// 静态资源路由
|
||||
honoApp.get('/asset/*', staticRoutes)
|
||||
honoApp.get('/client/*', staticRoutes)
|
||||
honoApp.get('/amap/*', staticRoutes)
|
||||
honoApp.get('/tailwindcss@3.4.16/*', staticRoutes)
|
||||
honoApp.get('/asset_dist/*', staticRoutes)
|
||||
honoApp.get('/client_dist/*', staticRoutes)
|
||||
|
||||
return honoApp
|
||||
}
|
||||
0
deno.lock → server/deno.lock
generated
0
deno.lock → server/deno.lock
generated
@@ -1,17 +1,12 @@
|
||||
import type { MigrationLiveDefinition } from '@d8d-appcontainer/types'
|
||||
|
||||
import {
|
||||
DeviceCategory, DeviceStatus, AlertLevel, AlertStatus, EnableStatus, DeleteStatus,
|
||||
HandleType, ProblemType, NotifyType, ServerType, AssetTransferType, AuditStatus,
|
||||
DeviceProtocolType, MetricType, ThemeMode, FontSize, CompactMode,
|
||||
|
||||
AssetStatus, NetworkStatus, PacketLossStatus,
|
||||
ZichanArea, ZichanCategory, DeviceType, DeviceInstance, RackInfo, RackServerType, RackServer, ZichanTransLog, DeviceAlertRule,
|
||||
ZichanInfo, AlertNotifyConfig, KnowInfo,
|
||||
EnableStatus, DeleteStatus,
|
||||
AuditStatus, ThemeMode, FontSize, CompactMode,
|
||||
SystemSettingKey,
|
||||
SystemSettingGroup,
|
||||
ALLOWED_FILE_TYPES,
|
||||
} from './asset/share/types.ts';
|
||||
} from '../client/share/types.ts';
|
||||
|
||||
// 定义用户表迁移
|
||||
const createUsersTable: MigrationLiveDefinition = {
|
||||
@@ -1,19 +1,8 @@
|
||||
import { Hono } from "hono";
|
||||
import debug from "debug";
|
||||
import type {
|
||||
FileLibrary,
|
||||
FileCategory,
|
||||
KnowInfo,
|
||||
ThemeSettings,
|
||||
} from "./asset/share/types.ts";
|
||||
|
||||
import {
|
||||
EnableStatus,
|
||||
DeleteStatus,
|
||||
ThemeMode,
|
||||
FontSize,
|
||||
CompactMode,
|
||||
} from "./asset/share/types.ts";
|
||||
} from "../client/share/types.ts";
|
||||
|
||||
import type { Variables, WithAuth } from "./app.tsx";
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import debug from "debug";
|
||||
import type {
|
||||
FileLibrary,
|
||||
FileCategory,
|
||||
KnowInfo,
|
||||
ThemeSettings,
|
||||
} from "./asset/share/types.ts";
|
||||
|
||||
import {
|
||||
EnableStatus,
|
||||
DeleteStatus,
|
||||
ThemeMode,
|
||||
FontSize,
|
||||
CompactMode,
|
||||
} from "./asset/share/types.ts";
|
||||
|
||||
import type { Variables, WithAuth } from "./app.tsx";
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ThemeSettings,
|
||||
SystemSetting,
|
||||
SystemSettingGroupData,
|
||||
} from "./asset/share/types.ts";
|
||||
} from "../client/share/types.ts";
|
||||
|
||||
import {
|
||||
EnableStatus,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ThemeMode,
|
||||
FontSize,
|
||||
CompactMode,
|
||||
} from "./asset/share/types.ts";
|
||||
} from "../client/share/types.ts";
|
||||
|
||||
import type { Variables, WithAuth } from "./app.tsx";
|
||||
|
||||
Reference in New Issue
Block a user