前后端第一版提交
This commit is contained in:
70
hertz_server_diango_ui/src/utils/hertz_captcha.ts
Normal file
70
hertz_server_diango_ui/src/utils/hertz_captcha.ts
Normal 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 }
|
||||
87
hertz_server_diango_ui/src/utils/hertz_env.ts
Normal file
87
hertz_server_diango_ui/src/utils/hertz_env.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
375
hertz_server_diango_ui/src/utils/hertz_error_handler.ts
Normal file
375
hertz_server_diango_ui/src/utils/hertz_error_handler.ts
Normal 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)
|
||||
}
|
||||
154
hertz_server_diango_ui/src/utils/hertz_permission.ts
Normal file
154
hertz_server_diango_ui/src/utils/hertz_permission.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
201
hertz_server_diango_ui/src/utils/hertz_request.ts
Normal file
201
hertz_server_diango_ui/src/utils/hertz_request.ts
Normal 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 }
|
||||
138
hertz_server_diango_ui/src/utils/hertz_router_utils.ts
Normal file
138
hertz_server_diango_ui/src/utils/hertz_router_utils.ts
Normal 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('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
|
||||
}
|
||||
113
hertz_server_diango_ui/src/utils/hertz_url.ts
Normal file
113
hertz_server_diango_ui/src/utils/hertz_url.ts
Normal 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)
|
||||
}
|
||||
251
hertz_server_diango_ui/src/utils/hertz_utils.ts
Normal file
251
hertz_server_diango_ui/src/utils/hertz_utils.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
112
hertz_server_diango_ui/src/utils/menu_mapping.ts
Normal file
112
hertz_server_diango_ui/src/utils/menu_mapping.ts
Normal 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
|
||||
}
|
||||
730
hertz_server_diango_ui/src/utils/yolo_frontend.ts
Normal file
730
hertz_server_diango_ui/src/utils/yolo_frontend.ts
Normal 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:优先 GPU(WebGPU/WebGL),再回退 WASM
|
||||
// 支持通过 localStorage 开关强制使用 WASM:localStorage.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) WebGL(GPU 加速,兼容更好)
|
||||
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
|
||||
try {
|
||||
await createWithEP('webgl')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGL 推理')
|
||||
} catch (e2) {
|
||||
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) WASM(CPU)
|
||||
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()
|
||||
Reference in New Issue
Block a user