前后端第一版提交

This commit is contained in:
2025-11-11 17:21:59 +08:00
commit 96e9a6d396
241 changed files with 197906 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import { generateCaptcha, refreshCaptcha, type CaptchaResponse, type CaptchaRefreshResponse } from '@/api/captcha'
import { ref, type Ref } from 'vue'
/**
* 验证码组合式函数
*/
export function useCaptcha() {
// 验证码数据
const captchaData: Ref<CaptchaResponse | null> = ref(null)
// 加载状态
const captchaLoading: Ref<boolean> = ref(false)
// 错误信息
const captchaError: Ref<string | null> = ref(null)
/**
* 生成验证码
*/
const handleGenerateCaptcha = async (): Promise<void> => {
try {
captchaLoading.value = true
captchaError.value = null
const response = await generateCaptcha()
captchaData.value = response
} catch (error) {
console.error('生成验证码失败:', error)
captchaError.value = error instanceof Error ? error.message : '生成验证码失败'
} finally {
captchaLoading.value = false
}
}
/**
* 刷新验证码
*/
const handleRefreshCaptcha = async (): Promise<void> => {
try {
captchaLoading.value = true
captchaError.value = null
// 检查是否有当前验证码ID
if (!captchaData.value?.captcha_id) {
console.warn('没有当前验证码ID将生成新的验证码')
await handleGenerateCaptcha()
return
}
const response = await refreshCaptcha(captchaData.value.captcha_id)
captchaData.value = response
} catch (error) {
console.error('刷新验证码失败:', error)
captchaError.value = error instanceof Error ? error.message : '刷新验证码失败'
} finally {
captchaLoading.value = false
}
}
return {
captchaData,
captchaLoading,
captchaError,
generateCaptcha: handleGenerateCaptcha,
refreshCaptcha: handleRefreshCaptcha
}
}
// 导出类型
export type { CaptchaResponse, CaptchaRefreshResponse }

View File

@@ -0,0 +1,87 @@
/**
* 环境变量检查工具
* 用于在开发环境中检查环境变量配置是否正确
*/
// 检查环境变量配置
export const checkEnvironmentVariables = () => {
console.log('🔧 环境变量检查')
// 在Vite中环境变量可能通过define选项直接定义
// 或者通过import.meta.env读取
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://hertzServer:8000/api'
const appTitle = import.meta.env.VITE_APP_TITLE || 'Hertz Admin'
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
// 检查必需的环境变量
const requiredVars = [
{ key: 'VITE_API_BASE_URL', value: apiBaseUrl },
{ key: 'VITE_APP_TITLE', value: appTitle },
{ key: 'VITE_APP_VERSION', value: appVersion },
]
requiredVars.forEach(({ key, value }) => {
if (value) {
console.log(`${key}: ${value}`)
} else {
console.warn(`${key}: 未设置`)
}
})
// 检查可选的环境变量
const devServerHost = import.meta.env.VITE_DEV_SERVER_HOST || 'localhost'
const devServerPort = import.meta.env.VITE_DEV_SERVER_PORT || '3000'
const optionalVars = [
{ key: 'VITE_DEV_SERVER_HOST', value: devServerHost },
{ key: 'VITE_DEV_SERVER_PORT', value: devServerPort },
]
optionalVars.forEach(({ key, value }) => {
if (value) {
console.log(` ${key}: ${value}`)
} else {
console.log(` ${key}: 未设置(使用默认值)`)
}
})
console.log('🎉 环境变量检查完成')
}
// 验证环境变量是否有效
export const validateEnvironment = () => {
// 检查API基础地址
if (!import.meta.env.VITE_API_BASE_URL) {
console.warn('⚠️ VITE_API_BASE_URL 未设置,将使用默认值')
}
// 检查应用配置
if (!import.meta.env.VITE_APP_TITLE) {
console.warn('⚠️ VITE_APP_TITLE 未设置,将使用默认值')
}
if (!import.meta.env.VITE_APP_VERSION) {
console.warn('⚠️ VITE_APP_VERSION 未设置,将使用默认值')
}
return {
isValid: true,
warnings: []
}
}
// 获取API基础地址
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
}
// 获取应用配置
export const getAppConfig = () => {
return {
title: import.meta.env.VITE_APP_TITLE || 'Hertz Admin',
version: import.meta.env.VITE_APP_VERSION || '1.0.0',
apiBaseUrl: getApiBaseUrl(),
devServerHost: import.meta.env.VITE_DEV_SERVER_HOST || 'localhost',
devServerPort: import.meta.env.VITE_DEV_SERVER_PORT || '3000',
}
}

View File

