前端重构 Vite

This commit is contained in:
2026-05-29 17:30:26 +08:00
parent cbd5d98993
commit 5bb9bc84ea
44 changed files with 7289 additions and 8769 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gemold - 模具制造管理系统</title>
<script>
(function () {
var params = new URLSearchParams(location.search);
var uiParam = params.get('ui');
var themeParam = params.get('theme');
var storedUi = null;
try { storedUi = localStorage.getItem('gemold_ui_version'); } catch (e) {}
var storedPercent = null;
try { storedPercent = localStorage.getItem('gemold_ui_rollout_percent'); } catch (e) {}
var percent = storedPercent ? Number(storedPercent) : 0;
if (!Number.isFinite(percent) || percent < 0) percent = 0;
if (percent > 100) percent = 100;
var uid = null;
try { uid = localStorage.getItem('gemold_uid'); } catch (e) {}
if (!uid) {
uid = 'u_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
try { localStorage.setItem('gemold_uid', uid); } catch (e) {}
}
function hash32(str) {
var h = 2166136261;
for (var i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function chooseUiVersion() {
if (uiParam === 'v1' || uiParam === 'v2') return uiParam;
if (storedUi === 'v1' || storedUi === 'v2') return storedUi;
if (percent <= 0) return 'v1';
var bucket = hash32(uid) % 100;
return bucket < percent ? 'v2' : 'v1';
}
function chooseTheme() {
var storedTheme = null;
try { storedTheme = localStorage.getItem('gemold_theme'); } catch (e) {}
if (themeParam === 'light' || themeParam === 'dark') return themeParam;
if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme;
return 'light';
}
var ui = chooseUiVersion();
var theme = chooseTheme();
document.documentElement.dataset.ui = ui;
document.documentElement.dataset.theme = theme;
try {
if (uiParam === 'v1' || uiParam === 'v2') localStorage.setItem('gemold_ui_version', uiParam);
if (themeParam === 'light' || themeParam === 'dark') localStorage.setItem('gemold_theme', themeParam);
} catch (e) {}
})();
</script>
</head>
<body>
<div id="app">
<div class="loading-overlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<p style="margin-top: 16px; color: var(--text-secondary);">加载中...</p>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1453
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"air-datepicker": "^3.6.0",
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
+252
View File
@@ -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>
+394
View File
@@ -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>
+10
View File
@@ -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')
+36
View File
@@ -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
+69
View File
@@ -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
+259
View File
@@ -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>
+34
View File
@@ -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
+61
View File
@@ -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()
}
+33
View File
@@ -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
}
+34
View File
@@ -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
}
+45
View File
@@ -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)
}
+40
View File
@@ -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,
}
})
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"ignoreDeprecations": "6.0",
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: '../static',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})