前端重构 Vite
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="notification-container" v-if="store.notifications.length > 0">
|
||||
<TransitionGroup name="notification">
|
||||
<div
|
||||
v-for="n in store.notifications"
|
||||
:key="n.id"
|
||||
:class="['notification', 'notification-' + n.type]"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
{{ n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : n.type === 'warning' ? '!' : 'i' }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-message">{{ n.message }}</div>
|
||||
</div>
|
||||
<button class="notification-close" @click="dismissNotification(n.id)">×</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="logo" @click="router.push('/')">
|
||||
<div class="logo-icon">G</div>
|
||||
<div>
|
||||
<div class="logo-text">Gemold</div>
|
||||
<div class="logo-subtitle">模具制造管理系统</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu" v-if="store.user">
|
||||
<router-link
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="user-section">
|
||||
<template v-if="store.user">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">{{ (store.user.full_name || store.user.username).charAt(0).toUpperCase() }}</div>
|
||||
<span class="user-name">{{ store.user.full_name || store.user.username }}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary" @click="handleLogout">退出</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="btn btn-primary">登录</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="footer-content">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" class="beian-link">粤ICP备2025386132号-1</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { clearAuth, initAuth } from '@/shared/auth'
|
||||
import { addNotification } from '@/shared/notification'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = [
|
||||
{ path: '/', label: '首页', icon: '⌂' },
|
||||
{ path: '/inventory', label: '进销存', icon: '⊞' },
|
||||
{ path: '/moldinsight', label: 'MoldInsight', icon: '◈' },
|
||||
]
|
||||
if (store.user?.is_superuser) {
|
||||
items.push({ path: '/users', label: '用户管理', icon: '👤' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiRequest('/api/auth/logout', { method: 'POST' })
|
||||
} catch { /* ignore */ }
|
||||
clearAuth()
|
||||
addNotification('已退出登录', 'success')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const dismissNotification = (id: number) => {
|
||||
const notification = store.notifications.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.visible = false
|
||||
setTimeout(() => {
|
||||
store.dismissNotification(id)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-6);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo { display: flex; align-items: center; gap: var(--space-3); cursor: pointer; }
|
||||
.logo-icon {
|
||||
width: 36px; height: 36px;
|
||||
background: var(--primary-500);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: var(--text-lg); font-weight: var(--font-bold);
|
||||
}
|
||||
.logo-text { font-size: var(--text-xl); font-weight: var(--font-semibold); color: var(--text-primary); }
|
||||
.logo-subtitle { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
.nav-menu { display: flex; align-items: center; gap: var(--space-1); }
|
||||
.nav-item {
|
||||
padding: var(--space-2) var(--space-4); border-radius: var(--radius-md);
|
||||
color: var(--text-secondary); text-decoration: none;
|
||||
font-size: var(--text-sm); font-weight: var(--font-medium);
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.nav-item.active { background: var(--primary-50); color: var(--primary-600); }
|
||||
.nav-item:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22); }
|
||||
|
||||
.user-section { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.user-info { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.user-avatar {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--primary-100); color: var(--primary-600);
|
||||
border-radius: var(--radius-full);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: var(--text-sm); font-weight: var(--font-semibold);
|
||||
}
|
||||
.user-name { font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--text-primary); }
|
||||
|
||||
.main-content {
|
||||
flex: 1; width: 100%; margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
padding-bottom: var(--space-8);
|
||||
}
|
||||
@media (max-width: 768px) { .main-content { padding: var(--space-6) var(--space-4); } }
|
||||
@media (min-width: 1400px) { .main-content { max-width: 95vw; padding: var(--space-8) var(--space-8); } }
|
||||
@media (min-width: 1600px) { .main-content { max-width: 90vw; } }
|
||||
@media (min-width: 1920px) { .main-content { max-width: 85vw; } }
|
||||
|
||||
.app-footer {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: var(--bg-primary); border-top: 1px solid var(--border-light);
|
||||
padding: var(--space-3) 0; z-index: 99;
|
||||
}
|
||||
.footer-content { max-width: 1400px; margin: 0 auto; padding: 0 var(--space-6); text-align: center; }
|
||||
.beian-link { font-size: var(--text-xs); color: var(--text-tertiary); text-decoration: none; transition: color var(--duration-fast) var(--ease-default); }
|
||||
.beian-link:hover { color: var(--primary-600); text-decoration: underline; }
|
||||
|
||||
.notification-container {
|
||||
position: fixed; top: var(--space-6); right: var(--space-6); z-index: 1000;
|
||||
display: flex; flex-direction: column; gap: var(--space-3);
|
||||
}
|
||||
.notification {
|
||||
display: flex; align-items: flex-start; gap: var(--space-3);
|
||||
padding: var(--space-4); background: var(--bg-primary);
|
||||
border: 1px solid var(--border-light); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg); min-width: 320px; max-width: 400px;
|
||||
}
|
||||
.notification-success { border-left: 3px solid var(--success); }
|
||||
.notification-error { border-left: 3px solid var(--error); }
|
||||
.notification-warning { border-left: 3px solid var(--warning); }
|
||||
.notification-icon { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.notification-content { flex: 1; }
|
||||
.notification-message { font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.notification-close {
|
||||
width: 20px; height: 20px; border: none; background: transparent;
|
||||
color: var(--text-muted); cursor: pointer; padding: 0;
|
||||
}
|
||||
|
||||
.notification-enter-active, .notification-leave-active { transition: all var(--duration-normal) var(--ease-default); }
|
||||
.notification-enter-from, .notification-leave-to { opacity: 0; transform: translateX(100%); }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity var(--duration-normal) var(--ease-default); }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed; inset: 0; background: var(--bg-overlay);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.loading-content { background: var(--bg-primary); padding: var(--space-8); border-radius: var(--radius-xl); text-align: center; }
|
||||
.loading-spinner {
|
||||
width: 24px; height: 24px; border: 2px solid var(--border-default);
|
||||
border-top-color: var(--primary-500); border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content { padding: 0 var(--space-4); height: 56px; }
|
||||
.logo-subtitle { display: none; }
|
||||
.nav-menu { gap: 0; }
|
||||
.nav-item { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
|
||||
.user-name { display: none; }
|
||||
.footer-content { padding: 0 var(--space-4); }
|
||||
.beian-link { font-size: 11px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
:root {
|
||||
--primary-50: #eff6ff;
|
||||
--primary-100: #dbeafe;
|
||||
--primary-200: #bfdbfe;
|
||||
--primary-300: #93c5fd;
|
||||
--primary-400: #60a5fa;
|
||||
--primary-500: #3b82f6;
|
||||
--primary-600: #2563eb;
|
||||
--primary-700: #1d4ed8;
|
||||
--primary-800: #1e40af;
|
||||
--primary-900: #1e3a8a;
|
||||
|
||||
--gray-50: #fafafa;
|
||||
--gray-100: #f5f5f5;
|
||||
--gray-200: #e5e5e5;
|
||||
--gray-300: #d4d4d4;
|
||||
--gray-400: #a3a3a3;
|
||||
--gray-500: #737373;
|
||||
--gray-600: #525252;
|
||||
--gray-700: #404040;
|
||||
--gray-800: #262626;
|
||||
--gray-900: #171717;
|
||||
|
||||
--success: #22c55e;
|
||||
--success-bg: #f0fdf4;
|
||||
--warning: #eab308;
|
||||
--warning-bg: #fefce8;
|
||||
--error: #ef4444;
|
||||
--error-bg: #fef2f2;
|
||||
--info: #3b82f6;
|
||||
--info-bg: #eff6ff;
|
||||
|
||||
--text-primary: #171717;
|
||||
--text-secondary: #404040;
|
||||
--text-tertiary: #737373;
|
||||
--text-muted: #a3a3a3;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #fafafa;
|
||||
--bg-tertiary: #f5f5f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.4);
|
||||
|
||||
--border-light: #f0f0f0;
|
||||
--border-default: #e5e5e5;
|
||||
--border-strong: #d4d4d4;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', Monaco, Consolas, monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.25rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
}
|
||||
|
||||
[data-ui="v2"] {
|
||||
--primary-50: #eef2ff;
|
||||
--primary-100: #e0e7ff;
|
||||
--primary-200: #c7d2fe;
|
||||
--primary-300: #a5b4fc;
|
||||
--primary-400: #818cf8;
|
||||
--primary-500: #6366f1;
|
||||
--primary-600: #4f46e5;
|
||||
--primary-700: #4338ca;
|
||||
--primary-800: #3730a3;
|
||||
--primary-900: #312e81;
|
||||
|
||||
--gray-50: #fafafa;
|
||||
--gray-100: #f4f4f5;
|
||||
--gray-200: #e4e4e7;
|
||||
--gray-300: #d4d4d8;
|
||||
--gray-400: #a1a1aa;
|
||||
--gray-500: #71717a;
|
||||
--gray-600: #52525b;
|
||||
--gray-700: #3f3f46;
|
||||
--gray-800: #27272a;
|
||||
--gray-900: #18181b;
|
||||
|
||||
--success: #16a34a;
|
||||
--success-bg: #f0fdf4;
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
--error: #dc2626;
|
||||
--error-bg: #fef2f2;
|
||||
--info: var(--primary-600);
|
||||
--info-bg: var(--primary-50);
|
||||
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f8fafc;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-overlay: rgba(15, 23, 42, 0.5);
|
||||
|
||||
--border-light: rgba(15, 23, 42, 0.08);
|
||||
--border-default: rgba(15, 23, 42, 0.12);
|
||||
--border-strong: rgba(15, 23, 42, 0.18);
|
||||
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-sm: 0 2px 10px rgba(15, 23, 42, 0.08);
|
||||
--shadow-md: 0 12px 30px rgba(15, 23, 42, 0.12);
|
||||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.14);
|
||||
|
||||
--duration-fast: 140ms;
|
||||
--duration-normal: 180ms;
|
||||
--duration-slow: 260ms;
|
||||
}
|
||||
|
||||
[data-ui="v2"] body {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
|
||||
--bg-primary: #0b1220;
|
||||
--bg-secondary: #0f172a;
|
||||
--bg-tertiary: #111c33;
|
||||
--bg-elevated: #0f172a;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--border-light: rgba(255, 255, 255, 0.06);
|
||||
--border-default: rgba(255, 255, 255, 0.1);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
[data-ui="v2"][data-theme="dark"] {
|
||||
--primary-50: rgba(99, 102, 241, 0.12);
|
||||
--primary-100: rgba(99, 102, 241, 0.18);
|
||||
--bg-primary: #0a1020;
|
||||
--bg-secondary: #0b1326;
|
||||
--bg-tertiary: #0d1930;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--duration-fast: 1ms;
|
||||
--duration-normal: 1ms;
|
||||
--duration-slow: 1ms;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 1ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--gray-300);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gray-400);
|
||||
}
|
||||
|
||||
select.form-input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23737373' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--space-3) center;
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5); font-size: var(--text-sm); font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md); border: none; cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default); text-decoration: none;
|
||||
}
|
||||
[data-ui="v2"] .btn { border-radius: var(--radius-lg); }
|
||||
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22); }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-600) 0%, var(--primary-700) 100%);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); box-shadow: 0 1px 4px rgba(59, 130, 246, 0.2); }
|
||||
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-default); }
|
||||
.btn-secondary:hover { background: var(--gray-200); }
|
||||
.btn-danger { background: var(--error); color: white; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-lg { padding: var(--space-4) var(--space-6); font-size: var(--text-base); }
|
||||
.btn-sm { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
|
||||
.btn:disabled, .btn-disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-primary); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl); padding: var(--space-6); margin-bottom: var(--space-6);
|
||||
}
|
||||
[data-ui="v2"] .card { border-color: var(--border-default); box-shadow: var(--shadow-sm); }
|
||||
[data-ui="v2"][data-theme="dark"] .card { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); }
|
||||
.card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: var(--space-4); padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.card-title { font-size: var(--text-lg); font-weight: var(--font-semibold); color: var(--text-primary); }
|
||||
.card-body { padding: var(--space-2) 0; }
|
||||
|
||||
.form-group { margin-bottom: var(--space-5); }
|
||||
.form-label {
|
||||
display: block; font-size: var(--text-sm); font-weight: var(--font-medium);
|
||||
color: var(--text-primary); margin-bottom: var(--space-2);
|
||||
}
|
||||
.form-input {
|
||||
width: 100%; padding: var(--space-3) var(--space-4); font-size: var(--text-base);
|
||||
color: var(--text-primary); background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none; border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
}
|
||||
.form-input::placeholder { color: var(--text-muted); }
|
||||
.form-error { font-size: var(--text-sm); color: var(--error); margin-top: var(--space-1); }
|
||||
.form-select {
|
||||
width: 100%; padding: 10px 12px; font-size: 14px; border: 1px solid #ddd;
|
||||
border-radius: 6px; background: white; color: #333; cursor: pointer;
|
||||
}
|
||||
.form-select:focus { outline: none; border-color: #1976d2; box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); }
|
||||
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td {
|
||||
padding: var(--space-4) var(--space-4); text-align: left;
|
||||
border-bottom: 1px solid var(--border-light); vertical-align: top;
|
||||
}
|
||||
.data-table th {
|
||||
font-size: var(--text-xs); font-weight: var(--font-medium); color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: 0.05em; background: var(--bg-secondary);
|
||||
}
|
||||
.data-table td { font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.data-table tbody tr:hover { background: var(--bg-secondary); }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs); font-weight: var(--font-medium); border-radius: var(--radius-full);
|
||||
}
|
||||
.badge-success { background: var(--success-bg); color: var(--success); }
|
||||
.badge-warning { background: var(--warning-bg); color: var(--warning); }
|
||||
.badge-error { background: var(--error-bg); color: var(--error); }
|
||||
.badge-info { background: var(--info-bg); color: var(--info); }
|
||||
|
||||
.tabs {
|
||||
display: flex; gap: var(--space-2); background: var(--bg-secondary);
|
||||
padding: var(--space-1); border-radius: var(--radius-xl); margin-bottom: var(--space-6);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tab {
|
||||
padding: var(--space-3) var(--space-5); font-size: var(--text-sm); font-weight: var(--font-medium);
|
||||
color: var(--text-secondary); background: transparent; border: none;
|
||||
border-radius: var(--radius-lg); cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default); white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); background: var(--bg-primary); }
|
||||
.tab.active { color: var(--primary-600); background: var(--bg-primary); box-shadow: var(--shadow-sm); }
|
||||
|
||||
.page-header { margin-bottom: var(--space-8); }
|
||||
.page-title { font-size: var(--text-3xl); font-weight: var(--font-bold); color: var(--text-primary); margin-bottom: var(--space-2); }
|
||||
.page-subtitle { font-size: var(--text-base); color: var(--text-tertiary); }
|
||||
|
||||
.progress-bar { width: 100%; height: 8px; background: var(--bg-tertiary); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--primary-500); border-radius: var(--radius-full); transition: width var(--duration-normal) var(--ease-default); }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000; padding: var(--space-4);
|
||||
}
|
||||
.modal-content {
|
||||
background: var(--bg-primary); border-radius: var(--radius-xl);
|
||||
width: 100%; max-width: 1000px; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.modal-header h3 { font-size: var(--text-lg); font-weight: var(--font-semibold); color: var(--text-primary); margin: 0; }
|
||||
.modal-close {
|
||||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none; font-size: var(--text-xl);
|
||||
color: var(--text-tertiary); cursor: pointer; border-radius: var(--radius-md);
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-secondary); color: var(--text-primary); }
|
||||
.modal-body { padding: var(--space-6); }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: var(--space-3);
|
||||
padding-top: var(--space-4); margin-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: var(--space-3) var(--space-4); background: var(--error-bg);
|
||||
color: var(--error); border-radius: var(--radius-md); font-size: var(--text-sm);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.empty-state { text-align: center; padding: var(--space-12) 0; }
|
||||
.empty-icon { width: 64px; height: 64px; margin: 0 auto var(--space-4); color: var(--text-muted); }
|
||||
.empty-title { font-size: var(--text-lg); font-weight: var(--font-medium); color: var(--text-primary); margin-bottom: var(--space-2); }
|
||||
.empty-desc { font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.w-full { width: 100%; }
|
||||
.hidden { display: none; }
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">设计体系</h1>
|
||||
<p class="page-subtitle">迁移中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">灰度发布与回滚</h1>
|
||||
<p class="page-subtitle">迁移中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hero-section">
|
||||
<h1 class="hero-title">Gemold 模具制造管理系统</h1>
|
||||
<p class="hero-subtitle">智能模具设计与制造一体化平台,从零件分析到模具生成,全流程数字化解决方案</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">◈</div>
|
||||
<h3 class="feature-title">MoldInsight</h3>
|
||||
<p class="feature-desc">智能模具分析引擎,支持 STP/STEP 文件解析、分模设计、CAM 刀路规划</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⊞</div>
|
||||
<h3 class="feature-title">进销存管理</h3>
|
||||
<p class="feature-desc">产品管理、采购销售、库存管理、财务管理,一站式 ERP 解决方案</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚙</div>
|
||||
<h3 class="feature-title">AI 驱动</h3>
|
||||
<p class="feature-desc">AI 分型面检测、模具方案评分、智能设计建议,提升设计效率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-section { text-align: center; padding: var(--space-16) 0; }
|
||||
.hero-title { font-size: var(--text-4xl); font-weight: var(--font-bold); color: var(--text-primary); margin-bottom: var(--space-4); }
|
||||
.hero-subtitle { font-size: var(--text-lg); color: var(--text-tertiary); max-width: 600px; margin: 0 auto var(--space-8); }
|
||||
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-6); margin-top: var(--space-12); }
|
||||
.feature-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: var(--radius-xl); padding: var(--space-6); text-align: left; }
|
||||
.feature-icon { width: 40px; height: 40px; background: var(--primary-50); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; color: var(--primary-500); font-size: var(--text-lg); margin-bottom: var(--space-4); }
|
||||
.feature-title { font-size: var(--text-base); font-weight: var(--font-semibold); color: var(--text-primary); margin-bottom: var(--space-2); }
|
||||
.feature-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.6; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">G</div>
|
||||
<h1 class="login-title">Gemold</h1>
|
||||
<p class="login-subtitle">模具制造管理系统</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label">用户名</label>
|
||||
<input v-model="state.username" type="text" class="form-input" placeholder="请输入用户名" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码</label>
|
||||
<input v-model="state.password" type="password" class="form-input" placeholder="请输入密码" required />
|
||||
</div>
|
||||
<div class="form-error" v-if="state.error">{{ state.error }}</div>
|
||||
<button type="submit" class="btn btn-primary w-full mt-4" :disabled="state.loading">
|
||||
{{ state.loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { saveAuth } from '@/shared/auth'
|
||||
import { addNotification } from '@/shared/notification'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const state = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
state.error = ''
|
||||
state.loading = true
|
||||
try {
|
||||
const data = await apiRequest<{ access_token: string; token_type: string; user: Record<string, unknown> }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: state.username, password: state.password }),
|
||||
})
|
||||
saveAuth(data.access_token, data.user)
|
||||
addNotification('登录成功', 'success')
|
||||
router.push('/')
|
||||
} catch (err: any) {
|
||||
state.error = err.message || '登录失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-secondary); padding: var(--space-6); }
|
||||
.login-card { width: 100%; max-width: 400px; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: var(--radius-xl); padding: var(--space-8); }
|
||||
.login-header { text-align: center; margin-bottom: var(--space-8); }
|
||||
.login-logo { width: 48px; height: 48px; background: var(--primary-500); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; color: white; font-size: var(--text-xl); font-weight: var(--font-bold); margin: 0 auto var(--space-4); }
|
||||
.login-title { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--text-primary); margin-bottom: var(--space-2); }
|
||||
.login-subtitle { font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>注塑模 STP 分析</h1>
|
||||
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
|
||||
</div>
|
||||
|
||||
<div class="moldinsight-intro-grid">
|
||||
<div class="intro-card">
|
||||
<div class="intro-card-title">输入</div>
|
||||
<div class="intro-card-text">STEP/STP 产品件,面向注塑模主流程</div>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card-title">输出</div>
|
||||
<div class="intro-card-text">分模方案、DFM 风险、注塑模系统摘要与 CAD 导出</div>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card-title">目标</div>
|
||||
<div class="intro-card-text">先确认推荐方案,再进入导出与 CAM 准备</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-layout">
|
||||
<div class="upload-main-card">
|
||||
<h2 class="section-title">1. 上传产品件</h2>
|
||||
<div
|
||||
:class="['upload-zone', { 'drag-over': state.dragOver }]"
|
||||
@dragover.prevent="state.dragOver = true"
|
||||
@dragleave.prevent="state.dragOver = false"
|
||||
@drop="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".stp,.step"
|
||||
@change="handleFileChange"
|
||||
hidden
|
||||
/>
|
||||
<div class="upload-icon">📁</div>
|
||||
<div class="upload-text">
|
||||
<span class="upload-title">点击选择或拖拽 STP/STEP 文件</span>
|
||||
<span class="upload-hint">支持注塑模产品件分析,最大 100MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state.selectedFile" class="file-info">
|
||||
<span class="file-name">{{ state.selectedFile.name }}</span>
|
||||
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span>
|
||||
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="state.error" class="error-message">{{ state.error }}</div>
|
||||
|
||||
<div v-if="state.polling" class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-side-card">
|
||||
<h2 class="section-title">2. 注塑模参数</h2>
|
||||
<div class="material-panel compact-panel">
|
||||
<div class="form-group">
|
||||
<label class="form-label">产品材料</label>
|
||||
<select v-model="state.selectedMaterial" class="form-select">
|
||||
<option value="ABS">ABS (1.05 g/cm³)</option>
|
||||
<option value="PP">PP (0.90 g/cm³)</option>
|
||||
<option value="PE">PE (0.95 g/cm³)</option>
|
||||
<option value="PC">PC (1.20 g/cm³)</option>
|
||||
<option value="PA">PA (1.14 g/cm³)</option>
|
||||
<option value="POM">POM (1.41 g/cm³)</option>
|
||||
<option value="PMMA">PMMA (1.18 g/cm³)</option>
|
||||
<option value="PBT">PBT (1.31 g/cm³)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<details class="advanced-params">
|
||||
<summary>高级工艺参数</summary>
|
||||
<div class="advanced-params-body">
|
||||
<div class="inline-note">默认值适用于多数注塑件;仅在已知工艺约束时再调整。</div>
|
||||
<div class="advanced-param-grid">
|
||||
<div class="param-input-card" v-for="field in moldParamFields" :key="field.key">
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
<div class="param-input-row">
|
||||
<input
|
||||
type="number"
|
||||
class="form-input param-number-input"
|
||||
v-model.number="state.moldParams[field.key as keyof typeof state.moldParams]"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
/>
|
||||
<span class="param-unit">{{ field.unit }}</span>
|
||||
</div>
|
||||
<div class="param-meta-row">
|
||||
<span class="range-value">默认 {{ field.defaultValue }}{{ field.unit }}</span>
|
||||
<span class="range-value">范围 {{ field.min }} - {{ field.max }}{{ field.unit }}</span>
|
||||
</div>
|
||||
<div class="param-hint">{{ field.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="state.selectedFile"
|
||||
class="btn-primary upload-submit-btn"
|
||||
@click="uploadFile"
|
||||
:disabled="state.uploading || state.polling"
|
||||
>
|
||||
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '3. 开始注塑模分析' }}
|
||||
</button>
|
||||
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始分析。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details v-if="state.history?.files?.length" class="history-collapsible section">
|
||||
<summary class="history-summary">分析历史({{ state.history.files.length }} 个文件)</summary>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>上传次数</th>
|
||||
<th>文件大小</th>
|
||||
<th>最新状态</th>
|
||||
<th>最新分析时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="file in state.history.files" :key="file.filename">
|
||||
<tr>
|
||||
<td>{{ file.filename }}</td>
|
||||
<td>
|
||||
<span class="badge badge-info">{{ file.upload_count }} 次</span>
|
||||
</td>
|
||||
<td>{{ formatFileSize(file.file_size) }}</td>
|
||||
<td>
|
||||
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
|
||||
{{ file.latest_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="viewResult({ task_id: file.latest_task_id })">
|
||||
查看最新
|
||||
</button>
|
||||
<button class="btn-sm btn-icon" @click="toggleFileHistory(file.filename)" :title="state.expandedFiles[file.filename] ? '收起' : '展开历史记录'">
|
||||
<span class="dropdown-icon" :class="{ expanded: state.expandedFiles[file.filename] }">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="state.expandedFiles[file.filename]" class="history-detail-row">
|
||||
<td colspan="6">
|
||||
<div class="history-dropdown">
|
||||
<table class="data-table inner-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>上传时间</th>
|
||||
<th>文件大小</th>
|
||||
<th>状态</th>
|
||||
<th>体积 (mm³)</th>
|
||||
<th>表面积 (mm²)</th>
|
||||
<th>重量 (g)</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in state.expandedFiles[file.filename]" :key="record.id">
|
||||
<td>{{ formatDateTime(record.upload_time) }}</td>
|
||||
<td>{{ formatFileSize(record.file_size) }}</td>
|
||||
<td>
|
||||
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
|
||||
{{ record.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ record.volume ? formatNumber(record.volume) : '-' }}</td>
|
||||
<td>{{ record.surface_area ? formatNumber(record.surface_area) : '-' }}</td>
|
||||
<td>{{ record.product_weight ? record.product_weight.toFixed(2) : '-' }}</td>
|
||||
<td>
|
||||
<button v-if="record.has_analysis" class="btn-sm btn-primary" @click="viewResult(record)">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { handleApiError, addNotification } from '@/shared/notification'
|
||||
import { clearAuth } from '@/shared/auth'
|
||||
import { formatFileSize, formatDateTime, formatNumber } from '@/shared/utils'
|
||||
|
||||
interface HistoryRecord {
|
||||
id: number
|
||||
upload_time: string
|
||||
file_size: number
|
||||
status: string
|
||||
volume?: number
|
||||
surface_area?: number
|
||||
product_weight?: number
|
||||
has_analysis?: boolean
|
||||
task_id?: string
|
||||
}
|
||||
|
||||
interface HistoryFile {
|
||||
filename: string
|
||||
upload_count: number
|
||||
file_size: number
|
||||
latest_status: string
|
||||
latest_upload_time: string
|
||||
latest_task_id?: string
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
files: HistoryFile[]
|
||||
}
|
||||
|
||||
interface MoldParams {
|
||||
draftAngle: number
|
||||
shrinkageRate: number
|
||||
partingPrecision: number
|
||||
cavityMatch: number
|
||||
}
|
||||
|
||||
interface TaskInfo {
|
||||
task_id: string
|
||||
status: string
|
||||
filename?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const state = reactive({
|
||||
selectedFile: null as File | null,
|
||||
selectedMaterial: 'ABS',
|
||||
moldParams: {
|
||||
draftAngle: 2.0,
|
||||
shrinkageRate: 0.5,
|
||||
partingPrecision: 0.1,
|
||||
cavityMatch: 95,
|
||||
} as MoldParams,
|
||||
uploading: false,
|
||||
error: '',
|
||||
currentTask: null as TaskInfo | null,
|
||||
task: null as TaskInfo | null,
|
||||
polling: false,
|
||||
dragOver: false,
|
||||
progress: 0,
|
||||
history: null as HistoryData | null,
|
||||
expandedFiles: {} as Record<string, HistoryRecord[] | null>,
|
||||
})
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
state.history = await apiRequest<HistoryData>('/api/history')
|
||||
} catch (e) {
|
||||
console.error('加载历史记录失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFileHistory = async (filename: string) => {
|
||||
if (state.expandedFiles[filename]) {
|
||||
state.expandedFiles[filename] = null
|
||||
} else {
|
||||
try {
|
||||
const records = await apiRequest<HistoryRecord[]>(`/api/history/${encodeURIComponent(filename)}`)
|
||||
state.expandedFiles[filename] = records
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载文件历史')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewResult = (record: { task_id?: string }) => {
|
||||
router.push(`/moldinsight/result/${record.task_id}`)
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
validateAndSelectFile(file)
|
||||
}
|
||||
|
||||
const validateAndSelectFile = (file: File) => {
|
||||
const lowerName = file.name.toLowerCase()
|
||||
if (!lowerName.endsWith('.stp') && !lowerName.endsWith('.step')) {
|
||||
state.error = '请选择 STP 或 STEP 格式文件'
|
||||
state.selectedFile = null
|
||||
return
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
state.error = '文件大小不能超过 100MB'
|
||||
state.selectedFile = null
|
||||
return
|
||||
}
|
||||
state.error = ''
|
||||
state.selectedFile = file
|
||||
addNotification(`已选择文件: ${file.name}`, 'success')
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
state.dragOver = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files && files.length > 0) validateAndSelectFile(files[0])
|
||||
}
|
||||
|
||||
const uploadFile = async () => {
|
||||
if (!state.selectedFile) return
|
||||
|
||||
if (!appStore.token) {
|
||||
state.error = '请先登录后再上传文件'
|
||||
addNotification('请先登录', 'warning')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (appStore.token === 'demo') {
|
||||
state.error = '演示模式不支持文件上传,请使用完整账户登录'
|
||||
addNotification('演示模式不支持上传', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
state.uploading = true
|
||||
state.error = ''
|
||||
state.progress = 0
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', state.selectedFile)
|
||||
formData.append('material', state.selectedMaterial)
|
||||
formData.append('draft_angle', String(state.moldParams.draftAngle))
|
||||
formData.append('shrinkage_rate', String(state.moldParams.shrinkageRate))
|
||||
formData.append('parting_precision', String(state.moldParams.partingPrecision))
|
||||
formData.append('cavity_match', String(state.moldParams.cavityMatch))
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${appStore.token}` },
|
||||
body: formData,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearAuth()
|
||||
state.error = '登录已过期,请重新登录'
|
||||
addNotification('登录已过期,请重新登录', 'warning')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
|
||||
const data = await res.json()
|
||||
state.currentTask = { task_id: data.task_id, status: 'processing', filename: data.file_info?.filename }
|
||||
addNotification('文件上传成功,开始分析...', 'success')
|
||||
startPolling(data.task_id)
|
||||
} catch (e) {
|
||||
state.error = handleApiError(e, '文件上传')
|
||||
} finally {
|
||||
state.uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = async (taskId: string) => {
|
||||
console.log('[Polling] 开始轮询任务:', taskId)
|
||||
state.polling = true
|
||||
state.progress = 10
|
||||
let pollCount = 0
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
pollCount++
|
||||
state.progress = Math.min(90, 10 + pollCount * 0.5)
|
||||
|
||||
const task = await apiRequest<TaskInfo>(`/api/status/${taskId}`, { method: 'POST' })
|
||||
state.currentTask = task
|
||||
state.task = task
|
||||
|
||||
if (task.status === 'completed') {
|
||||
state.polling = false
|
||||
state.progress = 100
|
||||
addNotification('分析完成', 'success')
|
||||
loadHistory()
|
||||
router.push(`/moldinsight/result/${taskId}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === 'failed') {
|
||||
state.polling = false
|
||||
state.error = task.error || '分析失败'
|
||||
addNotification('分析失败', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (pollCount < 300) setTimeout(poll, 2000)
|
||||
} catch (e) {
|
||||
state.polling = false
|
||||
state.error = handleApiError(e, '轮询状态')
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
const moldParamFields = [
|
||||
{
|
||||
key: 'draftAngle',
|
||||
label: '拔模角',
|
||||
unit: '°',
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
defaultValue: 2.0,
|
||||
hint: '常规注塑件建议从 1.5° 到 3° 起步',
|
||||
},
|
||||
{
|
||||
key: 'shrinkageRate',
|
||||
label: '收缩率',
|
||||
unit: '%',
|
||||
min: 0.5,
|
||||
max: 3.0,
|
||||
step: 0.1,
|
||||
defaultValue: 0.5,
|
||||
hint: '按材料牌号校核,默认值用于首轮方案评估',
|
||||
},
|
||||
{
|
||||
key: 'partingPrecision',
|
||||
label: '分型精度',
|
||||
unit: 'mm',
|
||||
min: 0.01,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
defaultValue: 0.1,
|
||||
hint: '用于控制分型面拟合与边界容差',
|
||||
},
|
||||
{
|
||||
key: 'cavityMatch',
|
||||
label: '型腔匹配度',
|
||||
unit: '%',
|
||||
min: 80,
|
||||
max: 100,
|
||||
step: 1,
|
||||
defaultValue: 95,
|
||||
hint: '数值越高越偏向紧配合与严格封合',
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (!appStore.user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
loadHistory()
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>管理系统用户和权限</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openUserModal()">+ 添加用户</button>
|
||||
</div>
|
||||
|
||||
<div v-if="state.loading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>姓名</th>
|
||||
<th>状态</th>
|
||||
<th>角色</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in state.users" :key="user.id">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.full_name || '-' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.is_active ? 'badge-success' : 'badge-error']">
|
||||
{{ user.is_active ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-for="role in (user as any).roles" :key="role" class="badge badge-info" style="margin-right: 4px;">
|
||||
{{ role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(user.created_at) }}</td>
|
||||
<td>
|
||||
<div class="action-buttons" style="display:flex; gap:4px;">
|
||||
<button class="btn btn-sm" style="background:var(--primary-500);color:white;" @click="openUserModal(user)">编辑</button>
|
||||
<button class="btn btn-sm" style="background:#eab308;color:white;" @click="resetPassword(user)">重置密码</button>
|
||||
<button v-if="(user as any).id !== storeUser?.id" class="btn btn-sm" style="background:var(--error);color:white;" @click="deleteUser(user)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="state.showUserModal" class="modal-overlay" @click.self="state.showUserModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ state.editingUser ? '编辑用户' : '添加用户' }}</h2>
|
||||
<button class="modal-close" @click="state.showUserModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">用户名</label>
|
||||
<input v-model="state.userForm.username" type="text" class="form-input" :disabled="!!state.editingUser" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">邮箱</label>
|
||||
<input v-model="state.userForm.email" type="email" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group" v-if="!state.editingUser">
|
||||
<label class="form-label">密码</label>
|
||||
<input v-model="state.userForm.password" type="password" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">姓名</label>
|
||||
<input v-model="state.userForm.full_name" type="text" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">角色</label>
|
||||
<div class="checkbox-group" style="flex-direction: column; gap: 8px;">
|
||||
<label v-for="role in state.roles" :key="(role as any).id" class="checkbox-label">
|
||||
<input type="checkbox" :value="(role as any).id" v-model="state.userForm.role_ids" class="checkbox-input" />
|
||||
{{ (role as any).name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="state.showUserModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="saveUser">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { addNotification } from '@/shared/notification'
|
||||
import { formatDateTime } from '@/shared/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const storeUser = store.user
|
||||
|
||||
interface UserItem {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string | null
|
||||
is_active: boolean
|
||||
roles: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface RoleItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
users: [] as UserItem[],
|
||||
roles: [] as RoleItem[],
|
||||
loading: true,
|
||||
showUserModal: false,
|
||||
editingUser: null as UserItem | null,
|
||||
userForm: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role_ids: [] as number[],
|
||||
},
|
||||
})
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
state.users = await apiRequest<UserItem[]>('/api/auth/users')
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
addNotification(err.message || '加载用户列表失败', 'error')
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
state.roles = await apiRequest<RoleItem[]>('/api/auth/roles')
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
addNotification(err.message || '加载角色列表失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const openUserModal = (user: UserItem | null = null) => {
|
||||
state.editingUser = user
|
||||
if (user) {
|
||||
state.userForm = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
full_name: user.full_name || '',
|
||||
role_ids: user.roles
|
||||
.map(r => {
|
||||
const role = state.roles.find(role => role.code === r)
|
||||
return role ? role.id : null
|
||||
})
|
||||
.filter(id => id !== null) as number[],
|
||||
}
|
||||
} else {
|
||||
state.userForm = { username: '', email: '', password: '', full_name: '', role_ids: [] }
|
||||
}
|
||||
state.showUserModal = true
|
||||
}
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!state.userForm.username || !state.userForm.email) {
|
||||
addNotification('请填写用户名和邮箱', 'error')
|
||||
return
|
||||
}
|
||||
if (!state.editingUser && !state.userForm.password) {
|
||||
addNotification('请填写密码', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.editingUser) {
|
||||
await apiRequest(`/api/auth/users/${(state.editingUser as UserItem).id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
email: state.userForm.email,
|
||||
full_name: state.userForm.full_name || null,
|
||||
role_ids: state.userForm.role_ids,
|
||||
}),
|
||||
})
|
||||
addNotification('用户更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(state.userForm),
|
||||
})
|
||||
addNotification('用户创建成功', 'success')
|
||||
}
|
||||
state.showUserModal = false
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
addNotification(err.message || '保存用户失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async (user: UserItem) => {
|
||||
if (!confirm(`确定要删除用户 ${user.username} 吗?`)) return
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/auth/users/${user.id}`, { method: 'DELETE' })
|
||||
addNotification('用户已删除', 'success')
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
addNotification(err.message || '删除用户失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (user: UserItem) => {
|
||||
const newPassword = prompt(`请输入 ${user.username} 的新密码:`)
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
addNotification('密码长度至少6位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newPassword),
|
||||
})
|
||||
addNotification('密码已重置', 'success')
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
addNotification(err.message || '重置密码失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.user?.is_superuser) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
await loadRoles()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: () => import('@/modules/home/HomeView.vue') },
|
||||
{ path: '/login', component: () => import('@/modules/login/LoginView.vue') },
|
||||
{ path: '/users', component: () => import('@/modules/users/UsersView.vue') },
|
||||
{ path: '/moldinsight', component: () => import('@/modules/moldinsight/MoldInsightView.vue') },
|
||||
{ path: '/moldinsight/result/:taskId', component: () => import('@/modules/moldinsight/ResultView.vue') },
|
||||
{ path: '/inventory', component: () => import('@/modules/inventory/InventoryView.vue') },
|
||||
{ path: '/_design-system', component: () => import('@/design-system/DesignSystemView.vue') },
|
||||
{ path: '/_release', component: () => import('@/design-system/ReleaseView.vue') },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const publicPages = ['/login', '/_design-system', '/_release']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
const store = useAppStore()
|
||||
|
||||
if (authRequired && !store.user) {
|
||||
return next('/login')
|
||||
}
|
||||
|
||||
if (to.path === '/login' && store.user) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
async function parseErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await response.json().catch(() => null)
|
||||
if (body?.detail) {
|
||||
if (Array.isArray(body.detail)) {
|
||||
const lines = body.detail
|
||||
.map((e: { loc?: string[]; msg?: string }) => {
|
||||
const loc = Array.isArray(e?.loc) ? e.loc.join('.') : ''
|
||||
const msg = e?.msg ? String(e.msg) : '校验失败'
|
||||
return loc ? `${loc}: ${msg}` : msg
|
||||
})
|
||||
.filter(Boolean)
|
||||
return lines.length ? lines.join('\n') : '请求校验失败'
|
||||
}
|
||||
if (typeof body.detail === 'object') return JSON.stringify(body.detail)
|
||||
return String(body.detail)
|
||||
}
|
||||
if (body?.message) return String(body.message)
|
||||
return '请求失败'
|
||||
}
|
||||
const text = await response.text().catch(() => '')
|
||||
const normalized = (text || '').trim()
|
||||
if (!normalized) return '请求失败'
|
||||
return normalized.length > 200 ? normalized.slice(0, 200) + '...' : normalized
|
||||
} catch {
|
||||
return '请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T = any>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const store = useAppStore()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
}
|
||||
|
||||
if (store.token) {
|
||||
headers['Authorization'] = `Bearer ${store.token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers })
|
||||
|
||||
if (response.status === 401) {
|
||||
store.user = null
|
||||
store.token = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseErrorMessage(response)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export function saveAuth(token: string, user: Record<string, unknown>): void {
|
||||
const store = useAppStore()
|
||||
store.token = token
|
||||
store.user = user as any
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
const store = useAppStore()
|
||||
store.token = null
|
||||
store.user = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
export function initAuth(): void {
|
||||
const store = useAppStore()
|
||||
const token = localStorage.getItem('token')
|
||||
const userStr = localStorage.getItem('user')
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
store.token = token
|
||||
store.user = JSON.parse(userStr)
|
||||
} catch {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
store.initialized = true
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useAppStore, type Notification } from '@/stores/app'
|
||||
|
||||
let notificationId = 0
|
||||
const notificationDedup = new Map<string, number>()
|
||||
|
||||
export function addNotification(message: string, type: Notification['type'] = 'info'): void {
|
||||
const dedupKey = `${type}:${message}`
|
||||
const now = Date.now()
|
||||
const lastAt = notificationDedup.get(dedupKey) || 0
|
||||
if (now - lastAt < 2500) return
|
||||
notificationDedup.set(dedupKey, now)
|
||||
|
||||
const store = useAppStore()
|
||||
const id = ++notificationId
|
||||
const notification: Notification = { id, message, type, timestamp: new Date(), visible: true }
|
||||
store.notifications.push(notification)
|
||||
|
||||
setTimeout(() => {
|
||||
const notification = store.notifications.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.visible = false
|
||||
setTimeout(() => {
|
||||
store.dismissNotification(id)
|
||||
}, 300)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
export function handleApiError(error: unknown, context = ''): string {
|
||||
console.error(`API错误 [${context}]:`, error)
|
||||
const message = error instanceof Error ? error.message : '请求失败,请稍后重试'
|
||||
addNotification(message, 'error')
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function formatNumber(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return 'N/A'
|
||||
if (num >= 1_000_000) return (num / 1_000_000).toFixed(2) + 'M'
|
||||
if (num >= 1_000) return (num / 1_000).toFixed(2) + 'K'
|
||||
return num.toFixed ? num.toFixed(2) : String(num)
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string | null | undefined): string {
|
||||
if (!dateString) return 'N/A'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) return dateString
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string | null | undefined): string {
|
||||
if (!dateString) return 'N/A'
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number | null | undefined): string {
|
||||
if (amount === null || amount === undefined) return '¥0.00'
|
||||
return '¥' + Number(amount).toFixed(2)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface Notification {
|
||||
id: number
|
||||
message: string
|
||||
type: 'info' | 'success' | 'error' | 'warning'
|
||||
timestamp: Date
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface AppUser {
|
||||
id: number
|
||||
username: string
|
||||
full_name: string | null
|
||||
is_superuser: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const user = ref<AppUser | null>(null)
|
||||
const token = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
const initialized = ref(false)
|
||||
|
||||
function dismissNotification(id: number) {
|
||||
const index = notifications.value.findIndex(n => n.id === id)
|
||||
if (index > -1) notifications.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
notifications,
|
||||
initialized,
|
||||
dismissNotification,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user