@@ -0,0 +1,375 @@
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
// 错误类型枚举
export enum ErrorType {
// 网络错误
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// 认证错误
UNAUTHORIZED = 'UNAUTHORIZED',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
TOKEN_INVALID = 'TOKEN_INVALID',
// 权限错误
FORBIDDEN = 'FORBIDDEN',
ACCESS_DENIED = 'ACCESS_DENIED',
// 业务错误
VALIDATION_ERROR = 'VALIDATION_ERROR',
BUSINESS_ERROR = 'BUSINESS_ERROR',
// 系统错误
SERVER_ERROR = 'SERVER_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
}
// 错误信息接口
export interface ErrorInfo {
code: number
message: string
type: ErrorType
details?: any
field?: string
}
// 错误处理器类
export class HertzErrorHandler {
private static instance: HertzErrorHandler
private i18n: any
constructor() {
// 在组件中使用时需要传入i18n实例
}
static getInstance(): HertzErrorHandler {
if (!HertzErrorHandler.instance) {
HertzErrorHandler.instance = new HertzErrorHandler()
}
return HertzErrorHandler.instance
}
// 设置i18n实例
setI18n(i18n: any) {
this.i18n = i18n
}
// 获取翻译文本
private t(key: string, fallback?: string): string {
if (this.i18n && this.i18n.t) {
return this.i18n.t(key)
}
return fallback || key
}
// 处理HTTP错误
handleHttpError(error: any): void {
const status = error?.response?.status
const data = error?.response?.data
console.error('🚨 HTTP错误详情:', {
status,
data,
url: error?.config?.url,
method: error?.config?.method,
requestData: error?.config?.data
})
switch (status) {
case 400:
this.handleBadRequestError(data)
break
case 401:
this.handleUnauthorizedError(data)
break
case 403:
this.handleForbiddenError(data)
break
case 404:
this.handleNotFoundError(data)
break
case 422:
this.handleValidationError(data)
break
case 429:
this.handleTooManyRequestsError(data)
break
case 500:
this.handleServerError(data)
break
case 502:
case 503:
case 504:
this.handleServiceUnavailableError(data)
break
default:
this.handleUnknownError(error)
}
}
// 处理400错误
private handleBadRequestError(data: any): void {
const message = data?.message || data?.detail || ''
// 检查是否是验证码相关错误
if (this.isMessageContains(message, ['验证码', 'captcha', 'Captcha'])) {
if (this.isMessageContains(message, ['过期', 'expired', 'expire'])) {
this.showError(this.t('error.captchaExpired', '验证码已过期,请刷新后重新输入'))
} else {
this.showError(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
}
return
}
// 检查是否是用户名或密码错误
if (this.isMessageContains(message, ['用户名', 'username', '密码', 'password', '登录', 'login'])) {
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
return
}
// 检查是否是注册相关错误
if (this.isMessageContains(message, ['用户名已存在', 'username exists', 'username already'])) {
this.showError(this.t('error.usernameExists', '用户名已存在,请选择其他用户名'))
return
}
if (this.isMessageContains(message, ['邮箱已注册', 'email exists', 'email already'])) {
this.showError(this.t('error.emailExists', '邮箱已被注册,请使用其他邮箱'))
return
}
if (this.isMessageContains(message, ['手机号已注册', 'phone exists', 'phone already'])) {
this.showError(this.t('error.phoneExists', '手机号已被注册,请使用其他手机号'))
return
}
// 默认400错误处理
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
}
// 处理401错误
private handleUnauthorizedError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['token', 'Token', '令牌', '过期', 'expired'])) {
this.showError(this.t('error.tokenExpired', '登录已过期,请重新登录'))
// 可以在这里添加自动跳转到登录页的逻辑
setTimeout(() => {
window.location.href = '/login'
}, 2000)
} else if (this.isMessageContains(message, ['账户锁定', 'account locked', 'locked'])) {
this.showError(this.t('error.accountLocked', '账户已被锁定,请联系管理员'))
} else if (this.isMessageContains(message, ['账户禁用', 'account disabled', 'disabled'])) {
this.showError(this.t('error.accountDisabled', '账户已被禁用,请联系管理员'))
} else {
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
}
}
// 处理403错误
private handleForbiddenError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['权限不足', 'permission denied', 'access denied'])) {
this.showError(this.t('error.permissionDenied', '权限不足,无法执行此操作'))
} else {
this.showError(this.t('error.accessDenied', '访问被拒绝,您没有执行此操作的权限'))
}
}
// 处理404错误
private handleNotFoundError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['用户', 'user'])) {
this.showError(this.t('error.userNotFound', '用户不存在或已被删除'))
} else if (this.isMessageContains(message, ['部门', 'department'])) {
this.showError(this.t('error.departmentNotFound', '部门不存在或已被删除'))
} else if (this.isMessageContains(message, ['角色', 'role'])) {
this.showError(this.t('error.roleNotFound', '角色不存在或已被删除'))
} else {
this.showError(this.t('error.404', '页面未找到'))
}
}
// 处理422验证错误
private handleValidationError(data: any): void {
console.log('🔍 422验证错误详情:', data)
// 处理FastAPI风格的验证错误
if (data?.detail && Array.isArray(data.detail)) {
const errors = data.detail
const errorMessages: string[] = []
errors.forEach((error: any) => {
const field = error.loc?.[error.loc.length - 1] || 'unknown'
const msg = error.msg || error.message || '验证失败'
// 根据字段和错误类型提供更具体的提示
if (field === 'username') {
if (msg.includes('required') || msg.includes('必填')) {
errorMessages.push(this.t('error.usernameRequired', '请输入用户名'))
} else if (msg.includes('length') || msg.includes('长度')) {
errorMessages.push('用户名长度不符合要求')
} else {
errorMessages.push(`用户名: ${msg}`)
}
} else if (field === 'password') {
if (msg.includes('required') || msg.includes('必填')) {
errorMessages.push(this.t('error.passwordRequired', '请输入密码'))
} else if (msg.includes('weak') || msg.includes('强度')) {
errorMessages.push(this.t('error.passwordTooWeak', '密码强度不足,请包含大小写字母、数字和特殊字符'))
} else {
errorMessages.push(`密码: ${msg}`)
}
} else if (field === 'email') {
if (msg.includes('format') || msg.includes('格式')) {
errorMessages.push(this.t('error.emailFormatError', '邮箱格式不正确,请输入有效的邮箱地址'))
} else {
errorMessages.push(`邮箱: ${msg}`)
}
} else if (field === 'phone') {
if (msg.includes('format') || msg.includes('格式')) {
errorMessages.push(this.t('error.phoneFormatError', '手机号格式不正确请输入11位手机号'))
} else {
errorMessages.push(`手机号: ${msg}`)
}
} else if (field === 'captcha' || field === 'captcha_code') {
errorMessages.push(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
} else {
errorMessages.push(`${field}: ${msg}`)
}
})
if (errorMessages.length > 0) {
this.showError(errorMessages.join(''))
return
}
}
// 处理其他格式的验证错误
if (data?.errors) {
const errors = data.errors
const errorMessages = []
for (const field in errors) {
if (errors[field] && Array.isArray(errors[field])) {
errorMessages.push(`${field}: ${errors[field].join(', ')}`)
} else if (errors[field]) {
errorMessages.push(`${field}: ${errors[field]}`)
}
}
if (errorMessages.length > 0) {
this.showError(`验证失败: ${errorMessages.join('; ')}`)
return
}
}
// 默认验证错误处理
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
}
// 处理429错误请求过多
private handleTooManyRequestsError(data: any): void {
this.showError(this.t('error.loginAttemptsExceeded', '登录尝试次数过多,账户已被临时锁定'))
}
// 处理500错误
private handleServerError(data: any): void {
this.showError(this.t('error.500', '服务器内部错误,请稍后重试'))
}
// 处理服务不可用错误
private handleServiceUnavailableError(data: any): void {
this.showError(this.t('error.serviceUnavailable', '服务暂时不可用,请稍后重试'))
}
// 处理网络错误
handleNetworkError(error: any): void {
if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
} else if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
this.showError(this.t('error.timeout', '请求超时,请稍后重试'))
} else {
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
}
}
// 处理未知错误
private handleUnknownError(error: any): void {
console.error('🚨 未知错误:', error)
this.showError(this.t('error.operationFailed', '操作失败,请稍后重试'))
}
// 显示错误消息
private showError(msg: string): void {
message.error(msg)
}
// 显示成功消息
showSuccess(msg: string): void {
message.success(msg)
}
// 显示警告消息
showWarning(msg: string): void {
message.warning(msg)
}
// 检查消息是否包含指定关键词
private isMessageContains(message: string, keywords: string[]): boolean {
if (!message) return false
const lowerMessage = message.toLowerCase()
return keywords.some(keyword => lowerMessage.includes(keyword.toLowerCase()))
}
// 处理业务操作成功
handleSuccess(operation: string, customMessage?: string): void {
if (customMessage) {
this.showSuccess(customMessage)
return
}
switch (operation) {
case 'save':
this.showSuccess(this.t('error.saveSuccess', '保存成功'))
break
case 'delete':
this.showSuccess(this.t('error.deleteSuccess', '删除成功'))
break
case 'update':
this.showSuccess(this.t('error.updateSuccess', '更新成功'))
break
case 'create':
this.showSuccess('创建成功')
break
case 'login':
this.showSuccess('登录成功')
break
case 'register':
this.showSuccess('注册成功')
break
default:
this.showSuccess('操作成功')
}
}
}
// 导出单例实例
export const errorHandler = HertzErrorHandler.getInstance()
// 导出便捷方法
export const handleError = (error: any) => {
if (error?.response) {
errorHandler.handleHttpError(error)
} else if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
errorHandler.handleNetworkError(error)
} else {
console.error('🚨 处理错误:', error)
errorHandler.showError('操作失败,请稍后重试')
}
}
export const handleSuccess = (operation: string, customMessage?: string) => {
errorHandler.handleSuccess(operation, customMessage)
}

View File

