添加管理端和移动端的多个新功能模块,包括文件上传、在线地图、用户认证、系统设置等,优化代码结构,提升可维护性和用户体验。

This commit is contained in:
zyh
2025-04-10 02:25:25 +00:00
parent 01dea6efa1
commit b1a6b608c6
31 changed files with 1366 additions and 46 deletions

18
client/mobile/deno.json Normal file
View 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
View 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
};
};

View 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();

View 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;

View 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">
&copy; {new Date().getFullYear()} {window.CONFIG?.APP_NAME || '移动应用'}
<p className="mt-1"></p>
</div>
</div>
);
};
export default LoginPage;

169
client/mobile/utils.ts Normal file
View 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));
};

View File

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

View File

@@ -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
}

View File

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";