diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..257d836 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +npm-debug.log +Dockerfile* +docker-compose* +.dockerignore +dist +.git +.gitignore +README.md +.vscode +.idea +*.md diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index ca7fc7a..6af86ab 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -4,28 +4,53 @@ ## 项目状态 -> **当前状态:尚未初始化代码。** 技术栈待定,目录除本文件外为空。 -> 在选定技术栈后,请补全本文件中标记为「⚠️ 待补充」的章节。 +**技术栈已确定,项目已初始化。** + +| 维度 | 选型 | +|------|------| +| 框架 | React 18 + TypeScript 5 | +| 构建 | Vite 6 | +| UI 库 | Ant Design 5 + @ant-design/icons | +| 路由 | React Router 6 | +| 状态管理 | Zustand(客户端状态) | +| HTTP 客户端 | Axios(API 封装在 `src/api/`) | ## 定位 -`frontend/` 是 asset_helper 的 **Web 前端**,调用后端 API(默认通过 Nginx 网关 `https://api.example.com` 暴露)。 +`frontend/` 是 asset_helper 的 **Web 管理后台**,调用后端 API(通过 Nginx 网关暴露)。 - 与 `backend/` 通过 HTTP/JSON 交互,遵循根目录 [CLAUDE.md](../CLAUDE.md) 中定义的跨端契约 - 与 `app/`(移动/桌面端)共享后端,但 UI 实现独立 +- 当前为后台管理系统,后续可扩展为面向用户的 Web 端 -## 技术栈 +## Docker 开发与部署 -> ⚠️ 待补充:选定后请填写。建议候选: -> -> | 维度 | 候选 | -> |------|------| -> | 框架 | React 18 / Vue 3 / Next.js | -> | 语言 | TypeScript(推荐,与 Rust 后端类型对齐更顺) | -> | 构建 | Vite / Next.js | -> | 状态管理 | Zustand / Pinia / React Query | -> | UI 库 | Ant Design / Element Plus / shadcn-ui | -> | HTTP 客户端 | axios / ky / fetch 封装 | +### 开发环境(热更新,不污染物理机) + +```bash +cd frontend +docker-compose -f docker-compose.dev.yml up --build +``` + +- 访问:`http://localhost:3000` +- 源码通过 volume 挂载,修改后自动热更新 +- API 请求通过 Vite proxy 转发到后端网关(默认 `http://host.docker.internal:80`) + +**如需修改后端地址:** +```bash +VITE_API_BASE_URL=http://your-backend:80 docker-compose -f docker-compose.dev.yml up +``` + +### 生产构建与部署 + +```bash +cd frontend +docker-compose up --build +``` + +- 访问:`http://localhost:20080`(端口可通过 `ADMIN_WEB_PORT` 环境变量修改) +- 多阶段构建:Node 构建 → Nginx 提供静态文件 +- Nginx 代理 `/api/*` 到后端网关容器 ## 与后端的协作约定 @@ -35,25 +60,18 @@ ```ts interface ApiRequest { - device: number; // 见下方 device 编码 - language: number; // 见下方 language 编码 + device: number; // 前端固定使用 Device.Web = 3 + language: number; // 默认 Language.SimplifiedChinese = 1 data: T; // 业务字段 } ``` -**device 编码(必须与 backend 保持一致):** -- `1` = iOS -- `2` = Android -- `3` = Web ← **前端使用此值** -- `4` = iPad -- `5` = macOS -- `6` = Windows -- `7` = Linux +**device 编码:** +- `1` = iOS、`2` = Android、`3` = Web ← **前端使用此值** +- `4` = iPad、`5` = macOS、`6` = Windows、`7` = Linux **language 编码:** -- `1` = 简体中文 -- `2` = 繁体中文 -- `3` = 英文 +- `1` = 简体中文(默认)、`2` = 繁体中文、`3` = 英文 ### 2. 响应包装 @@ -75,7 +93,7 @@ interface LoginResponse { } ``` -JWT 存储建议:HttpOnly Cookie(CSRF 防护)或 localStorage(注意 XSS 风险),后续根据安全策略统一。 +**JWT 存储策略**:存于 Zustand 内存中(页面刷新丢失,需重新登录)。如需持久化,可改为 localStorage,但需注意 XSS 风险。 ### 4. 错误响应(HTTP 非 200) @@ -87,44 +105,88 @@ interface ErrorResponse { } ``` +## 目录结构 + +``` +frontend/ +├── docker/ +│ ├── Dockerfile # 生产多阶段构建 +│ ├── Dockerfile.dev # 开发环境(volume 挂载源码) +│ └── nginx.conf # 生产 Nginx SPA 配置 +├── src/ +│ ├── api/ +│ │ ├── client.ts # Axios 封装(device/language 注入、JWT、错误处理) +│ │ └── auth.ts # 认证相关 API +│ ├── components/ # 可复用 UI 组件(待扩展) +│ ├── hooks/ # 自定义 Hooks(待扩展) +│ ├── layouts/ +│ │ └── MainLayout.tsx # 后台主布局(侧边栏 + 头部 + 内容区) +│ ├── pages/ +│ │ ├── LoginPage.tsx # 登录页(账号/邮箱切换) +│ │ ├── DashboardPage.tsx # 仪表盘首页 +│ │ └── NotFoundPage.tsx # 404 +│ ├── router/ +│ │ └── index.tsx # 路由配置(登录守卫 + 受保护路由) +│ ├── stores/ +│ │ └── auth.ts # Zustand 认证状态(token + login/logout) +│ ├── types/ +│ │ ├── api.ts # 通用 API 类型(device/language 枚举、包装类型) +│ │ └── auth.ts # 认证相关类型 +│ ├── utils/ +│ │ └── storage.ts # localStorage 封装(带前缀隔离) +│ ├── App.tsx # 根组件(ConfigProvider + RouterProvider) +│ └── main.tsx # 入口 +├── docker-compose.dev.yml # 开发编排(热更新) +├── docker-compose.yml # 生产编排 +├── index.html +├── package.json +├── tsconfig.json / tsconfig.app.json / tsconfig.node.json +└── vite.config.ts +``` + +## API 调用规范 + +**必须使用封装函数,禁止直接 fetch/axios:** + +```ts +// ✅ 业务接口(自动注入 device/language) +import { apiPost, apiGet } from '@/api/client' +const data = await apiPost('/api/v1/users/xxx', payload) + +// ✅ 认证接口(扁平格式,不包装) +import { loginAccount } from '@/api/auth' +const result = await loginAccount({ account: 'xxx', password: 'xxx' }) +``` + ## 代码风格 - 注释使用**中文**(与后端保持一致) -- TypeScript 类型与后端 Rust 结构体一一对齐,避免 `any` -- API 客户端集中封装在一处(如 `src/api/`),不要在组件中直接 fetch - -## 目录结构 - -> ⚠️ 待补充:技术栈选定后填写。常见骨架: -> -> ``` -> frontend/ -> ├── src/ -> │ ├── api/ # API 客户端封装(device/language 注入、错误统一处理) -> │ ├── components/ # 可复用 UI 组件 -> │ ├── pages/ # 路由页面 -> │ ├── stores/ # 状态管理 -> │ ├── types/ # 与后端对齐的类型定义 -> │ └── utils/ -> ├── public/ -> ├── package.json -> └── vite.config.ts (或对应配置) -> ``` +- TypeScript 类型与后端 Rust 结构体一一对齐,禁止 `any` +- API 调用集中在 `src/api/`,不在组件中直接写 axios +- 路由守卫在 `src/router/index.tsx` 中统一配置 ## 常用命令 -> ⚠️ 待补充:技术栈选定后填写(如 `pnpm dev` / `pnpm build` / `pnpm test`)。 +| 命令 | 说明 | +|------|------| +| `npm install` | 安装依赖(开发容器内自动执行) | +| `npm run dev` | 启动开发服务器(容器内) | +| `npm run build` | 生产构建 | +| `npm run preview` | 预览生产构建 | ## 开发环境 -- 后端网关默认监听 `localhost:80`/`443`,本地需配置 hosts 或直连 `localhost` -- 后端开发证书为自签名,浏览器需信任或开发环境关闭 HTTPS +- 后端网关默认通过 Docker 网络或 `host.docker.internal` 访问 +- 开发容器内 Vite 监听 `0.0.0.0:5173`,映射到宿主机 `3000` +- 如需后端使用 HTTPS 自签名证书,Vite proxy 已配置 `secure: false` ## 扩展指南 新增页面/功能时: 1. 先在 `types/` 定义与后端对齐的类型 2. 在 `api/` 添加调用封装(务必通过统一封装注入 `device`/`language`) -3. 再开发组件/页面 +3. 在 `pages/` 创建页面组件 +4. 在 `router/index.tsx` 添加路由 +5. 如需加入侧边栏菜单,在 `layouts/MainLayout.tsx` 的 `menuItems` 中配置 -发现重复逻辑时优先抽到 `utils/` 或自定义 Hook(React)/ Composable(Vue)。 +发现重复逻辑时优先抽到 `utils/` 或自定义 Hook。 diff --git a/frontend/docker-compose.dev.yml b/frontend/docker-compose.dev.yml new file mode 100644 index 0000000..2483258 --- /dev/null +++ b/frontend/docker-compose.dev.yml @@ -0,0 +1,29 @@ +services: + admin-web-dev: + build: + context: . + dockerfile: docker/Dockerfile.dev + container_name: asset-helper-admin-dev + environment: + - NODE_ENV=development + - CHOKIDAR_USEPOLLING=true + - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://host.docker.internal:80} + ports: + - "3000:5173" + volumes: + # 源码挂载(实现热更新) + - ./src:/app/src:ro + - ./index.html:/app/index.html:ro + - ./vite.config.ts:/app/vite.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.app.json:/app/tsconfig.app.json:ro + - ./tsconfig.node.json:/app/tsconfig.node.json:ro + # 不覆盖 node_modules + - /app/node_modules + networks: + - asset-helper-network + restart: unless-stopped + +networks: + asset-helper-network: + driver: bridge diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml new file mode 100644 index 0000000..2f71399 --- /dev/null +++ b/frontend/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" + +services: + admin-web: + build: + context: . + dockerfile: docker/Dockerfile + container_name: asset-helper-admin + ports: + - "${ADMIN_WEB_PORT:-20080}:80" + networks: + - asset-helper-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + asset-helper-network: + driver: bridge diff --git a/frontend/docker/Dockerfile b/frontend/docker/Dockerfile new file mode 100644 index 0000000..9fbf6ec --- /dev/null +++ b/frontend/docker/Dockerfile @@ -0,0 +1,28 @@ +# 生产环境 Dockerfile — 多阶段构建 +# Stage 1: 构建 +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Stage 2: 运行 +FROM nginx:1.25-alpine + +# 复制自定义 Nginx 配置 +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1 + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docker/Dockerfile.dev b/frontend/docker/Dockerfile.dev new file mode 100644 index 0000000..d6e1ad4 --- /dev/null +++ b/frontend/docker/Dockerfile.dev @@ -0,0 +1,16 @@ +# 开发环境 Dockerfile +# 不复制源码,通过 docker-compose volume 挂载,实现热更新 + +FROM node:20-alpine + +WORKDIR /app + +# 安装依赖(利用 Docker 缓存层) +COPY package.json package-lock.json* ./ +RUN npm install + +# 暴露 Vite 开发服务器端口 +EXPOSE 5173 + +# 开发模式启动(--host 确保外部可访问) +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf new file mode 100644 index 0000000..afd36ca --- /dev/null +++ b/frontend/docker/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 前端路由支持(SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理到后端网关(生产环境) + location /api/ { + proxy_pass http://gateway:80/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # 安全响应头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9fe331e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Asset Helper 管理后台 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..76bb35b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "asset-helper-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "@ant-design/icons": "^5.6.1", + "antd": "^5.24.6", + "axios": "^1.8.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.7.3", + "vite": "^6.3.3" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..54891bc --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,20 @@ +import { RouterProvider } from 'react-router-dom' +import { ConfigProvider, theme } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import { router } from './router' +import { useThemeStore } from './stores/theme' + +export default function App() { + const isDark = useThemeStore((s) => s.isDark) + + return ( + + + + ) +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..cc87785 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,23 @@ +import { rawPost } from './client' +import type { LoginResponse } from '@/types/api' +import type { LoginAccountPayload, LoginEmailPayload, RegisterAccountPayload, RegisterEmailPayload } from '@/types/auth' + +/** 账号密码登录 */ +export function loginAccount(payload: LoginAccountPayload) { + return rawPost('/api/v1/auth/login/account', payload) +} + +/** 邮箱密码登录 */ +export function loginEmail(payload: LoginEmailPayload) { + return rawPost('/api/v1/auth/login/email', payload) +} + +/** 账号注册 */ +export function registerAccount(payload: RegisterAccountPayload) { + return rawPost('/api/v1/users/register/account', payload) +} + +/** 邮箱注册 */ +export function registerEmail(payload: RegisterEmailPayload) { + return rawPost('/api/v1/users/register/email', payload) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..0b296fb --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,95 @@ +import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios' +import { Device, Language, type ApiRequest, type ApiResponse, type ErrorResponse } from '@/types/api' +import { useAuthStore } from '@/stores/auth' + +// 根据环境选择基础地址 +// Docker 开发环境:Vite proxy 会将 /api 转发到后端 +// 生产环境:Nginx 代理 /api 到后端网关 +const baseURL = import.meta.env.DEV ? '/' : '/api' + +const client: AxiosInstance = axios.create({ + baseURL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 请求拦截器:注入 JWT +client.interceptors.request.use((config) => { + const token = useAuthStore.getState().token + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截器:统一错误处理 +client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const status = error.response?.status + const message = error.response?.data?.message || error.message || '请求失败' + + if (status === 401) { + useAuthStore.getState().logout() + window.location.href = '/login' + } + + return Promise.reject(new Error(message)) + } +) + +/** 包装业务请求(注册/业务类接口) */ +export async function apiPost( + url: string, + data: TReq, + config?: AxiosRequestConfig +): Promise { + const body: ApiRequest = { + device: Device.Web, + language: Language.SimplifiedChinese, + data, + } + + const response = await client.post>(url, body, config) + const result = response.data + + if (!result.success) { + throw new Error(result.message) + } + + if (result.data === null) { + throw new Error('接口返回数据为空') + } + + return result.data +} + +/** 原始 POST(登录/认证类接口,不包装) */ +export async function rawPost( + url: string, + data: TReq, + config?: AxiosRequestConfig +): Promise { + const response = await client.post(url, data, config) + return response.data +} + +/** 通用 GET */ +export async function apiGet(url: string, config?: AxiosRequestConfig): Promise { + const response = await client.get>(url, config) + const result = response.data + + if (!result.success) { + throw new Error(result.message) + } + + if (result.data === null) { + throw new Error('接口返回数据为空') + } + + return result.data +} + +export default client diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..7b83b92 --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { Outlet, useNavigate } from 'react-router-dom' +import { Layout, Menu, Button, theme, Dropdown, Avatar, Space } from 'antd' +import { + DashboardOutlined, + LogoutOutlined, + UserOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, +} from '@ant-design/icons' +import { useAuthStore } from '@/stores/auth' + +const { Header, Sider, Content } = Layout + +export default function MainLayout() { + const [collapsed, setCollapsed] = useState(false) + const logout = useAuthStore((s) => s.logout) + const navigate = useNavigate() + + const { + token: { colorBgContainer, borderRadiusLG }, + } = theme.useToken() + + const menuItems = [ + { + key: '/', + icon: , + label: '仪表盘', + }, + ] + + const userMenuItems = [ + { + key: 'logout', + icon: , + label: '退出登录', + danger: true, + onClick: () => { + logout() + navigate('/login') + }, + }, + ] + + return ( + + +
+ {collapsed ? 'AH' : 'Asset Helper'} +
+ navigate(key)} + /> + + +
+
+ + + +
+ + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..eeeb409 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './styles/global.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..2ba39aa --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,35 @@ +import { Card, Statistic, Row, Col } from 'antd' +import { UserOutlined, AppstoreOutlined } from '@ant-design/icons' + +export default function DashboardPage() { + return ( +
+

