Przeglądaj źródła

框架重构,代码优化

Gogs 1 miesiąc temu
rodzic
commit
af968c22a7

+ 2 - 2
apps/factory-app/src/App.vue

@@ -55,8 +55,8 @@ const showLayout = computed(() => {
 });
 
 // 登出处理
-function handleLogout() {
-  authStore.logout();
+async function handleLogout() {
+  await authStore.logout();
   message.success('已退出登录');
   router.push('/login');
 }

+ 5 - 1
apps/factory-app/src/stores/auth.ts

@@ -59,7 +59,11 @@ export const useAuthStore = defineStore('auth', () => {
   /**
    * 登出
    */
-  function logout() {
+  async function logout() {
+    // P1: best-effort 调后端撤销 jti,失败不阻塞本地登出
+    try {
+      await authApi.logout();
+    } catch (e) { /* 忽略 */ }
     token.value = null;
     user.value = null;
     currentFactoryId.value = null;

+ 3 - 1
apps/factory-app/src/views/LoginView.vue

@@ -124,7 +124,9 @@ async function handleLogin() {
     message.success('登录成功');
 
     const redirect = route.query.redirect as string;
-    router.push(redirect || '/');
+    // P2: redirect 仅允许相对路径,防开放重定向
+    const safeRedirect = redirect && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/';
+    router.push(safeRedirect);
   } catch (error: any) {
     const errorMsg = error.response?.data?.msg || '登录失败';
     message.error(errorMsg);

+ 6 - 0
apps/platform-app/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 6 - 8
apps/platform-app/src/App.vue

@@ -1,13 +1,12 @@
 <template>
   <a-config-provider :locale="zhCN">
-    <SidebarLayout
+    <BasicLayout
       v-if="showLayout"
       :userName="authStore.user?.name"
-      :currentPath="route.path"
       @logout="handleLogout"
     >
       <router-view />
-    </SidebarLayout>
+    </BasicLayout>
     <router-view v-else />
   </a-config-provider>
 </template>
@@ -18,7 +17,7 @@ import { useRouter, useRoute } from 'vue-router';
 import { message } from 'ant-design-vue';
 import zhCN from 'ant-design-vue/es/locale/zh_CN';
 import { DashboardOutlined, ShopOutlined, UserOutlined } from '@ant-design/icons-vue';
-import { SidebarLayout } from '@smartcut/shared-components';
+import BasicLayout from '@/layouts/BasicLayout.vue';
 import { useAuthStore } from '@/stores/auth';
 import { useLayoutStore } from '@/stores/layout';
 
@@ -34,8 +33,7 @@ onMounted(() => {
       { key: 'dashboard', label: '主页看板', icon: DashboardOutlined, path: '/' },
       { key: 'factories', label: '工厂管理', icon: ShopOutlined, path: '/factories' },
       { key: 'users', label: '系统用户', icon: UserOutlined, path: '/users' }
-    ],
-    theme: 'dark'
+    ]
   });
 });
 
@@ -45,8 +43,8 @@ const showLayout = computed(() => {
 });
 
 // 登出处理
-function handleLogout() {
-  authStore.logout();
+async function handleLogout() {
+  await authStore.logout();
   message.success('已退出登录');
   router.push('/login');
 }

+ 27 - 0
apps/platform-app/src/components/Breadcrumb.vue

@@ -0,0 +1,27 @@
+<template>
+  <a-breadcrumb class="breadcrumb-nav">
+    <a-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index">
+      <span :class="{ 'font-semibold text-gray-800': index === breadcrumbs.length - 1 }">
+        {{ item }}
+      </span>
+    </a-breadcrumb-item>
+  </a-breadcrumb>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRoute } from 'vue-router';
+
+const route = useRoute();
+
+const breadcrumbs = computed(() => {
+  const breadcrumb = route.meta.breadcrumb as string[] | undefined;
+  return breadcrumb || ['首页'];
+});
+</script>
+
+<style scoped>
+.breadcrumb-nav {
+  font-size: 14px;
+}
+</style>

+ 55 - 0
apps/platform-app/src/components/HeaderBar.vue

@@ -0,0 +1,55 @@
+<template>
+  <header class="bg-white shadow-sm h-16 flex items-center justify-between px-6 flex-shrink-0 border-b border-gray-100">
+    <!-- 左侧:Logo + 折叠按钮 -->
+    <div class="flex items-center">
+      <!-- 品牌Logo -->
+      <h1 class="text-xl font-bold bg-gradient-to-r from-blue-500 to-blue-700 bg-clip-text text-transparent mr-6">
+        智裁云
+      </h1>
+      <!-- 折叠按钮 -->
+      <a-button
+        type="text"
+        @click="$emit('toggle')"
+        class="flex items-center justify-center hover:bg-gray-100 transition-colors duration-200"
+      >
+        <MenuUnfoldOutlined v-if="collapsed" class="text-lg text-gray-600" />
+        <MenuFoldOutlined v-else class="text-lg text-gray-600" />
+      </a-button>
+    </div>
+
+    <!-- 中间:面包屑导航 -->
+    <div class="flex-1 flex justify-center">
+      <Breadcrumb />
+    </div>
+
+    <!-- 右侧:通知 + 用户信息 -->
+    <div class="flex items-center space-x-4">
+      <!-- 通知图标 -->
+      <a-badge :count="0" :dot="false">
+        <BellOutlined class="text-xl text-gray-600 cursor-pointer hover:text-blue-500 transition-colors duration-200" />
+      </a-badge>
+
+      <!-- 分隔线 -->
+      <div class="h-6 w-px bg-gray-200"></div>
+
+      <!-- 用户信息 -->
+      <UserMenu :userName="userName" @logout="$emit('logout')" />
+    </div>
+  </header>
+</template>
+
+<script setup lang="ts">
+import { MenuFoldOutlined, MenuUnfoldOutlined, BellOutlined } from '@ant-design/icons-vue';
+import Breadcrumb from './Breadcrumb.vue';
+import UserMenu from './UserMenu.vue';
+
+defineProps<{
+  collapsed: boolean;
+  userName?: string;
+}>();
+
+defineEmits<{
+  toggle: [];
+  logout: [];
+}>();
+</script>

+ 59 - 0
apps/platform-app/src/components/SideMenu.vue

@@ -0,0 +1,59 @@
+<template>
+  <aside
+    class="bg-white border-r border-gray-200 transition-all duration-300 flex-shrink-0"
+    :style="{ width: collapsed ? '64px' : '256px' }"
+  >
+    <nav class="py-4">
+      <router-link
+        v-for="item in menuItems"
+        :key="item.key"
+        :to="item.path"
+        class="menu-item flex items-center px-6 py-3 transition-all duration-200 relative"
+        :class="{
+          'active': isActive(item.path),
+          'text-gray-600 hover:bg-gray-50 hover:text-gray-900': !isActive(item.path)
+        }"
+      >
+        <!-- 激活状态左侧高亮条 -->
+        <span v-if="isActive(item.path)" class="absolute left-0 top-0 bottom-0 w-1 bg-blue-500"></span>
+        <!-- 图标 -->
+        <component :is="item.icon" class="text-xl flex-shrink-0" />
+        <!-- 文字(折叠时隐藏) -->
+        <span v-if="!collapsed" class="ml-3 font-medium">{{ item.label }}</span>
+      </router-link>
+    </nav>
+  </aside>
+</template>
+
+<script setup lang="ts">
+import { useRoute } from 'vue-router';
+import type { Component } from 'vue';
+
+interface MenuItem {
+  key: string;
+  label: string;
+  icon: Component;
+  path: string;
+}
+
+defineProps<{
+  menuItems: MenuItem[];
+  collapsed: boolean;
+}>();
+
+const route = useRoute();
+
+function isActive(path: string): boolean {
+  if (path === '/') {
+    return route.path === '/';
+  }
+  return route.path.startsWith(path);
+}
+</script>
+
+<style scoped>
+.menu-item.active {
+  background-color: #eff6ff;
+  color: #2563eb;
+}
+</style>

+ 47 - 0
apps/platform-app/src/components/UserMenu.vue

@@ -0,0 +1,47 @@
+<template>
+  <a-dropdown :trigger="['click']">
+    <div class="flex items-center cursor-pointer px-3 py-1.5 rounded-lg hover:bg-gray-100 transition-colors duration-200">
+      <!-- 头像 -->
+      <a-avatar class="bg-gradient-to-br from-blue-500 to-blue-600 text-white font-bold mr-2">
+        {{ userName?.charAt(0) || '?' }}
+      </a-avatar>
+      <!-- 用户名和角色 -->
+      <div class="flex flex-col">
+        <span class="text-sm font-medium text-gray-800">{{ userName || '未登录' }}</span>
+        <span class="text-xs text-gray-500">管理员</span>
+      </div>
+      <DownOutlined class="ml-2 text-gray-400 text-xs" />
+    </div>
+    <template #overlay>
+      <a-menu class="!rounded-lg !shadow-lg !min-w-[160px]">
+        <a-menu-item key="password" class="!hover:bg-gray-50">
+          <router-link to="/change-password" class="flex items-center">
+            <LockOutlined class="mr-2" />
+            修改密码
+          </router-link>
+        </a-menu-item>
+        <a-menu-divider />
+        <a-menu-item key="logout" @click="handleLogout" class="!hover:bg-red-50 !text-red-600">
+          <LogoutOutlined class="mr-2" />
+          退出登录
+        </a-menu-item>
+      </a-menu>
+    </template>
+  </a-dropdown>
+</template>
+
+<script setup lang="ts">
+import { DownOutlined, LockOutlined, LogoutOutlined } from '@ant-design/icons-vue';
+
+defineProps<{
+  userName?: string;
+}>();
+
+const emit = defineEmits<{
+  logout: [];
+}>();
+
+function handleLogout() {
+  emit('logout');
+}
+</script>

+ 41 - 0
apps/platform-app/src/layouts/BasicLayout.vue

@@ -0,0 +1,41 @@
+<template>
+  <div class="h-screen flex flex-col overflow-hidden">
+    <!-- Header 顶部导航栏(全宽) -->
+    <HeaderBar
+      :collapsed="layoutStore.collapsed"
+      :userName="userName"
+      @toggle="layoutStore.toggleCollapsed()"
+      @logout="$emit('logout')"
+    />
+
+    <!-- 下方区域:侧边栏 + 主内容区 -->
+    <div class="flex-1 flex overflow-hidden">
+      <!-- 侧边栏 -->
+      <SideMenu
+        :menuItems="layoutStore.menuItems"
+        :collapsed="layoutStore.collapsed"
+      />
+
+      <!-- 主内容区 -->
+      <main class="flex-1 overflow-auto bg-gray-50">
+        <slot />
+      </main>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import HeaderBar from '@/components/HeaderBar.vue';
+import SideMenu from '@/components/SideMenu.vue';
+import { useLayoutStore } from '@/stores/layout';
+
+defineProps<{
+  userName?: string;
+}>();
+
+defineEmits<{
+  logout: [];
+}>();
+
+const layoutStore = useLayoutStore();
+</script>

+ 4 - 4
apps/platform-app/src/router/index.ts

@@ -14,25 +14,25 @@ const routes = [
     path: '/',
     name: 'Dashboard',
     component: () => import('@/views/DashboardView.vue'),
-    meta: { requiresAuth: true, showLayout: true }
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '主页看板'] }
   },
   {
     path: '/factories',
     name: 'Factories',
     component: () => import('@/views/FactoriesView.vue'),
-    meta: { requiresAuth: true, showLayout: true }
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '工厂管理'] }
   },
   {
     path: '/users',
     name: 'Users',
     component: () => import('@/views/UsersView.vue'),
-    meta: { requiresAuth: true, showLayout: true }
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '系统用户'] }
   },
   {
     path: '/change-password',
     name: 'ChangePassword',
     component: () => import('@/views/ChangePasswordView.vue'),
-    meta: { requiresAuth: true, showLayout: true }
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '修改密码'] }
   },
   {
     path: '/:pathMatch(.*)*',

+ 5 - 1
apps/platform-app/src/stores/auth.ts

@@ -48,7 +48,11 @@ export const useAuthStore = defineStore('auth', () => {
   /**
    * 登出
    */
-  function logout() {
+  async function logout() {
+    // P1: best-effort 调后端撤销 jti,失败不阻塞本地登出
+    try {
+      await authApi.logout();
+    } catch (e) { /* 忽略 */ }
     token.value = null;
     user.value = null;
 

+ 1 - 4
apps/platform-app/src/stores/layout.ts

@@ -4,26 +4,23 @@ import type { Component } from 'vue';
 export interface MenuItem {
   key: string;
   label: string;
-  icon: Component; // 图标组件引用
+  icon: Component;
   path: string;
 }
 
 export interface LayoutConfig {
   menuItems: MenuItem[];
-  theme?: 'light' | 'dark';
 }
 
 export const useLayoutStore = defineStore('layout', {
   state: () => ({
     menuItems: [] as MenuItem[],
-    theme: 'dark' as 'light' | 'dark',
     collapsed: false
   }),
 
   actions: {
     init(config: LayoutConfig) {
       this.menuItems = config.menuItems;
-      if (config.theme) this.theme = config.theme;
     },
 
     toggleCollapsed() {

+ 19 - 1
apps/platform-app/src/styles/main.css

@@ -19,4 +19,22 @@
 
 .ant-btn-primary:active {
   background-color: #15304f;
-}
+}
+
+/* 统计卡片装饰效果 */
+.stat-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.stat-card::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  right: 0;
+  width: 100px;
+  height: 100px;
+  background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
+  transform: translate(30%, -30%);
+  pointer-events: none;
+}

+ 30 - 9
apps/platform-app/src/views/ChangePasswordView.vue

@@ -1,6 +1,16 @@
 <template>
-  <div>
-      <a-card title="修改密码">
+  <div class="p-8">
+      <a-card class="max-w-2xl mx-auto rounded-xl shadow-md !border-0">
+        <template #title>
+          <div class="flex items-center">
+            <LockOutlined class="mr-3 text-2xl text-blue-500" />
+            <span class="text-xl font-semibold">修改密码</span>
+          </div>
+        </template>
+        <template #extra>
+          <span class="text-gray-400 text-sm">安全设置</span>
+        </template>
+
         <a-form
           ref="formRef"
           :model="formState"
@@ -13,7 +23,9 @@
               v-model:value="formState.old_password"
               placeholder="请输入旧密码"
               size="large"
-            />
+            >
+              <template #prefix><LockOutlined class="text-gray-400" /></template>
+            </a-input-password>
           </a-form-item>
 
           <a-form-item label="新密码" name="new_password">
@@ -21,8 +33,11 @@
               v-model:value="formState.new_password"
               placeholder="请输入新密码(至少8位,包含大小写字母、数字、特殊字符中3类)"
               size="large"
-            />
-            <div class="text-xs text-gray-500 mt-2">
+            >
+              <template #prefix><KeyOutlined class="text-gray-400" /></template>
+            </a-input-password>
+            <div class="text-xs text-gray-500 mt-2 flex items-center">
+              <InfoCircleOutlined class="mr-1" />
               密码强度要求:至少8位,包含大小写字母、数字、特殊字符中3类
             </div>
           </a-form-item>
@@ -32,20 +47,25 @@
               v-model:value="formState.confirm_password"
               placeholder="请再次输入新密码"
               size="large"
-            />
+            >
+              <template #prefix><CheckCircleOutlined class="text-gray-400" /></template>
+            </a-input-password>
           </a-form-item>
 
-          <a-form-item>
-            <a-space>
+          <a-form-item class="mt-6">
+            <a-space size="large">
               <a-button
                 type="primary"
                 html-type="submit"
                 size="large"
                 :loading="submitting"
+                class="shadow-md hover:shadow-lg transition-shadow"
               >
+                <CheckOutlined class="mr-2" />
                 提交
               </a-button>
-              <a-button size="large" @click="resetForm">
+              <a-button size="large" @click="resetForm" class="hover:shadow-sm transition-shadow">
+                <ReloadOutlined class="mr-2" />
                 重置
               </a-button>
             </a-space>
@@ -59,6 +79,7 @@
 import { ref, reactive } from 'vue';
 import { useRouter } from 'vue-router';
 import { message } from 'ant-design-vue';
+import { LockOutlined, KeyOutlined, CheckCircleOutlined, InfoCircleOutlined, CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue';
 import { useAuthStore } from '@/stores/auth';
 import { validatePasswordStrength } from '@smartcut/shared-utils';
 

+ 63 - 34
apps/platform-app/src/views/DashboardView.vue

@@ -1,57 +1,85 @@
 <template>
-  <div class="p-6">
-    <a-row :gutter="16">
+  <div class="p-8">
+    <!-- 统计卡片区域 -->
+    <a-row :gutter="24" class="mb-8">
+      <!-- 工厂总数卡片 -->
       <a-col :span="6">
-        <a-card>
-          <a-statistic title="工厂总数" :value="dashboardData.factory_count || 0">
-            <template #suffix>
-              <span class="text-sm text-gray-500">家</span>
-            </template>
-          </a-statistic>
+        <a-card class="stat-card bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-300 !border-0">
+          <div class="flex items-center justify-between">
+            <div>
+              <div class="text-sm opacity-80 mb-2">工厂总数</div>
+              <div class="text-4xl font-bold">{{ dashboardData.factory_count }}</div>
+              <div class="text-sm mt-2 opacity-70">家</div>
+            </div>
+            <ShopOutlined class="text-5xl opacity-80" />
+          </div>
         </a-card>
       </a-col>
 
+      <!-- 启用工厂卡片 -->
       <a-col :span="6">
-        <a-card>
-          <a-statistic
-            title="启用工厂"
-            :value="dashboardData.enabled_factory_count || 0"
-            :valueStyle="{ color: '#3f8600' }"
-          >
-            <template #suffix>
-              <span class="text-sm text-gray-500">家</span>
-            </template>
-          </a-statistic>
+        <a-card class="stat-card bg-gradient-to-br from-green-500 to-green-600 text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-300 !border-0">
+          <div class="flex items-center justify-between">
+            <div>
+              <div class="text-sm opacity-80 mb-2">启用工厂</div>
+              <div class="text-4xl font-bold">{{ dashboardData.enabled_factory_count }}</div>
+              <div class="text-sm mt-2 opacity-70">家</div>
+            </div>
+            <CheckCircleOutlined class="text-5xl opacity-80" />
+          </div>
         </a-card>
       </a-col>
 
+      <!-- 系统用户卡片 -->
       <a-col :span="6">
-        <a-card>
-          <a-statistic title="系统用户" :value="dashboardData.user_count || 0">
-            <template #suffix>
-              <span class="text-sm text-gray-500">人</span>
-            </template>
-          </a-statistic>
+        <a-card class="stat-card bg-gradient-to-br from-purple-500 to-purple-600 text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-300 !border-0">
+          <div class="flex items-center justify-between">
+            <div>
+              <div class="text-sm opacity-80 mb-2">系统用户</div>
+              <div class="text-4xl font-bold">{{ dashboardData.user_count }}</div>
+              <div class="text-sm mt-2 opacity-70">人</div>
+            </div>
+            <UserOutlined class="text-5xl opacity-80" />
+          </div>
         </a-card>
       </a-col>
 
+      <!-- 审计日志卡片 -->
       <a-col :span="6">
-        <a-card>
-          <a-statistic title="审计日志" :value="dashboardData.log_count || 0">
-            <template #suffix>
-              <span class="text-sm text-gray-500">条</span>
-            </template>
-          </a-statistic>
+        <a-card class="stat-card bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-300 !border-0">
+          <div class="flex items-center justify-between">
+            <div>
+              <div class="text-sm opacity-80 mb-2">审计日志</div>
+              <div class="text-4xl font-bold">{{ dashboardData.log_count }}</div>
+              <div class="text-sm mt-2 opacity-70">条</div>
+            </div>
+            <FileTextOutlined class="text-5xl opacity-80" />
+          </div>
         </a-card>
       </a-col>
     </a-row>
 
-    <a-card title="快捷操作" class="mt-4">
-      <a-space>
-        <a-button type="primary" @click="router.push('/factories')">
+    <!-- 快捷操作卡片 -->
+    <a-card class="rounded-xl shadow-md !border-0" title="快捷操作">
+      <template #extra>
+        <span class="text-gray-400 text-sm">常用功能快速访问</span>
+      </template>
+      <a-space size="large">
+        <a-button
+          type="primary"
+          size="large"
+          @click="router.push('/factories')"
+          class="shadow-md hover:shadow-lg transition-shadow"
+        >
+          <ShopOutlined class="mr-2" />
           管理工厂
         </a-button>
-        <a-button @click="router.push('/users')">
+        <a-button
+          size="large"
+          @click="router.push('/users')"
+          class="shadow-sm hover:shadow-md transition-shadow"
+        >
+          <UserOutlined class="mr-2" />
           系统用户
         </a-button>
       </a-space>
@@ -63,6 +91,7 @@
 import { ref, onMounted } from 'vue';
 import { useRouter } from 'vue-router';
 import { message } from 'ant-design-vue';
+import { ShopOutlined, CheckCircleOutlined, UserOutlined, FileTextOutlined } from '@ant-design/icons-vue';
 import { FactoryApi, createApiClient } from '@smartcut/api-client';
 import { STORAGE_KEYS } from '@smartcut/shared-utils';
 import { getApiBaseUrl } from '@/apiConfig';

+ 19 - 10
apps/platform-app/src/views/FactoriesView.vue

@@ -1,22 +1,23 @@
 <template>
-  <div>
+  <div class="p-8">
       <!-- 头部工具栏 -->
-      <a-card>
-        <a-space>
+      <a-card class="mb-6 bg-gray-50 rounded-lg shadow-sm !border-0">
+        <a-space size="large">
           <a-input-search
             v-model:value="searchKeyword"
-            placeholder="搜索工厂名称"
-            style="width: 300px"
+            placeholder="搜索工厂名称..."
+            class="w-80"
             @search="loadFactories"
           />
-          <a-button type="primary" @click="showCreateModal">
+          <a-button type="primary" @click="showCreateModal" class="shadow-sm hover:shadow-md transition-shadow">
+            <PlusOutlined class="mr-2" />
             创建工厂
           </a-button>
         </a-space>
       </a-card>
 
       <!-- 工厂列表表格 -->
-      <a-card class="mt-4">
+      <a-card class="rounded-xl shadow-md !border-0">
         <a-table
           :columns="columns"
           :dataSource="factories"
@@ -27,7 +28,7 @@
         >
           <!-- 状态列 -->
           <template #status="{ record }">
-            <a-tag :color="record.status === 1 ? 'green' : 'red'">
+            <a-tag :color="record.status === 1 ? 'green' : 'red'" class="!rounded-full">
               {{ record.status === 1 ? '启用' : '禁用' }}
             </a-tag>
           </template>
@@ -35,13 +36,15 @@
           <!-- 操作列 -->
           <template #action="{ record }">
             <a-space>
-              <a-button size="small" @click="showEditModal(record)">
+              <a-button size="small" @click="showEditModal(record)" class="hover:shadow-sm transition-shadow">
+                <EditOutlined class="mr-1" />
                 编辑
               </a-button>
               <a-button
                 size="small"
                 :type="record.status === 1 ? 'default' : 'primary'"
                 @click="toggleFactoryStatus(record)"
+                class="hover:shadow-sm transition-shadow"
               >
                 {{ record.status === 1 ? '禁用' : '启用' }}
               </a-button>
@@ -51,7 +54,8 @@
                 cancel-text="取消"
                 @confirm="deleteFactory(record.id)"
               >
-                <a-button size="small" danger>
+                <a-button size="small" danger class="hover:shadow-sm transition-shadow">
+                  <DeleteOutlined class="mr-1" />
                   删除
                 </a-button>
               </a-popconfirm>
@@ -67,6 +71,7 @@
         @ok="handleSubmit"
         @cancel="resetForm"
         :confirmLoading="submitting"
+        class="!rounded-xl"
       >
         <a-form
           ref="formRef"
@@ -78,6 +83,7 @@
             <a-input
               v-model:value="formState.id"
               placeholder="请输入工厂ID(字母或数字)"
+              size="large"
             />
           </a-form-item>
 
@@ -85,6 +91,7 @@
             <a-input
               v-model:value="formState.name"
               placeholder="请输入工厂名称"
+              size="large"
             />
           </a-form-item>
 
@@ -92,6 +99,7 @@
             <a-input
               v-model:value="formState.db_path"
               placeholder="可选,默认自动生成"
+              size="large"
             />
           </a-form-item>
         </a-form>
@@ -102,6 +110,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
 import { message } from 'ant-design-vue';
+import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import { FactoryApi, createApiClient } from '@smartcut/api-client';
 import { STORAGE_KEYS } from '@smartcut/shared-utils';
 import { getApiBaseUrl } from '@/apiConfig';

+ 53 - 14
apps/platform-app/src/views/LoginView.vue

@@ -1,6 +1,15 @@
 <template>
   <div class="login-page">
-    <a-card class="login-card" title="智裁云 - 系统管理后台">
+    <a-card class="login-card rounded-xl shadow-2xl !border-0" :bodyStyle="{ padding: '32px' }">
+      <template #title>
+        <div class="text-center py-4">
+          <h1 class="text-3xl font-bold bg-gradient-to-r from-blue-500 to-blue-700 bg-clip-text text-transparent mb-2">
+            智裁云
+          </h1>
+          <p class="text-gray-500 text-sm">系统管理后台</p>
+        </div>
+      </template>
+
       <a-form
         :model="formState"
         :rules="rules"
@@ -12,7 +21,9 @@
             v-model:value="formState.username"
             placeholder="请输入用户名"
             size="large"
-          />
+          >
+            <template #prefix><UserOutlined class="text-gray-400" /></template>
+          </a-input>
         </a-form-item>
 
         <a-form-item label="密码" name="password">
@@ -20,17 +31,21 @@
             v-model:value="formState.password"
             placeholder="请输入密码"
             size="large"
-          />
+          >
+            <template #prefix><LockOutlined class="text-gray-400" /></template>
+          </a-input-password>
         </a-form-item>
 
-        <a-form-item>
+        <a-form-item class="mt-6">
           <a-button
             type="primary"
             html-type="submit"
             size="large"
             block
             :loading="loading"
+            class="shadow-lg hover:shadow-xl transition-all duration-300"
           >
+            <LoginOutlined class="mr-2" />
             登录
           </a-button>
         </a-form-item>
@@ -43,6 +58,7 @@
 import { ref, reactive } from 'vue';
 import { useRouter, useRoute } from 'vue-router';
 import { message } from 'ant-design-vue';
+import { UserOutlined, LockOutlined, LoginOutlined } from '@ant-design/icons-vue';
 import { useAuthStore } from '@/stores/auth';
 
 const router = useRouter();
@@ -73,7 +89,9 @@ async function handleLogin() {
 
     // 跳转到目标页面或首页
     const redirect = route.query.redirect as string;
-    router.push(redirect || '/');
+    // P2: redirect 仅允许相对路径,防开放重定向
+    const safeRedirect = redirect && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/';
+    router.push(safeRedirect);
   } catch (error: any) {
     const errorMsg = error.response?.data?.msg || '登录失败,请检查用户名和密码';
     message.error(errorMsg);
@@ -83,25 +101,46 @@ async function handleLogin() {
 }
 </script>
 
-<style scoped lang="postcss">
+<style scoped>
 .login-page {
   height: 100vh;
   display: flex;
   justify-content: center;
   align-items: center;
-  background-color: #f0f2f5;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  position: relative;
+}
+
+.login-page::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="rgba(255,255,255,0.1)"/></svg>') repeat;
+  opacity: 0.3;
 }
 
 .login-card {
   width: 400px;
-  border-radius: 8px;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  backdrop-filter: blur(10px);
+  background: rgba(255, 255, 255, 0.95);
+  animation: fadeInUp 0.6s ease;
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
 }
 
-.login-card .ant-card-head-title {
-  text-align: center;
-  font-size: 18px;
-  font-weight: bold;
-  color: #1e3a5f;
+.login-card :deep(.ant-card-head) {
+  border-bottom: none;
 }
 </style>

+ 24 - 12
apps/platform-app/src/views/UsersView.vue

@@ -1,22 +1,23 @@
 <template>
-  <div>
+  <div class="p-8">
       <!-- 头部工具栏 -->
-      <a-card>
-        <a-space>
+      <a-card class="mb-6 bg-gray-50 rounded-lg shadow-sm !border-0">
+        <a-space size="large">
           <a-input-search
             v-model:value="searchKeyword"
-            placeholder="搜索用户名或姓名"
-            style="width: 300px"
+            placeholder="搜索用户名或姓名..."
+            class="w-80"
             @search="loadUsers"
           />
-          <a-button type="primary" @click="showCreateModal">
+          <a-button type="primary" @click="showCreateModal" class="shadow-sm hover:shadow-md transition-shadow">
+            <PlusOutlined class="mr-2" />
             创建用户
           </a-button>
         </a-space>
       </a-card>
 
       <!-- 用户列表表格 -->
-      <a-card class="mt-4">
+      <a-card class="rounded-xl shadow-md !border-0">
         <a-table
           :columns="columns"
           :dataSource="users"
@@ -27,14 +28,14 @@
         >
           <!-- 状态列 -->
           <template #status="{ record }">
-            <a-tag :color="record.status === 1 ? 'green' : 'red'">
+            <a-tag :color="record.status === 1 ? 'green' : 'red'" class="!rounded-full">
               {{ record.status === 1 ? '正常' : '禁用' }}
             </a-tag>
           </template>
 
           <!-- 必须修改密码列 -->
           <template #must_change_password="{ record }">
-            <a-tag :color="record.must_change_password ? 'orange' : 'blue'">
+            <a-tag :color="record.must_change_password ? 'orange' : 'blue'" class="!rounded-full">
               {{ record.must_change_password ? '需要修改' : '已修改' }}
             </a-tag>
           </template>
@@ -42,10 +43,12 @@
           <!-- 操作列 -->
           <template #action="{ record }">
             <a-space>
-              <a-button size="small" @click="showEditModal(record)">
+              <a-button size="small" @click="showEditModal(record)" class="hover:shadow-sm transition-shadow">
+                <EditOutlined class="mr-1" />
                 编辑
               </a-button>
-              <a-button size="small" @click="showResetPasswordModal(record)">
+              <a-button size="small" @click="showResetPasswordModal(record)" class="hover:shadow-sm transition-shadow">
+                <KeyOutlined class="mr-1" />
                 重置密码
               </a-button>
               <a-popconfirm
@@ -54,7 +57,8 @@
                 cancel-text="取消"
                 @confirm="deleteUser(record.id)"
               >
-                <a-button size="small" danger>
+                <a-button size="small" danger class="hover:shadow-sm transition-shadow">
+                  <DeleteOutlined class="mr-1" />
                   删除
                 </a-button>
               </a-popconfirm>
@@ -71,6 +75,7 @@
         @cancel="resetForm"
         :confirmLoading="submitting"
         width="600px"
+        class="!rounded-xl"
       >
         <a-form
           ref="formRef"
@@ -82,6 +87,7 @@
             <a-input
               v-model:value="formState.username"
               placeholder="请输入用户名(2-32位)"
+              size="large"
             />
           </a-form-item>
 
@@ -89,6 +95,7 @@
             <a-input
               v-model:value="formState.name"
               placeholder="请输入姓名"
+              size="large"
             />
           </a-form-item>
 
@@ -96,6 +103,7 @@
             <a-input
               v-model:value="formState.phone"
               placeholder="请输入手机号"
+              size="large"
             />
           </a-form-item>
 
@@ -103,6 +111,7 @@
             <a-input-password
               v-model:value="formState.password"
               placeholder="请输入密码(至少8位,包含大小写字母、数字、特殊字符中3类)"
+              size="large"
             />
             <div class="text-xs text-gray-500 mt-2">
               密码强度要求:至少8位,包含大小写字母、数字、特殊字符中3类
@@ -125,6 +134,7 @@
         @ok="handleResetPassword"
         @cancel="resetPasswordForm"
         :confirmLoading="resettingPassword"
+        class="!rounded-xl"
       >
         <a-form
           ref="resetPasswordFormRef"
@@ -136,6 +146,7 @@
             <a-input-password
               v-model:value="resetPasswordState.password"
               placeholder="请输入新密码"
+              size="large"
             />
             <div class="text-xs text-gray-500 mt-2">
               密码强度要求:至少8位,包含大小写字母、数字、特殊字符中3类
@@ -149,6 +160,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
 import { message } from 'ant-design-vue';
+import { PlusOutlined, EditOutlined, KeyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import { UserApi, createApiClient } from '@smartcut/api-client';
 import { STORAGE_KEYS, validatePasswordStrength } from '@smartcut/shared-utils';
 import { getApiBaseUrl } from '@/apiConfig';

+ 5 - 1
apps/worker-app/src/stores/auth.ts

@@ -73,7 +73,11 @@ export const useAuthStore = defineStore('auth', () => {
     }
   }
 
-  function logout() {
+  async function logout() {
+    // P1: best-effort 调后端撤销 jti,失败不阻塞本地登出
+    try {
+      await authApi.logout();
+    } catch (e) { /* 忽略 */ }
     token.value = null;
     user.value = null;
     currentFactoryId.value = null;

+ 3 - 1
apps/worker-app/src/views/LoginView.vue

@@ -120,7 +120,9 @@ async function handleLogin() {
     }
     message.success('登录成功');
     const redirect = route.query.redirect as string;
-    router.push(redirect || '/');
+    // P2: redirect 仅允许相对路径,防开放重定向
+    const safeRedirect = redirect && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/';
+    router.push(safeRedirect);
   } catch (error: any) {
     message.error(error.response?.data?.msg || '登录失败');
   } finally {

+ 2 - 2
apps/worker-app/src/views/ProfileView.vue

@@ -57,8 +57,8 @@ import WorkerLayout from '@/components/WorkerLayout.vue';
 const router = useRouter();
 const authStore = useAuthStore();
 
-function handleLogout() {
-  authStore.logout();
+async function handleLogout() {
+  await authStore.logout();
   message.success('已退出登录');
   router.push('/login');
 }

+ 7 - 3
packages/api-client/src/client.ts

@@ -6,7 +6,7 @@ import { getSessionStorage, removeSessionStorage, setSessionStorage, STORAGE_KEY
 
 export interface ApiClientConfig {
   baseURL: string;
-  tokenKey: string; // localStorage中的token键名
+  tokenKey: string; // sessionStorage中的token键名
 }
 
 /**
@@ -69,8 +69,12 @@ export function createApiClient(config: ApiClientConfig): AxiosInstance {
         removeSessionStorage(config.tokenKey);
         removeSessionStorage(STORAGE_KEYS.CURRENT_FACTORY_ID);
 
-        // 跳转登录页面(需根据应用类型判断)
-        window.location.href = '/login';
+        // P2: 保存当前路径,登录后返回原页面;redirect 仅允许相对路径,防开放重定向
+        const currentPath = window.location.pathname + window.location.search;
+        const isSafeRedirect = currentPath.startsWith('/') && !currentPath.startsWith('//') && !currentPath.startsWith('/login');
+        window.location.href = isSafeRedirect
+          ? '/login?redirect=' + encodeURIComponent(currentPath)
+          : '/login';
       }
       return Promise.reject(error);
     }

+ 7 - 0
packages/api-client/src/endpoints/auth.ts

@@ -30,4 +30,11 @@ export class AuthApi {
   async changePassword(data: ChangePasswordRequest): Promise<void> {
     await this.client.put<ApiResponse>('/auth/password', data);
   }
+
+  /**
+   * 登出(撤销后端 jti)
+   */
+  async logout(): Promise<void> {
+    await this.client.post<ApiResponse>('/auth/logout');
+  }
 }

+ 53 - 21
packages/shared-components/src/layout/SidebarLayout.vue

@@ -5,49 +5,69 @@
       v-model:collapsed="layout.collapsed"
       :trigger="null"
       collapsible
-      class="!bg-slate-900"
+      class="!bg-gradient-to-b from-slate-800 to-slate-900"
     >
-      <div class="h-16 flex items-center justify-center text-white font-bold text-xl">
-        <h1 v-if="!layout.collapsed">智裁云</h1>
-        <h1 v-else>裁</h1>
+      <!-- Logo 区域 -->
+      <div class="h-16 flex items-center justify-center bg-gradient-to-r from-blue-500/20 to-blue-600/20">
+        <h1 class="text-white font-bold text-xl tracking-wide">
+          <span v-if="!layout.collapsed" class="bg-gradient-to-r from-blue-400 to-blue-600 bg-clip-text text-transparent drop-shadow-lg">
+            智裁云
+          </span>
+          <span v-else class="bg-gradient-to-r from-blue-400 to-blue-600 bg-clip-text text-transparent">
+            裁
+          </span>
+        </h1>
       </div>
 
+      <!-- 菜单 -->
       <a-menu
         v-model:selectedKeys="selectedKeys"
         mode="inline"
         :theme="layout.theme"
-        class="border-r-0"
+        class="border-r-0 !bg-transparent"
       >
-        <a-menu-item v-for="item in layout.menuItems" :key="item.key">
+        <a-menu-item
+          v-for="item in layout.menuItems"
+          :key="item.key"
+          class="!hover:bg-gradient-to-r !hover:from-slate-700/50 !hover:to-transparent !transition-all !duration-200 !border-l-3"
+        >
           <router-link :to="item.path" class="flex items-center">
-            <component :is="item.icon" class="mr-2" />
-            <span>{{ item.label }}</span>
+            <component :is="item.icon" class="mr-3 text-lg" />
+            <span class="font-medium">{{ item.label }}</span>
           </router-link>
         </a-menu-item>
       </a-menu>
     </a-layout-sider>
 
     <!-- 主内容区 -->
-    <a-layout class="bg-gray-100">
+    <a-layout class="bg-gray-50">
       <!-- 头部 -->
-      <a-layout-header class="bg-white px-6 flex justify-between items-center shadow-sm">
-        <a-button type="text" @click="layout.toggleCollapsed()" class="text-lg px-6">
-          <MenuFoldOutlined v-if="!layout.collapsed" />
-          <MenuUnfoldOutlined v-else />
+      <a-layout-header class="bg-white px-6 flex justify-between items-center shadow-md !h-16">
+        <a-button
+          type="text"
+          @click="layout.toggleCollapsed()"
+          class="text-lg px-6 hover:bg-gray-100 transition-colors duration-200"
+        >
+          <MenuFoldOutlined v-if="!layout.collapsed" class="text-gray-600" />
+          <MenuUnfoldOutlined v-else class="text-gray-600" />
         </a-button>
 
         <!-- 用户下拉菜单 -->
         <a-dropdown>
-          <a-avatar class="cursor-pointer">
+          <a-avatar class="cursor-pointer hover:scale-110 transition-transform duration-200 bg-gradient-to-br from-blue-500 to-blue-600 text-white font-bold">
             {{ userName?.charAt(0) }}
           </a-avatar>
           <template #overlay>
-            <a-menu>
-              <a-menu-item key="password">
-                <router-link to="/change-password">修改密码</router-link>
+            <a-menu class="!shadow-lg !rounded-lg">
+              <a-menu-item key="password" class="!hover:bg-gray-50">
+                <router-link to="/change-password" class="flex items-center">
+                  <LockOutlined class="mr-2" />
+                  修改密码
+                </router-link>
               </a-menu-item>
               <a-menu-divider />
-              <a-menu-item key="logout" @click="handleLogout">
+              <a-menu-item key="logout" @click="handleLogout" class="!hover:bg-red-50 !text-red-600">
+                <LogoutOutlined class="mr-2" />
                 退出登录
               </a-menu-item>
             </a-menu>
@@ -56,7 +76,7 @@
       </a-layout-header>
 
       <!-- 内容区 -->
-      <a-layout-content class="m-6 bg-white rounded-lg min-h-80 overflow-auto">
+      <a-layout-content class="m-6 bg-white rounded-xl shadow-md min-h-[calc(100vh-128px)] overflow-auto">
         <slot />
       </a-layout-content>
     </a-layout>
@@ -65,7 +85,7 @@
 
 <script setup lang="ts">
 import { computed, getCurrentInstance } from 'vue';
-import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue';
+import { MenuFoldOutlined, MenuUnfoldOutlined, LockOutlined, LogoutOutlined } from '@ant-design/icons-vue';
 import { useLayoutStore } from '@/stores/layout';
 
 const layout = useLayoutStore();
@@ -92,4 +112,16 @@ const selectedKeys = computed(() => {
 function handleLogout() {
   emit('logout');
 }
-</script>
+</script>
+
+<style scoped>
+/* 菜单项激活状态样式 */
+.ant-menu-item-selected {
+  background: linear-gradient(90deg, rgba(59, 130, 246, 0.3) 0%, transparent 100%) !important;
+  border-left: 3px solid #3b82f6 !important;
+}
+
+.ant-menu-item-selected::after {
+  border-bottom: none !important;
+}
+</style>

+ 2 - 2
packages/shared-utils/src/storage.ts

@@ -1,7 +1,7 @@
-// localStorage和sessionStorage封装
+// sessionStorage封装
 
 /**
- * localStorage存储键常量
+ * sessionStorage存储键常量
  */
 export const STORAGE_KEYS = {
   PLATFORM_TOKEN: 'platform_token',