@@ -0,0 +1,154 @@
/**
* 权限管理工具类
* 统一管理用户权限检查和菜单过滤逻辑
*/
import { computed } from 'vue'
import { useUserStore } from '@/stores/hertz_user'
import { UserRole } from '@/router/admin_menu'
// 权限检查接口
export interface PermissionChecker {
hasRole(role: string): boolean
hasPermission(permission: string): boolean
hasAnyRole(roles: string[]): boolean
hasAnyPermission(permissions: string[]): boolean
isAdmin(): boolean
isLoggedIn(): boolean
}
// 权限管理类
export class PermissionManager implements PermissionChecker {
// 延迟获取 Pinia store避免在 Pinia 未初始化时调用
private get userStore() {
return useUserStore()
}
/**
* 检查用户是否拥有指定角色
*/
hasRole(role: string): boolean {
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
return userRoles.includes(role)
}
/**
* 检查用户是否拥有指定权限
*/
hasPermission(permission: string): boolean {
const userPermissions = this.userStore.userInfo?.permissions || []
return userPermissions.includes(permission)
}
/**
* 检查用户是否拥有任意一个指定角色
*/
hasAnyRole(roles: string[]): boolean {
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
return roles.some(role => userRoles.includes(role))
}
/**
* 检查用户是否拥有任意一个指定权限
*/
hasAnyPermission(permissions: string[]): boolean {
const userPermissions = this.userStore.userInfo?.permissions || []
return permissions.some(permission => userPermissions.includes(permission))
}
/**
* 检查用户是否为管理员
*/
isAdmin(): boolean {
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN]
return this.hasAnyRole(adminRoles)
}
/**
* 检查用户是否已登录
*/
isLoggedIn(): boolean {
return this.userStore.isLoggedIn && !!this.userStore.userInfo
}
/**
* 获取用户角色列表
*/
getUserRoles(): string[] {
return this.userStore.userInfo?.roles?.map(r => r.role_code) || []
}
/**
* 获取用户权限列表
*/
getUserPermissions(): string[] {
return this.userStore.userInfo?.permissions || []
}
/**
* 检查用户是否可以访问指定路径
*/
canAccessPath(path: string, requiredRoles?: string[], requiredPermissions?: string[]): boolean {
if (!this.isLoggedIn()) {
return false
}
// 如果没有指定权限要求,默认允许访问
if (!requiredRoles && !requiredPermissions) {
return true
}
// 检查角色权限
if (requiredRoles && requiredRoles.length > 0) {
if (!this.hasAnyRole(requiredRoles)) {
return false
}
}
// 检查具体权限
if (requiredPermissions && requiredPermissions.length > 0) {
if (!this.hasAnyPermission(requiredPermissions)) {
return false
}
}
return true
}
}
// 创建全局权限管理实例
export const permissionManager = new PermissionManager()
// 便捷的权限检查函数
export const usePermission = () => {
return {
hasRole: (role: string) => permissionManager.hasRole(role),
hasPermission: (permission: string) => permissionManager.hasPermission(permission),
hasAnyRole: (roles: string[]) => permissionManager.hasAnyRole(roles),
hasAnyPermission: (permissions: string[]) => permissionManager.hasAnyPermission(permissions),
isAdmin: () => permissionManager.isAdmin(),
isLoggedIn: () => permissionManager.isLoggedIn(),
canAccessPath: (path: string, requiredRoles?: string[], requiredPermissions?: string[]) =>
permissionManager.canAccessPath(path, requiredRoles, requiredPermissions)
}
}
// Vue 3 组合式 API 权限检查 Hook
export const usePermissionCheck = () => {
const userStore = useUserStore()
return {
// 响应式权限检查
hasRole: (role: string) => computed(() => permissionManager.hasRole(role)),
hasPermission: (permission: string) => computed(() => permissionManager.hasPermission(permission)),
hasAnyRole: (roles: string[]) => computed(() => permissionManager.hasAnyRole(roles)),
hasAnyPermission: (permissions: string[]) => computed(() => permissionManager.hasAnyPermission(permissions)),
isAdmin: computed(() => permissionManager.isAdmin()),
isLoggedIn: computed(() => permissionManager.isLoggedIn()),
// 用户信息
userRoles: computed(() => permissionManager.getUserRoles()),
userPermissions: computed(() => permissionManager.getUserPermissions()),
userInfo: computed(() => userStore.userInfo)
}
}

View File

