feat(frontend): 认证体系 + 类型定义 + API 服务层
认证: - AuthContext + useAuth (JWT token 持久化, 登录/登出) - AdminLoginPage (登录表单, 对接 POST /auth/login) - axios 拦截器自动注入 Authorization header - 401 自动清除 token 并跳转登录页 类型: - api.ts: TASK_STATUS 常量, TaskResponse/ProductScanResponse 扩展字段 - admin.ts: ProductResponse 扩展 + MaterialOption/BomItem API 服务: - authApi: login() + getMe() - materialApi: fetchMaterialGroups() + fetchMaterialItems() - printApi: getLabelPreview() + executePrint() + getPrinterConfig() - antd 6.5.3 依赖 字体: simhei.ttf (标签打印用)
This commit is contained in:
122
frontend/src/contexts/AuthContext.tsx
Normal file
122
frontend/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { login as loginApi, getMe } from "../services/authApi";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
// ============================================================
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
loading: boolean; // 初始化时检查 token
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Token 存储 key
|
||||
// ============================================================
|
||||
|
||||
const TOKEN_KEY = "track_admin_token";
|
||||
const USER_KEY = "track_admin_user";
|
||||
|
||||
// ============================================================
|
||||
// Context
|
||||
// ============================================================
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider
|
||||
// ============================================================
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// 初始化:从 localStorage 恢复 token
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
const user = JSON.parse(savedUser) as UserInfo;
|
||||
setState({ user, token: savedToken, loading: false });
|
||||
// 可选:后端验证 token 是否仍有效
|
||||
getMe(savedToken)
|
||||
.then((fresh) => {
|
||||
setState((prev) => ({ ...prev, user: fresh }));
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
|
||||
})
|
||||
.catch(() => {
|
||||
// token 过期,清除
|
||||
logoutInternal();
|
||||
});
|
||||
} catch {
|
||||
logoutInternal();
|
||||
}
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
function logoutInternal() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setState({ user: null, token: null, loading: false });
|
||||
}
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const result = await loginApi(username, password);
|
||||
const token = result.access_token;
|
||||
const user: UserInfo = result.user;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState({ user, token, loading: false });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutInternal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!state.token && !!state.user,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
99
frontend/src/pages/admin/AdminLoginPage.tsx
Normal file
99
frontend/src/pages/admin/AdminLoginPage.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Navigate } from "react-router-dom";
|
||||
import { QrCode, Loader2 } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 已登录 → 直接跳转
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
navigate("/admin/dashboard", { replace: true });
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "登录失败,请检查用户名和密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-600">
|
||||
<QrCode className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-lg font-bold text-gray-800">
|
||||
生产流转 · 管理端
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">请使用工号登录</p>
|
||||
</div>
|
||||
|
||||
{/* 错误 */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-600">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="如 IRIS 或工号"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-600">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入密码"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password.trim()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,11 +8,13 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 — 可在此注入 token
|
||||
// 请求拦截器 — 注入 JWT token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// const token = localStorage.getItem("access_token");
|
||||
// if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
const token = localStorage.getItem("track_admin_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
@ -23,7 +25,12 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 未授权:跳转登录 / 刷新 token
|
||||
// Token 过期或无效 → 清除并跳转登录
|
||||
localStorage.removeItem("track_admin_token");
|
||||
localStorage.removeItem("track_admin_user");
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
window.location.href = "/admin/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
|
||||
34
frontend/src/services/authApi.ts
Normal file
34
frontend/src/services/authApi.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import axios from "axios";
|
||||
import type { UserInfo } from "../contexts/AuthContext";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
/** 登录 — 注意:此请求不走 axios 实例(避免循环依赖),直接用 fetch */
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as any).detail ?? "登录失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 验证 token 有效性 + 获取最新用户信息 */
|
||||
export async function getMe(token: string): Promise<UserInfo> {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Token 无效");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
43
frontend/src/services/materialApi.ts
Normal file
43
frontend/src/services/materialApi.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import api from "./api";
|
||||
|
||||
// ============================================================
|
||||
// 类型 — 对标 MOM material_base
|
||||
// ============================================================
|
||||
|
||||
export interface MaterialGroup {
|
||||
category: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MaterialItem {
|
||||
id: number;
|
||||
name: string; // material_base.name
|
||||
spec: string; // material_base.spec_model
|
||||
category: string; // material_base.category
|
||||
type: string; // material_base.material_type
|
||||
unit: string; // material_base.unit
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API
|
||||
// ============================================================
|
||||
|
||||
/** 获取物料分组摘要 — 手风琴外层 */
|
||||
export async function fetchMaterialGroups(keyword?: string): Promise<MaterialGroup[]> {
|
||||
const { data } = await api.get<MaterialGroup[]>("/materials/groups", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取指定分类下的物料条目 — 手风琴展开时懒加载 */
|
||||
export async function fetchMaterialItems(
|
||||
category: string,
|
||||
keyword?: string
|
||||
): Promise<MaterialItem[]> {
|
||||
const { data } = await api.get<MaterialItem[]>("/materials/items", {
|
||||
params: { category, keyword: keyword || "", limit: 9999 },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
72
frontend/src/services/printApi.ts
Normal file
72
frontend/src/services/printApi.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import api from "./api";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
// ============================================================
|
||||
|
||||
export interface LabelPreviewRequest {
|
||||
serial_number: string;
|
||||
material_name?: string;
|
||||
spec_model?: string;
|
||||
order_no?: string;
|
||||
}
|
||||
|
||||
export interface PrintExecuteRequest extends LabelPreviewRequest {
|
||||
copies: number;
|
||||
printer_ip?: string;
|
||||
printer_port?: number;
|
||||
}
|
||||
|
||||
export interface PrinterConfig {
|
||||
label_printer: {
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API
|
||||
// ============================================================
|
||||
|
||||
/** 生成标签预览图 → Base64 data URL */
|
||||
export async function getLabelPreview(
|
||||
data: LabelPreviewRequest
|
||||
): Promise<string> {
|
||||
const { data: res } = await api.post<{ data_url: string }>(
|
||||
"/print/preview",
|
||||
data
|
||||
);
|
||||
return res.data_url;
|
||||
}
|
||||
|
||||
/** 发送打印指令到打标机 */
|
||||
export async function executePrint(
|
||||
data: PrintExecuteRequest
|
||||
): Promise<{ message: string }> {
|
||||
const { data: res } = await api.post<{ message: string }>(
|
||||
"/print/execute",
|
||||
data
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 获取打印机配置 */
|
||||
export async function getPrinterConfig(): Promise<PrinterConfig> {
|
||||
const { data } = await api.get<PrinterConfig>("/print/config");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 更新打印机配置 */
|
||||
export async function updatePrinterConfig(
|
||||
ip: string,
|
||||
port: number,
|
||||
enabled: boolean = true
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await api.post<{ message: string }>("/print/config", {
|
||||
ip,
|
||||
port,
|
||||
enabled,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@ -3,11 +3,26 @@
|
||||
export interface ProductResponse {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
order_id: string;
|
||||
order_no?: string;
|
||||
external_serial: string | null;
|
||||
order_id: string | null;
|
||||
order_no: string;
|
||||
material_id: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
category: string | null;
|
||||
material_type: string | null;
|
||||
parent_product_id: string | null;
|
||||
current_location_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** MOM 物料选项 */
|
||||
export interface MaterialOption {
|
||||
id: number;
|
||||
name: string;
|
||||
spec: string;
|
||||
category: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user