仪表盘

+ + + + } + /> + + + + + } + /> + + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..90e4a59 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, Tabs, message } from 'antd' +import { UserOutlined, LockOutlined, MailOutlined, MoonOutlined, SunOutlined } from '@ant-design/icons' +import { loginAccount, loginEmail } from '@/api/auth' +import { useAuthStore } from '@/stores/auth' +import { useThemeStore } from '@/stores/theme' +import type { LoginAccountPayload, LoginEmailPayload } from '@/types/auth' + +type LoginType = 'account' | 'email' + +export default function LoginPage() { + const [loginType, setLoginType] = useState('account') + const [loading, setLoading] = useState(false) + const login = useAuthStore((s) => s.login) + const isDark = useThemeStore((s) => s.isDark) + const toggleTheme = useThemeStore((s) => s.toggle) + const navigate = useNavigate() + + async function handleSubmit(values: LoginAccountPayload | LoginEmailPayload) { + setLoading(true) + try { + const response = + loginType === 'account' + ? await loginAccount(values as LoginAccountPayload) + : await loginEmail(values as LoginEmailPayload) + + if (response.success && response.token) { + login(response.token) + message.success('登录成功') + navigate('/') + } else { + message.error(response.message || '登录失败') + } + } catch (err) { + message.error(err instanceof Error ? err.message : '登录失败') + } finally { + setLoading(false) + } + } + + return ( +
+ : } + onClick={toggleTheme} + /> + } + style={{ width: 400 }} + styles={{ header: { textAlign: 'center', fontSize: 18 } }} + > + setLoginType(key as LoginType)} + items={[ + { key: 'account', label: '账号登录' }, + { key: 'email', label: '邮箱登录' }, + ]} + /> +
+ {loginType === 'account' ? ( + + } placeholder="账号" /> + + ) : ( + + } placeholder="邮箱" /> + + )} + + } placeholder="密码" /> + + + + +
+
+
+ ) +} diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..f2aa359 --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,19 @@ +import { Button, Result } from 'antd' +import { useNavigate } from 'react-router-dom' + +export default function NotFoundPage() { + const navigate = useNavigate() + + return ( + navigate('/')}> + 返回首页 + + } + /> + ) +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx new file mode 100644 index 0000000..516bdbf --- /dev/null +++ b/frontend/src/router/index.tsx @@ -0,0 +1,38 @@ +import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom' +import { useAuthStore } from '@/stores/auth' +import LoginPage from '@/pages/LoginPage' +import DashboardPage from '@/pages/DashboardPage' +import NotFoundPage from '@/pages/NotFoundPage' +import MainLayout from '@/layouts/MainLayout' + +/** 路由守卫:已登录则放行,未登录跳转登录页 */ +function RequireAuth() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + return isAuthenticated ? : +} + +/** 登录页守卫:已登录则跳转首页 */ +function LoginGuard() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + return isAuthenticated ? : +} + +export const router = createBrowserRouter([ + { + path: '/login', + element: , + }, + { + element: , + children: [ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: '*', element: }, + ], + }, + ], + }, +]) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..7156a75 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface AuthState { + token: string | null + isAuthenticated: boolean + login: (token: string) => void + logout: () => void +} + +export const useAuthStore = create()( + devtools( + (set) => ({ + token: null, + isAuthenticated: false, + + login: (token: string) => + set({ token, isAuthenticated: true }, false, 'login'), + + logout: () => + set({ token: null, isAuthenticated: false }, false, 'logout'), + }), + { name: 'AuthStore' } + ) +) diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..8934d88 --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface ThemeState { + isDark: boolean + toggle: () => void +} + +export const useThemeStore = create()( + devtools( + persist( + (set) => ({ + isDark: false, + toggle: () => set((state) => ({ isDark: !state.isDark }), false, 'toggleTheme'), + }), + { name: 'theme-storage' } + ), + { name: 'ThemeStore' } + ) +) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..194b1ac --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,5 @@ +body { + margin: 0; + padding: 0; + transition: background-color 0.3s ease; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..b545306 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,47 @@ +// 与后端对齐的通用 API 类型 + +/** device 编码 */ +export enum Device { + IOS = 1, + Android = 2, + Web = 3, + IPad = 4, + MacOS = 5, + Windows = 6, + Linux = 7, +} + +/** language 编码 */ +export enum Language { + SimplifiedChinese = 1, + TraditionalChinese = 2, + English = 3, +} + +/** 注册/业务类接口请求包装 */ +export interface ApiRequest { + device: number + language: number + data: T +} + +/** 注册/业务类接口响应包装 */ +export interface ApiResponse { + success: boolean + message: string + data: T | null +} + +/** 登录/认证类接口响应 */ +export interface LoginResponse { + success: boolean + token: string | null + message: string +} + +/** 网关错误响应(HTTP 非 200) */ +export interface ErrorResponse { + error: string + message: string + code: number +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..8773594 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,27 @@ +// 认证相关类型 + +export interface LoginAccountPayload { + account: string + password: string +} + +export interface LoginEmailPayload { + email: string + password: string +} + +export interface RegisterAccountPayload { + account: string + password: string +} + +export interface RegisterEmailPayload { + email: string + password: string +} + +export interface UserProfile { + id: string + account?: string + email?: string +} diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000..7c5623d --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -0,0 +1,29 @@ +// 轻量存储封装(JWT 存内存,其他持久化数据用 localStorage) + +const PREFIX = 'asset_helper_admin:' + +export const storage = { + get(key: string): T | null { + try { + const raw = localStorage.getItem(PREFIX + key) + return raw ? (JSON.parse(raw) as T) : null + } catch { + return null + } + }, + + set(key: string, value: T): void { + localStorage.setItem(PREFIX + key, JSON.stringify(value)) + }, + + remove(key: string): void { + localStorage.removeItem(PREFIX + key) + }, + + clear(): void { + // 仅清除本应用前缀的 key + Object.keys(localStorage) + .filter((k) => k.startsWith(PREFIX)) + .forEach((k) => localStorage.removeItem(k)) + }, +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..fe426d4 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..872ffbd --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d02126f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig(({ mode }) => ({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + watch: { + usePolling: true, + }, + proxy: { + '/api': { + target: process.env.VITE_API_BASE_URL || 'http://host.docker.internal:80', + changeOrigin: true, + secure: false, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: mode !== 'production', + }, +}))