@@ -0,0 +1,201 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { handleError } from './hertz_error_handler'
// 请求配置接口
interface RequestConfig extends AxiosRequestConfig {
showLoading?: boolean
showError?: boolean
metadata?: {
requestId: string
timestamp: string
}
}
// 响应数据接口
interface ApiResponse<T = any> {
code: number
message: string
data: T
success?: boolean
}
// 请求拦截器配置
const requestInterceptor = {
onFulfilled: (config: RequestConfig) => {
const timestamp = new Date().toISOString()
const requestId = Math.random().toString(36).substr(2, 9)
// 简化日志,只在开发环境显示关键信息
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`)
}
// 添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
// 如果是FormData删除Content-Type让浏览器自动设置
if (config.data instanceof FormData) {
if (config.headers && 'Content-Type' in config.headers) {
delete config.headers['Content-Type']
}
console.log('📦 检测到FormData移除Content-Type让浏览器自动设置')
}
// 显示loading
if (config.showLoading !== false) {
// 这里可以添加loading显示逻辑
}
// 将requestId添加到config中用于响应时匹配
config.metadata = { requestId, timestamp }
return config as InternalAxiosRequestConfig
},
onRejected: (error: any) => {
console.error('❌ 请求错误:', error.message)
return Promise.reject(error)
}
}
// 响应拦截器配置
const responseInterceptor = {
onFulfilled: (response: AxiosResponse) => {
const requestTimestamp = (response.config as any).metadata?.timestamp
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
// 简化日志,只在开发环境显示关键信息
if (import.meta.env.DEV) {
console.log(`${response.status} ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`)
}
// 统一处理响应数据
if (response.data && typeof response.data === 'object') {
// 如果后端返回的是标准格式 {code, message, data}
if ('code' in response.data) {
// 标准API响应格式处理
}
}
return response
},
onRejected: (error: any) => {
const requestTimestamp = (error.config as any)?.metadata?.timestamp
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
// 简化错误日志
console.error(`${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`)
console.error('错误信息:', error.response?.data?.message || error.message)
// 使用统一错误处理器(支持按请求关闭全局错误提示)
const showError = (error.config as any)?.showError
if (showError !== false) {
handleError(error)
}
// 特殊处理401错误
if (error.response?.status === 401) {
console.warn('🔒 未授权清除token')
localStorage.removeItem('token')
// 可以在这里跳转到登录页
}
return Promise.reject(error)
}
}
class HertzRequest {
private instance: AxiosInstance
constructor(config: AxiosRequestConfig) {
// 在开发环境中使用空字符串以便Vite代理正常工作
// 在生产环境中使用完整的API地址
const isDev = import.meta.env.DEV
const baseURL = isDev ? '' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
console.log('🔧 创建axios实例 - isDev:', isDev)
console.log('🔧 创建axios实例 - baseURL:', baseURL)
console.log('🔧 环境变量 VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
this.instance = axios.create({
baseURL,
timeout: 10000,
// 不设置默认Content-Type让每个请求根据数据类型自动设置
...config
})
// 添加请求拦截器
this.instance.interceptors.request.use(
requestInterceptor.onFulfilled,
requestInterceptor.onRejected
)
// 添加响应拦截器
this.instance.interceptors.response.use(
responseInterceptor.onFulfilled,
responseInterceptor.onRejected
)
}
// GET请求
get<T = any>(url: string, config?: RequestConfig): Promise<T> {
return this.instance.get(url, config).then(res => res.data)
}
// POST请求
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
// 如果不是FormData设置Content-Type为application/json
const finalConfig = { ...config }
if (!(data instanceof FormData)) {
finalConfig.headers = {
'Content-Type': 'application/json',
...finalConfig.headers
}
}
return this.instance.post(url, data, finalConfig).then(res => res.data)
}
// PUT请求
put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
// 如果不是FormData设置Content-Type为application/json
const finalConfig = { ...config }
if (!(data instanceof FormData)) {
finalConfig.headers = {
'Content-Type': 'application/json',
...finalConfig.headers
}
}
return this.instance.put(url, data, finalConfig).then(res => res.data)
}
// DELETE请求
delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
return this.instance.delete(url, config).then(res => res.data)
}
// PATCH请求
patch<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.instance.patch(url, data, config).then(res => res.data)
}
// 上传文件
upload<T = any>(url: string, formData: FormData, config?: RequestConfig): Promise<T> {
// 不要手动设置Content-Type让浏览器自动设置这样会包含正确的boundary
return this.instance.post(url, formData, {
...config,
headers: {
// 不设置Content-Type让浏览器自动设置multipart/form-data的header
...config?.headers
}
}).then(res => res.data)
}
}
// 创建默认实例
export const request = new HertzRequest({})
// 导出类和配置接口
export { HertzRequest }
export type { RequestConfig, ApiResponse }

View File

@@ -0,0 +1,138 @@
/**
* 路由工具函数
* 用于动态路由相关的辅助功能
*/
// 获取views目录下的所有Vue文件
export const getViewFiles = () => {
const viewsContext = import.meta.glob('@/views/*.vue')
return Object.keys(viewsContext).map(path => path.split('/').pop())
}
// 从文件名生成路由名称
export const generateRouteName = (fileName: string): string => {
return fileName.replace('.vue', '')
}
// 从文件名生成路由路径
export const generateRoutePath = (fileName: string): string => {
const routeName = generateRouteName(fileName)
let routePath = `/${routeName.toLowerCase()}`
// 处理特殊命名(驼峰转短横线)
if (routeName !== routeName.toLowerCase()) {
routePath = `/${routeName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`
}
return routePath
}
// 生成路由标题
export const generateRouteTitle = (routeName: string): string => {
const titleMap: Record<string, string> = {
Dashboard: '仪表板',
User: '用户管理',
Profile: '个人资料',
Settings: '系统设置',
Test: '样式测试',
WebSocketTest: 'WebSocket测试',
NotFound: '页面未找到',
}
return titleMap[routeName] || routeName
}
// 判断路由是否需要认证
export const shouldRequireAuth = (routeName: string): boolean => {
const publicRoutes = ['Test', 'WebSocketTest']
return !(
publicRoutes.includes(routeName) || // 公开路由列表
routeName.startsWith('Demo') // Demo开头的页面不需要认证
)
}
// 获取公开路由列表
export const getPublicRoutes = (): string[] => {
return ['Test', 'WebSocketTest', 'Demo'] // 可以添加更多公开路由
}
// 打印路由调试信息
export const debugRoutes = () => {
const viewFiles = getViewFiles()
const fixedFiles = ['Home.vue', 'Login.vue']
const dynamicFiles = viewFiles.filter(file => !fixedFiles.includes(file) && file !== 'NotFound.vue')
console.log('🔍 路由调试信息:')
console.log('📁 所有视图文件:', viewFiles)
console.log('🔒 固定路由文件:', fixedFiles)
console.log('🚀 动态路由文件:', dynamicFiles)
const publicRoutes = getPublicRoutes()
console.log('🔓 公开路由 (不需要认证):', publicRoutes)
console.log('\n📋 动态路由配置:')
dynamicFiles.forEach(file => {
const routeName = generateRouteName(file)
const routePath = generateRoutePath(file)
const title = generateRouteTitle(routeName)
const requiresAuth = shouldRequireAuth(routeName)
const isPublic = !requiresAuth
console.log(` ${file}${routePath} (${title}) ${isPublic ? '🔓' : '🔒'}`)
})
console.log('\n🎯 Demo页面特殊说明:')
console.log(' - Demo开头的页面不需要认证 (Demo.vue, DemoPage.vue等)')
console.log(' - 可以直接访问 /demo 路径')
}
// 在开发环境中自动调用调试函数
if (import.meta.env.DEV) {
debugRoutes()
}
// 提供全局访问的路由信息查看函数
export const showRoutesInfo = () => {
console.log('🚀 Hertz Admin 路由配置信息:')
console.log('📋 完整路由列表:')
// 注意: 这里需要从路由实例中获取真实数据
// 由于路由工具函数在路由配置之前加载,这里提供的是示例数据
// 实际的动态路由信息会在项目启动时通过logRouteInfo()函数显示
console.log('\n🔒 固定路由 (需要手动配置):')
console.log(' 🔒 / → Home (首页)')
console.log(' 🔓 /login → Login (登录)')
console.log('\n🚀 动态路由 (自动生成):')
console.log(' 🔒 /dashboard → Dashboard (仪表板)')
console.log(' 🔒 /user → User (用户管理)')
console.log(' 🔒 /profile → Profile (个人资料)')
console.log(' 🔒 /settings → Settings (系统设置)')
console.log(' 🔓 /test → Test (样式测试)')
console.log(' 🔓 /websocket-test → WebSocketTest (WebSocket测试)')
console.log(' 🔓 /demo → Demo (动态路由演示)')
console.log('\n❓ 404路由:')
console.log(' ❓ /:pathMatch(.*)* → NotFound (页面未找到)')
console.log('\n📖 访问说明:')
console.log(' 🔓 公开路由: 可以直接访问,不需要登录')
console.log(' 🔒 私有路由: 需要登录后才能访问')
console.log(' 💡 提示: 可以在浏览器中直接访问这些路径')
console.log('\n🌐 可用链接:')
console.log(' http://localhost:3000/ - 首页 (需要登录)')
console.log(' http://localhost:3000/login - 登录页面')
console.log(' http://localhost:3000/dashboard - 仪表板 (需要登录)')
console.log(' http://localhost:3000/user - 用户管理 (需要登录)')
console.log(' http://localhost:3000/profile - 个人资料 (需要登录)')
console.log(' http://localhost:3000/settings - 系统设置 (需要登录)')
console.log(' http://localhost:3000/test - 样式测试 (公开)')
console.log(' http://localhost:3000/websocket-test - WebSocket测试 (公开)')
console.log(' http://localhost:3000/demo - 动态路由演示 (公开)')
console.log(' http://localhost:3000/any-other-path - 404页面 (公开)')
console.log('\n✅ 路由配置加载完成!')
console.log('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
}

View File

@@ -0,0 +1,113 @@
/**
* URL处理工具函数
*/
/**
* 获取完整的文件URL
* @param relativePath 相对路径,如 /media/detection/original/xxx.jpg
* @returns 完整的URL
*/
export function getFullFileUrl(relativePath: string): string {
if (!relativePath) {
console.warn('⚠️ 文件路径为空')
return ''
}
// 如果已经是完整URL直接返回
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
return relativePath
}
// 在开发环境中使用相对路径通过Vite代理
if (import.meta.env.DEV) {
return relativePath
}
// 在生产环境中拼接完整的URL
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
return `${baseURL}${relativePath}`
}
/**
* 获取API基础URL
* @returns API基础URL
*/
export function getApiBaseUrl(): string {
if (import.meta.env.DEV) {
return '' // 开发环境使用空字符串通过Vite代理
}
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
}
/**
* 获取媒体文件基础URL
* @returns 媒体文件基础URL
*/
export function getMediaBaseUrl(): string {
if (import.meta.env.DEV) {
return '' // 开发环境使用空字符串通过Vite代理
}
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
return baseURL.replace('/api', '') // 移除/api后缀
}
/**
* 检查URL是否可访问
* @param url 要检查的URL
* @returns Promise<boolean>
*/
export async function checkUrlAccessibility(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch (error) {
console.error('❌ URL访问检查失败:', url, error)
return false
}
}
/**
* 格式化文件大小
* @param bytes 字节数
* @returns 格式化后的文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', '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]
}
/**
* 获取文件扩展名
* @param filename 文件名
* @returns 文件扩展名
*/
export function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || ''
}
/**
* 检查是否为图片文件
* @param filename 文件名或URL
* @returns 是否为图片文件
*/
export function isImageFile(filename: string): boolean {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const extension = getFileExtension(filename)
return imageExtensions.includes(extension)
}
/**
* 检查是否为视频文件
* @param filename 文件名或URL
* @returns 是否为视频文件
*/
export function isVideoFile(filename: string): boolean {
const videoExtensions = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv']
const extension = getFileExtension(filename)
return videoExtensions.includes(extension)
}

View File

@@ -0,0 +1,251 @@
import { useAppStore } from '@/stores/hertz_app'
// 日期格式化
export const formatDate = (date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss') => {
const d = new Date(date)
if (isNaN(d.getTime())) {
return ''
}
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
// 防抖函数
export const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func(...args), delay)
}
}
// 节流函数
export const throttle = <T extends (...args: any[]) => any>(
func: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let lastCall = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
func(...args)
}
}
}
// 深拷贝
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T
}
if (typeof obj === 'object') {
const cloned = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
return obj
}
// 数组去重
export const unique = <T>(arr: T[]): T[] => {
return Array.from(new Set(arr))
}
// 获取URL参数
export const getUrlParam = (name: string, url?: string): string | null => {
const searchUrl = url || window.location.search
const params = new URLSearchParams(searchUrl)
return params.get(name)
}
// 设置URL参数
export const setUrlParam = (name: string, value: string, url?: string): string => {
const searchUrl = url || window.location.search
const params = new URLSearchParams(searchUrl)
if (value === null || value === undefined || value === '') {
params.delete(name)
} else {
params.set(name, value)
}
return params.toString()
}
// 复制到剪贴板
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
// 降级处理
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
textArea.remove()
if (!successful) {
throw new Error('复制失败')
}
}
return true
} catch (error) {
console.error('复制失败:', error)
return false
}
}
// 下载文件
export const downloadFile = (url: string, filename?: string) => {
const link = document.createElement('a')
link.href = url
link.download = filename || ''
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 格式化文件大小
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]
}
// 验证邮箱格式
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 验证手机号格式(中国大陆)
export const isValidPhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// 验证身份证号
export const isValidIdCard = (idCard: string): boolean => {
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return idCardRegex.test(idCard)
}
// 生成随机字符串
export const generateRandomString = (length: number = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// 等待函数
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 获取浏览器信息
export const getBrowserInfo = () => {
const userAgent = navigator.userAgent
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)
const isFirefox = /Firefox/.test(userAgent)
const isSafari = /Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor)
const isEdge = /Edg/.test(userAgent)
const isIE = /MSIE|Trident/.test(userAgent)
return {
isChrome,
isFirefox,
isSafari,
isEdge,
isIE,
userAgent,
}
}
// 本地存储封装
export const storage = {
get: <T>(key: string, defaultValue?: T): T | null => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : (defaultValue ?? null)
} catch (error) {
console.error(`获取本地存储失败 (${key}):`, error)
return defaultValue ?? null
}
},
set: <T>(key: string, value: T): void => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error(`设置本地存储失败 (${key}):`, error)
}
},
remove: (key: string): void => {
try {
localStorage.removeItem(key)
} catch (error) {
console.error(`删除本地存储失败 (${key}):`, error)
}
},
clear: (): void => {
try {
localStorage.clear()
} catch (error) {
console.error('清空本地存储失败:', error)
}
},
}

View File

@@ -0,0 +1,112 @@
import { menuApi, type Menu } from '@/api/menu'
// 菜单key和菜单ID的映射关系
let menuKeyToIdMap: Map<string, number> = new Map()
let menuIdToKeyMap: Map<number, string> = new Map()
let isInitialized = false
// 菜单key和菜单code的映射关系用于建立映射
const MENU_KEY_TO_CODE_MAP: { [key: string]: string } = {
'dashboard': 'dashboard',
'user-management': 'user_management',
'department-management': 'department_management',
'menu-management': 'menu_management',
'teacher': 'role_management'
}
/**
* 初始化菜单映射
*/
export const initializeMenuMapping = async (): Promise<void> => {
try {
// 获取菜单树数据
const response = await menuApi.getMenuTree()
if (response.code === 200 && response.data) {
// 清空现有映射
menuKeyToIdMap.clear()
// 递归处理菜单树
const processMenuTree = (menus: Menu[]) => {
menus.forEach(menu => {
if (menu.key && menu.id) {
menuKeyToIdMap.set(menu.key, menu.id)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
processMenuTree(menu.children)
}
})
}
processMenuTree(response.data)
}
} catch (error) {
console.error('初始化菜单映射时发生错误:', error)
}
}
/**
* 递归构建菜单映射关系
*/
const buildMenuMapping = (menus: Menu[]): void => {
menus.forEach(menu => {
// 根据menu_code找到对应的key
const menuKey = Object.keys(MENU_KEY_TO_CODE_MAP).find(
key => MENU_KEY_TO_CODE_MAP[key] === menu.menu_code
)
if (menuKey) {
menuKeyToIdMap.set(menuKey, menu.menu_id)
menuIdToKeyMap.set(menu.menu_id, menuKey)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
buildMenuMapping(menu.children)
}
})
}
/**
* 根据菜单key获取菜单ID
*/
export const getMenuIdByKey = (menuKey: string): number | undefined => {
return menuKeyToIdMap.get(menuKey)
}
/**
* 根据菜单ID获取菜单key
*/
export const getMenuKeyById = (menuId: number): string | undefined => {
return menuIdToKeyMap.get(menuId)
}
/**
* 检查用户是否有指定菜单的权限
*/
export const hasMenuPermissionById = (menuKey: string, userMenuPermissions: number[]): boolean => {
const menuId = getMenuIdByKey(menuKey)
if (!menuId) {
// 降级策略:如果没有找到菜单映射,则允许显示(向后兼容)
return true
}
return userMenuPermissions.includes(menuId)
}
/**
* 获取用户有权限的菜单keys
*/
export const getPermittedMenuKeys = (userMenuPermissions: number[]): string[] => {
const permittedKeys: string[] = []
userMenuPermissions.forEach(menuId => {
const menuKey = getMenuKeyById(menuId)
if (menuKey) {
permittedKeys.push(menuKey)
}
})
return permittedKeys
}

View File

@@ -0,0 +1,730 @@
// 前端ONNX YOLO检测工具类
import * as ort from 'onnxruntime-web'
// ONNX检测结果接口
export interface YOLODetectionResult {
detections: Array<{
class_name: string
confidence: number
bbox: {
x: number
y: number
width: number
height: number
}
}>
object_count: number
detected_categories: string[]
confidence_scores: number[]
avg_confidence: number
annotated_image: string // base64图像
processing_time: number
}
// 不预置任何类别名称;等待后端或标签文件提供
class YOLODetector {
private session: ort.InferenceSession | null = null
private modelPath: string = ''
private classNames: string[] = []
private inputShape: [number, number] = [640, 640] // 默认输入尺寸(可在 WASM 下动态调小)
private currentEP: 'webgpu' | 'webgl' | 'wasm' = 'wasm'
/**
* 加载ONNX模型
* @param modelPath 模型路径相对于public目录
* @param classNames 类别名称列表可选如果不提供则使用默认COCO类别
*/
async loadModel(modelPath: string, classNames?: string[], forceEP?: 'webgpu' | 'webgl' | 'wasm'): Promise<void> {
try {
console.log('🔄 开始加载ONNX模型:', modelPath)
// 设置类别名称
if (classNames && classNames.length > 0) {
this.classNames = classNames
console.log('📦 使用自定义类别:', classNames.length, '个类别')
} else {
// 如果未提供类别,稍后根据输出维度自动推断数量并用 class_0.. 命名
this.classNames = []
console.log('📦 未提供类别,将根据模型输出自动推断类别数量')
}
// 动态选择可用的 wasm 资源路径,避免 404/HTML 导致的“magic word”错误
const ensureWasmPath = async () => {
const candidates = [
'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/',
'https://unpkg.com/onnxruntime-web@1.23.2/dist/',
'/onnxruntime-web/', // 如果你把 dist 拷贝到 public/onnxruntime-web/
'/ort/' // 或者 public/ort/
]
for (const base of candidates) {
try {
const testUrl = base.replace(/\/$/, '') + '/ort-wasm.wasm'
const res = await fetch(testUrl, { method: 'HEAD', cache: 'no-store', mode: 'no-cors' as any })
// no-cors 模式下 status 为 0也视为可用跨域但可下载
if (res.ok || res.status === 0) {
// @ts-ignore
ort.env.wasm.wasmPaths = base
return true
}
} catch {}
}
return false
}
await ensureWasmPath()
// 配置 WASM 线程:若不支持跨域隔离/SharedArrayBuffer则退回单线程避免“worker not ready”
const canMultiThread = (self as any).crossOriginIsolated && typeof (self as any).SharedArrayBuffer !== 'undefined'
try {
// @ts-ignore
ort.env.wasm.numThreads = canMultiThread ? Math.max(2, Math.min(4, (navigator as any)?.hardwareConcurrency || 2)) : 1
// @ts-ignore
ort.env.wasm.proxy = true
} catch {}
const createWithEP = async (ep: 'webgpu' | 'webgl' | 'wasm') => {
if (ep === 'webgpu') {
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['webgpu'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'webgpu'
return
}
if (ep === 'webgl') {
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['webgl'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'webgl'
return
}
// wasm
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'wasm'
}
// 配置 ONNX Runtime优先 GPUWebGPU/WebGL再回退 WASM
// 支持通过 localStorage 开关强制使用 WASMlocalStorage.setItem('ort_force_wasm','1')
// 也可通过第三个参数 forceEP 指定(用于错误时的程序化降级)
const forceWasm = forceEP === 'wasm' || (localStorage.getItem('ort_force_wasm') === '1')
// 1) WebGPU实验性浏览器需支持 navigator.gpu
let created = false
if (!forceWasm && (navigator as any)?.gpu && (!forceEP || forceEP === 'webgpu')) {
try {
// 动态引入 webgpu 版本(若不支持不会打包)
await import('onnxruntime-web/webgpu')
await createWithEP('webgpu')
created = true
console.log('✅ 使用 WebGPU 推理')
} catch (e) {
console.warn('⚠️ WebGPU 初始化失败,回退到 WebGL:', e)
}
}
// 2) WebGLGPU 加速,兼容更好)
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
try {
await createWithEP('webgl')
created = true
console.log('✅ 使用 WebGL 推理')
} catch (e2) {
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
}
}
// 3) WASMCPU
if (!created) {
try {
// 设置 WASM 线程/特性(路径已在 ensureWasmPath 中选择)
// @ts-ignore
ort.env.wasm.numThreads = Math.max(1, Math.min(4, (navigator as any)?.hardwareConcurrency || 2))
// @ts-ignore
ort.env.wasm.proxy = true
} catch {}
await createWithEP('wasm')
console.log('✅ 使用 WASM 推理')
}
this.modelPath = modelPath
// 根据后端动态调整输入尺寸WASM 默认调小以提升流畅度,可用 localStorage 覆盖
try {
const override = parseInt(localStorage.getItem('ort_input_size') || '', 10)
if (Number.isFinite(override) && override >= 256 && override <= 1024) {
this.inputShape = [override, override] as any
} else if (this.currentEP === 'wasm') {
this.inputShape = [512, 512] as any
} else {
this.inputShape = [640, 640] as any
}
console.log('🧩 推理输入尺寸:', this.inputShape[0])
} catch {}
// 获取模型输入输出信息(兼容性更强的写法)
const inputNames = this.session.inputNames
const outputNames = this.session.outputNames
console.log('✅ 模型加载成功')
console.log('📥 输入:', inputNames)
console.log('📤 输出:', outputNames)
// 尝试从 outputMetadata 推断类别数(某些环境不提供 dims需要兜底
try {
if (outputNames && outputNames.length > 0) {
const outputMetadata: any = (this.session as any).outputMetadata
const outputName = outputNames[0]
const meta = outputMetadata?.[outputName]
const outputShape: number[] | undefined = meta?.dims
if (Array.isArray(outputShape) && outputShape.length >= 3) {
const numClasses = (outputShape[2] as number) - 5 // YOLO: [N, B, 5+C]
if (Number.isFinite(numClasses) && numClasses > 0 && numClasses !== this.classNames.length) {
console.warn(`⚠️ 模型输出类别数 (${numClasses}) 与提供的类别数 (${this.classNames.length}) 不匹配/或未提供`)
if (this.classNames.length === 0) {
this.classNames = Array.from({ length: numClasses }, (_, i) => `class_${i}`)
console.log('📦 根据模型输出调整类别数量为:', numClasses)
}
}
} else {
console.warn('⚠️ 无法从 outputMetadata 推断输出维度将在首次推理时根据输出tensor推断。')
}
}
} catch (metaErr) {
console.warn('⚠️ 读取 outputMetadata 失败,将在首次推理时推断类别数。', metaErr)
}
} catch (error) {
console.error('❌ 加载模型失败:', error)
throw new Error(`加载ONNX模型失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 检查模型是否已加载
*/
isLoaded(): boolean {
return this.session !== null
}
/**
* 获取当前加载的模型路径
*/
getModelPath(): string {
return this.modelPath
}
/**
* 获取类别名称列表
*/
getClassNames(): string[] {
return this.classNames
}
/**
* 预处理图像Ultralytics letterbox保比例缩放+灰边填充)
* 返回输入张量与还原坐标所需的比例与padding
*/
private preprocessImage(image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement): {
input: Float32Array
ratio: number
padX: number
padY: number
dstW: number
dstH: number
srcW: number
srcH: number
} {
const dstW = this.inputShape[0]
const dstH = this.inputShape[1]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建canvas上下文')
const srcW = image instanceof HTMLVideoElement ? image.videoWidth : (image as HTMLImageElement | HTMLCanvasElement).width
const srcH = image instanceof HTMLVideoElement ? image.videoHeight : (image as HTMLImageElement | HTMLCanvasElement).height
// 计算 letterbox
const r = Math.min(dstW / srcW, dstH / srcH)
const newW = Math.round(srcW * r)
const newH = Math.round(srcH * r)
const padX = Math.floor((dstW - newW) / 2)
const padY = Math.floor((dstH - newH) / 2)
canvas.width = dstW
canvas.height = dstH
// 背景填充灰色(114)与 Ultralytics 一致
ctx.fillStyle = 'rgb(114,114,114)'
ctx.fillRect(0, 0, dstW, dstH)
// 绘制等比缩放后的图像到中间
ctx.drawImage(image as any, 0, 0, srcW, srcH, padX, padY, newW, newH)
const imageData = ctx.getImageData(0, 0, dstW, dstH)
const data = imageData.data
const input = new Float32Array(3 * dstW * dstH)
for (let i = 0; i < data.length; i += 4) {
const r8 = data[i] / 255.0
const g8 = data[i + 1] / 255.0
const b8 = data[i + 2] / 255.0
const idx = i / 4
input[idx] = r8
input[idx + dstW * dstH] = g8
input[idx + dstW * dstH * 2] = b8
}
return { input, ratio: r, padX, padY, dstW, dstH, srcW, srcH }
}
/**
* 非极大值抑制NMS
*/
private nms(boxes: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}>, iouThreshold: number): number[] {
if (boxes.length === 0) return []
// 按置信度排序
boxes.sort((a, b) => b.conf - a.conf)
const selected: number[] = []
const suppressed = new Set<number>()
for (let i = 0; i <boxes.length; i++) {
if (suppressed.has(i)) continue
selected.push(i)
const box1 = boxes[i]
for (let j = i + 1; j < boxes.length; j++) {
if (suppressed.has(j)) continue
const box2 = boxes[j]
// 计算IoU
const iou = this.calculateIoU(box1, box2)
if (iou > iouThreshold) {
suppressed.add(j)
}
}
}
return selected
}
/**
* 计算IoU交并比
*/
private calculateIoU(box1: {x: number, y: number, w: number, h: number}, box2: {x: number, y: number, w: number, h: number}): number {
const x1 = Math.max(box1.x, box2.x)
const y1 = Math.max(box1.y, box2.y)
const x2 = Math.min(box1.x + box1.w, box2.x + box2.w)
const y2 = Math.min(box1.y + box1.h, box2.y + box2.h)
if (x2 < x1 || y2 < y1) return 0
const intersection = (x2 - x1) * (y2 - y1)
const area1 = box1.w * box1.h
const area2 = box2.w * box2.h
const union = area1 + area2 - intersection
return intersection / union
}
/**
* 后处理检测结果
*/
private postprocess(
output: ort.Tensor,
meta: { ratio: number; padX: number; padY: number; srcW: number; srcH: number },
confThreshold: number,
nmsThreshold: number,
opts?: { maxDetections?: number; minBoxArea?: number; classWise?: boolean }
): Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}> {
const outputData = output.data as Float32Array
const outputShape = output.dims || []
// YOLO输出常见两种
// A) [1, num_boxes, 5+num_classes]
// B) [1, 5+num_classes, num_boxes]
// 另外也可能已经扁平化为 [num_boxes, 5+num_classes]
let numBoxes = 0
let numFeatures = 0
if (outputShape.length === 3) {
// 取更大的作为 boxes 维度(通常是 8400较小的是 5+C通常是 85
const a = outputShape[1] as number
const b = outputShape[2] as number
if (a >= b) {
numBoxes = a
numFeatures = b
} else {
numBoxes = b
numFeatures = a
}
} else if (outputShape.length === 2) {
numBoxes = outputShape[0] as number
numFeatures = outputShape[1] as number
} else {
// 无维度信息时根据长度推断(保底)
numFeatures = 85
numBoxes = Math.floor(outputData.length / numFeatures)
}
const numClasses = Math.max(0, numFeatures - 5)
const detections: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
// 还原到原图坐标:先减去 padding再除以 ratio
const { ratio, padX, padY, srcW: originalWidth, srcH: originalHeight } = meta
// 获取 (row i, col j) 的值,兼容布局 A/B
const getVal = (i: number, j: number): number => {
if (outputShape.length === 3) {
const a = outputShape[1] as number
const b = outputShape[2] as number
if (a >= b) {
// [1, boxes, features]
return outputData[i * b + j]
}
// [1, features, boxes]
return outputData[j * a + i]
}
// [boxes, features]
return outputData[i * numFeatures + j]
}
// sigmoid
const sigmoid = (v: number) => 1 / (1 + Math.exp(-v))
// 情况一部分导出的ONNX已经做过NMS输出形如 [num, 6]x1,y1,x2,y2,score,classId或其它顺序
const tryPostNmsLayouts = () => {
const candidates: Array<(row: (j:number)=>number) => {x:number,y:number,w:number,h:number,conf:number,cls:number} | null> = [
// [x1,y1,x2,y2,score,cls]
(get) => {
const x1 = get(0), y1 = get(1), x2 = get(2), y2 = get(3)
const score = get(4), cls = get(5)
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
if (score < 0 || score > 1) return null
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
},
// [cls,score,x1,y1,x2,y2]
(get) => {
const cls = get(0), score = get(1), x1 = get(2), y1 = get(3), x2 = get(4), y2 = get(5)
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
if (score < 0 || score > 1) return null
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
},
// [x,y,w,h,score,cls]xywh
(get) => {
const x = get(0), y = get(1), w = get(2), h = get(3), score = get(4), cls = get(5)
if (!isFinite(x+y+w+h+score+cls)) return null
if (score < 0 || score > 1) return null
return { x: x - w/2, y: y - h/2, w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
}
]
const out: typeof detections = []
for (let i = 0; i < numBoxes; i++) {
const getter = (j:number) => getVal(i, j)
let picked = null
for (const decode of candidates) {
picked = decode(getter)
if (picked && picked.conf >= confThreshold) break
}
if (!picked || picked.conf < confThreshold) continue
// 还原坐标
let { x, y, w, h, conf, cls } = picked
x = (x - padX) / ratio
y = (y - padY) / ratio
w = w / ratio
h = h / ratio
const area = Math.max(0, w) * Math.max(0, h)
const minArea = opts?.minBoxArea ?? (meta.srcW * meta.srcH * 0.0001)
if (area <= 0 || area < minArea) continue
out.push({ x, y, w, h, conf, class: cls })
}
return out
}
// 情况二:原始预测 [*, *, 5+num_classes],需要 obj × class 计算。
// 支持两种坐标格式xywh(中心点) 与 xyxy(左上/右下)。优先取能得到更多有效框的解码。
const decode = (mode: 'xywh' | 'xyxy') => {
const out: typeof detections = []
for (let i = 0; i < numBoxes; i++) {
const v0 = getVal(i, 0)
const v1 = getVal(i, 1)
const v2 = getVal(i, 2)
const v3 = getVal(i, 3)
const objConf = sigmoid(getVal(i, 4))
// 最大类别
let maxClassConf = 0
let maxClassIdx = 0
for (let j = 0; j < numClasses; j++) {
const classConf = sigmoid(getVal(i, 5 + j))
if (classConf > maxClassConf) {
maxClassConf = classConf
maxClassIdx = j
}
}
const confidence = objConf * maxClassConf
if (confidence < confThreshold) continue
let x = 0, y = 0, w = 0, h = 0
if (mode === 'xywh') {
const xc = (v0 - padX) / ratio
const yc = (v1 - padY) / ratio
const wv = v2 / ratio
const hv = v3 / ratio
x = xc - wv / 2
y = yc - hv / 2
w = wv
h = hv
} else {
// xyxy
const x1 = (v0 - padX) / ratio
const y1 = (v1 - padY) / ratio
const x2 = (v2 - padX) / ratio
const y2 = (v3 - padY) / ratio
x = Math.min(x1, x2)
y = Math.min(y1, y2)
w = Math.abs(x2 - x1)
h = Math.abs(y2 - y1)
}
const area = Math.max(0, w) * Math.max(0, h)
const minArea = opts?.minBoxArea ?? (originalWidth * originalHeight * 0.00005) // 放宽0.005%
if (area <= 0 || area < minArea) continue
if (w > 4 * originalWidth || h > 4 * originalHeight) continue // 明显异常
out.push({ x, y, w, h, conf: confidence, class: maxClassIdx })
}
return out
}
let pick: typeof detections = []
// 若特征维很小(<=6优先按“已NMS格式”解析
if (numFeatures <= 6) {
pick = tryPostNmsLayouts()
}
// 否则按原始格式解码
if (pick.length === 0) {
const d1 = decode('xywh')
const d2 = decode('xyxy')
pick = d2.length > d1.length ? d2 : d1
}
detections.push(...pick)
// 执行NMS支持按类别
const classWise = opts?.classWise ?? true
let kept: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
if (classWise) {
const byClass: Record<number, typeof detections> = {}
for (const d of detections) {
(byClass[d.class] ||= []).push(d)
}
for (const k in byClass) {
const group = byClass[k]
const idxs = this.nms(group, nmsThreshold)
kept.push(...idxs.map(i => group[i]))
}
} else {
const idxs = this.nms(detections, nmsThreshold)
kept = idxs.map(i => detections[i])
}
// 置信度排序并限制最大数量
kept.sort((a, b) => b.conf - a.conf)
const limited = kept.slice(0, opts?.maxDetections ?? 100)
// 构建最终结果
return limited.map(det => {
const className = this.classNames[det.class] || `class_${det.class}`
return {
class_name: className,
confidence: det.conf,
bbox: {
x: Math.max(0, det.x),
y: Math.max(0, det.y),
width: Math.min(det.w, originalWidth - det.x),
height: Math.min(det.h, originalHeight - det.y)
}
}
})
}
/**
* 在图像上绘制检测框
*/
private drawDetections(canvas: HTMLCanvasElement, detections: Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}>): void {
const ctx = canvas.getContext('2d')
if (!ctx) return
// 为每个类别分配颜色
const colors: {[key: string]: string} = {}
const colorPalette = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
detections.forEach((det, idx) => {
if (!colors[det.class_name]) {
colors[det.class_name] = colorPalette[idx % colorPalette.length]
}
})
detections.forEach(det => {
const { x, y, width, height } = det.bbox
const color = colors[det.class_name]
// 绘制边界框
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.strokeRect(x, y, width, height)
// 绘制标签背景
const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`
ctx.font = '14px Arial'
const textMetrics = ctx.measureText(label)
const textWidth = textMetrics.width
const textHeight = 20
ctx.fillStyle = color
ctx.fillRect(x, y - textHeight, textWidth + 10, textHeight)
// 绘制标签文字
ctx.fillStyle = '#FFFFFF'
ctx.fillText(label, x + 5, y - 5)
})
}
/**
* 执行检测
* @param image 图像元素Image, Video, 或 Canvas
* @param confidenceThreshold 置信度阈值
* @param nmsThreshold NMS阈值
*/
async detect(
image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement,
confidenceThreshold: number = 0.25,
nmsThreshold: number = 0.7
): Promise<YOLODetectionResult> {
if (!this.session) {
throw new Error('模型未加载,请先调用 loadModel()')
}
const startTime = performance.now()
try {
// 获取原始图像尺寸
const originalWidth = image instanceof HTMLVideoElement ? image.videoWidth : image.width
const originalHeight = image instanceof HTMLVideoElement ? image.videoHeight : image.height
// 预处理图像letterbox
const prep = this.preprocessImage(image)
// 创建输入tensor [1, 3, H, W]
const inputTensor = new ort.Tensor('float32', prep.input, [1, 3, this.inputShape[1], this.inputShape[0]])
// 执行推理
const inputName = this.session.inputNames[0]
const feeds = { [inputName]: inputTensor }
const results = await this.session.run(feeds)
// 获取输出
const outputName = this.session.outputNames[0]
const output = results[outputName]
// 后处理
const detections = this.postprocess(
output,
{ ratio: prep.ratio, padX: prep.padX, padY: prep.padY, srcW: originalWidth, srcH: originalHeight },
confidenceThreshold,
nmsThreshold,
{ maxDetections: 100, minBoxArea: originalWidth * originalHeight * 0.0001, classWise: true }
)
// 计算统计信息
const objectCount = detections.length
const detectedCategories = [...new Set(detections.map(d => d.class_name))]
const confidenceScores = detections.map(d => d.confidence)
const avgConfidence = confidenceScores.length > 0
? confidenceScores.reduce((a, b) => a + b, 0) / confidenceScores.length
: 0
// 绘制检测结果
const canvas = document.createElement('canvas')
canvas.width = originalWidth
canvas.height = originalHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(image, 0, 0, originalWidth, originalHeight)
this.drawDetections(canvas, detections)
}
// 转换为base64降低质量减少内存与传输开销
const annotatedImage = canvas.toDataURL('image/jpeg', 0.4)
const processingTime = (performance.now() - startTime) / 1000
return {
detections: detections.map(d => ({
class_name: d.class_name,
confidence: d.confidence,
bbox: d.bbox
})),
object_count: objectCount,
detected_categories: detectedCategories,
confidence_scores: confidenceScores,
avg_confidence: avgConfidence,
annotated_image: annotatedImage,
processing_time: processingTime
}
} catch (error) {
console.error('❌ 检测失败:', error)
// 若 GPU 后端不支持某些算子,自动回退到 WASM 并重试一次
const msg = String((error as any)?.message || error)
const needFallback = /GatherND|Unsupported data type|JSF Kernel|ExecuteKernel|WebGPU|WebGL|worker not ready/i.test(msg)
if (needFallback && this.currentEP !== 'wasm') {
try {
console.warn('⚠️ 检测算子不被 GPU 支持,自动回退到 WASM 并重试一次。')
// 强制全局与本次调用走 WASM
localStorage.setItem('ort_force_wasm','1')
await this.loadModel(this.modelPath, this.classNames, 'wasm')
// 强制使用 wasm
// @ts-ignore
ort.env.wasm.proxy = true
this.currentEP = 'wasm'
return await this.detect(image, confidenceThreshold, nmsThreshold)
} catch (e2) {
console.error('❌ 回退到 WASM 后仍失败:', e2)
}
}
// 如果已是 wasm但报 worker not ready再降级为单线程重建 session
if (/worker not ready/i.test(msg) && this.currentEP === 'wasm') {
try {
// @ts-ignore
ort.env.wasm.numThreads = 1
await this.loadModel(this.modelPath, this.classNames)
return await this.detect(image, confidenceThreshold, nmsThreshold)
} catch (e3) {
console.error('❌ 降级单线程后仍失败:', e3)
}
}
throw new Error(`检测失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 释放模型资源
*/
dispose(): void {
if (this.session) {
// ONNX Runtime会自动管理资源但我们可以清理引用
this.session = null
this.modelPath = ''
console.log('🗑️ 模型资源已释放')
}
}
}
// 导出单例
export const yoloDetector = new YOLODetector()