前端重构 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,
},
},
},
})
+18
View File
@@ -226,6 +226,24 @@ async def users():
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/moldinsight/result/{task_id:path}")
async def moldinsight_result(task_id: str):
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/_design-system")
async def design_system():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/_release")
async def release():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
if __name__ == "__main__":
import uvicorn
from config.settings import settings
@@ -0,0 +1 @@
import{E as e,_ as t,t as n,y as r}from"./index-DkoUSxK0.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`设计体系`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
+1
View File
@@ -0,0 +1 @@
.hero-section[data-v-a374362f]{text-align:center;padding:var(--space-16) 0}.hero-title[data-v-a374362f]{font-size:var(--text-4xl);font-weight:var(--font-bold);color:var(--text-primary);margin-bottom:var(--space-4)}.hero-subtitle[data-v-a374362f]{font-size:var(--text-lg);color:var(--text-tertiary);max-width:600px;margin:0 auto var(--space-8)}.features-grid[data-v-a374362f]{gap:var(--space-6);margin-top:var(--space-12);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.feature-card[data-v-a374362f]{background:var(--bg-primary);border:1px solid var(--border-light);border-radius:var(--radius-xl);padding:var(--space-6);text-align:left}.feature-icon[data-v-a374362f]{background:var(--primary-50);border-radius:var(--radius-lg);width:40px;height:40px;color:var(--primary-500);font-size:var(--text-lg);margin-bottom:var(--space-4);justify-content:center;align-items:center;display:flex}.feature-title[data-v-a374362f]{font-size:var(--text-base);font-weight:var(--font-semibold);color:var(--text-primary);margin-bottom:var(--space-2)}.feature-desc[data-v-a374362f]{font-size:var(--text-sm);color:var(--text-tertiary);line-height:1.6}
+1
View File
@@ -0,0 +1 @@
import{E as e,b as t,t as n,y as r}from"./index-DkoUSxK0.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`<div class="hero-section" data-v-a374362f><h1 class="hero-title" data-v-a374362f>Gemold 模具制造管理系统</h1><p class="hero-subtitle" data-v-a374362f>智能模具设计与制造一体化平台,从零件分析到模具生成,全流程数字化解决方案</p></div><div class="features-grid" data-v-a374362f><div class="feature-card" data-v-a374362f><div class="feature-icon" data-v-a374362f>◈</div><h3 class="feature-title" data-v-a374362f>MoldInsight</h3><p class="feature-desc" data-v-a374362f>智能模具分析引擎,支持 STP/STEP 文件解析、分模设计、CAM 刀路规划</p></div><div class="feature-card" data-v-a374362f><div class="feature-icon" data-v-a374362f>⊞</div><h3 class="feature-title" data-v-a374362f>进销存管理</h3><p class="feature-desc" data-v-a374362f>产品管理、采购销售、库存管理、财务管理,一站式 ERP 解决方案</p></div><div class="feature-card" data-v-a374362f><div class="feature-icon" data-v-a374362f>⚙</div><h3 class="feature-title" data-v-a374362f>AI 驱动</h3><p class="feature-desc" data-v-a374362f>AI 分型面检测、模具方案评分、智能设计建议,提升设计效率</p></div></div>`,2)]])}var o=n(i,[[`render`,a],[`__scopeId`,`data-v-a374362f`]]);export{o as default};
+1
View File
@@ -0,0 +1 @@
.mb-12[data-v-15993ef5]{margin-bottom:12px}.mb-16[data-v-15993ef5]{margin-bottom:16px}.mt-16[data-v-15993ef5]{margin-top:16px}.mr-8[data-v-15993ef5]{margin-right:8px}.text-secondary[data-v-15993ef5]{color:var(--text-secondary)}.flex-row-center[data-v-15993ef5]{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.w-260[data-v-15993ef5]{width:260px}.w-inline-120[data-v-15993ef5]{width:120px;display:inline-block}.w-inline-140[data-v-15993ef5]{width:140px;display:inline-block}.info-display[data-v-15993ef5]{background:var(--bg-secondary);padding:8px 12px}.form-group-spaced[data-v-15993ef5]{margin-bottom:var(--space-4)}.bom-add-btn[data-v-15993ef5]{margin-bottom:8px}.btn-disabled[data-v-15993ef5]{opacity:.5;cursor:not-allowed}.datetime-field[data-v-15993ef5]{cursor:pointer;position:relative}.air-datetime-input[data-v-15993ef5]{cursor:pointer;background:var(--bg-primary)}.datetime-native[data-v-15993ef5]{opacity:0;cursor:pointer;width:100%;position:absolute;inset:0}.datetime-field-icon[data-v-15993ef5]{pointer-events:none;font-size:1rem;position:absolute;top:50%;right:10px;transform:translateY(-50%)}.required[data-v-15993ef5]{color:var(--error);font-weight:var(--font-bold)}.sales-order-items[data-v-15993ef5]{flex-direction:column;gap:16px;display:flex}.sales-order-item[data-v-15993ef5]{border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}.sales-order-item-header[data-v-15993ef5]{background:var(--bg-tertiary);border-bottom:1px solid var(--border-default);gap:0;display:flex}.sales-order-item-header .btn[data-v-15993ef5]{color:var(--text-secondary);background:0 0;border:none;border-radius:0}.sales-order-item-header .btn.active[data-v-15993ef5]{background:var(--primary-500);color:#fff}.sales-order-item-content[data-v-15993ef5]{padding:12px 16px}.sales-order-item-row[data-v-15993ef5]{flex-wrap:wrap;align-items:flex-end;gap:12px;display:flex}.sales-order-item-column[data-v-15993ef5]{flex:1;min-width:120px}.sales-order-item-actions[data-v-15993ef5]{align-items:flex-end;padding-bottom:4px;display:flex}.purchase-order-items[data-v-15993ef5]{flex-direction:column;gap:16px;display:flex}.purchase-order-item[data-v-15993ef5]{border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}.purchase-order-item-content[data-v-15993ef5]{padding:12px 16px}.purchase-order-item-row[data-v-15993ef5]{flex-wrap:wrap;align-items:flex-end;gap:12px;display:flex}.purchase-order-item-column[data-v-15993ef5]{flex:1;min-width:120px}.purchase-order-item-actions[data-v-15993ef5]{align-items:flex-end;padding-bottom:4px;display:flex}.material-consumption-list[data-v-15993ef5]{border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}.material-consumption-header[data-v-15993ef5]{background:var(--bg-tertiary);font-weight:var(--font-semibold);border-bottom:1px solid var(--border-default);display:flex}.material-consumption-row[data-v-15993ef5]{border-bottom:1px solid var(--border-light);display:flex}.material-consumption-row[data-v-15993ef5]:last-child{border-bottom:none}.material-consumption-total[data-v-15993ef5]{background:var(--bg-tertiary);font-weight:var(--font-bold);border-top:1px solid var(--border-default);display:flex}.material-consumption-item[data-v-15993ef5]{flex:1;min-width:80px;padding:8px 12px}.bom-section[data-v-15993ef5]{border-top:1px solid var(--border-default);margin-top:12px;padding-top:12px}.form-section[data-v-15993ef5]{margin-bottom:16px}.section-header[data-v-15993ef5]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.restock-modal[data-v-15993ef5]{max-width:900px}.restock-col-material[data-v-15993ef5]{flex:2}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.login-container[data-v-792eed9c]{background:var(--bg-secondary);min-height:100vh;padding:var(--space-6);justify-content:center;align-items:center;display:flex}.login-card[data-v-792eed9c]{background:var(--bg-primary);border:1px solid var(--border-light);border-radius:var(--radius-xl);width:100%;max-width:400px;padding:var(--space-8)}.login-header[data-v-792eed9c]{text-align:center;margin-bottom:var(--space-8)}.login-logo[data-v-792eed9c]{background:var(--primary-500);border-radius:var(--radius-lg);color:#fff;width:48px;height:48px;font-size:var(--text-xl);font-weight:var(--font-bold);margin:0 auto var(--space-4);justify-content:center;align-items:center;display:flex}.login-title[data-v-792eed9c]{font-size:var(--text-2xl);font-weight:var(--font-bold);color:var(--text-primary);margin-bottom:var(--space-2)}.login-subtitle[data-v-792eed9c]{font-size:var(--text-sm);color:var(--text-tertiary)}
+1
View File
@@ -0,0 +1 @@
import{E as e,O as t,P as n,S as r,_ as i,a,f as o,k as s,l as c,m as l,n as u,o as d,t as f,v as p,y as m}from"./index-DkoUSxK0.js";var h={class:`login-container`},g={class:`login-card`},_={class:`form-group`},v={class:`form-group`},y={key:0,class:`form-error`},b=[`disabled`],x=f(r({__name:`LoginView`,setup(r){let f=c(),x=s({username:``,password:``,error:``,loading:!1}),S=async()=>{x.error=``,x.loading=!0;try{let e=await d(`/api/auth/login`,{method:`POST`,body:JSON.stringify({username:x.username,password:x.password})});a(e.access_token,e.user),u(`登录成功`,`success`),f.push(`/`)}catch(e){x.error=e.message||`登录失败`}finally{x.loading=!1}};return(r,a)=>(e(),m(`div`,h,[i(`div`,g,[a[4]||=i(`div`,{class:`login-header`},[i(`div`,{class:`login-logo`},`G`),i(`h1`,{class:`login-title`},`Gemold`),i(`p`,{class:`login-subtitle`},`模具制造管理系统`)],-1),i(`form`,{onSubmit:l(S,[`prevent`])},[i(`div`,_,[a[2]||=i(`label`,{class:`form-label`},`用户名`,-1),t(i(`input`,{"onUpdate:modelValue":a[0]||=e=>x.username=e,type:`text`,class:`form-input`,placeholder:`请输入用户名`,required:``},null,512),[[o,x.username]])]),i(`div`,v,[a[3]||=i(`label`,{class:`form-label`},`密码`,-1),t(i(`input`,{"onUpdate:modelValue":a[1]||=e=>x.password=e,type:`password`,class:`form-input`,placeholder:`请输入密码`,required:``},null,512),[[o,x.password]])]),x.error?(e(),m(`div`,y,n(x.error),1)):p(``,!0),i(`button`,{type:`submit`,class:`btn btn-primary w-full mt-4`,disabled:x.loading},n(x.loading?`登录中...`:`登录`),9,b)],32)])]))}}),[[`__scopeId`,`data-v-792eed9c`]]);export{x as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{E as e,_ as t,t as n,y as r}from"./index-DkoUSxK0.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`灰度发布与回滚`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function e(e){if(!e||e===0)return`0 B`;let t=1024,n=[`B`,`KB`,`MB`,`GB`],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/t**r).toFixed(2))+` `+n[r]}function t(e){return e==null?`N/A`:e>=1e6?(e/1e6).toFixed(2)+`M`:e>=1e3?(e/1e3).toFixed(2)+`K`:e.toFixed?e.toFixed(2):String(e)}function n(e){if(!e)return`N/A`;try{let t=new Date(e);return isNaN(t.getTime())?e:`${t.getFullYear()}/${String(t.getMonth()+1).padStart(2,`0`)}/${String(t.getDate()).padStart(2,`0`)} ${String(t.getHours()).padStart(2,`0`)}:${String(t.getMinutes()).padStart(2,`0`)}:${String(t.getSeconds()).padStart(2,`0`)}`}catch{return e}}function r(e){if(!e)return`N/A`;try{return new Date(e).toLocaleDateString(`zh-CN`)}catch{return e}}function i(e){return e==null?`¥0.00`:`¥`+Number(e).toFixed(2)}export{t as a,e as i,r as n,n as r,i as t};
+5 -11
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gemold - 模具制造管理系统</title>
<script>
(function () {
@@ -61,8 +61,8 @@
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.css">
<link rel="stylesheet" href="/static/style.css?v=20260419-01">
<script type="module" crossorigin src="/assets/index-DkoUSxK0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CpCSYLtb.css">
</head>
<body>
<div id="app">
@@ -73,11 +73,5 @@
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.js"></script>
<script src="/static/vue-app.js?v=20260506-01"></script>
</body>
</html>
-3476
View File
File diff suppressed because it is too large Load Diff
-5282
View File
File diff suppressed because it is too large Load Diff