Selaa lähdekoodia

feat(frontend): 双层壳层(AppShell/ModuleRail/ContextNav/SystemStatusBar)和路由重构

wangkangyjy 1 viikko sitten
vanhempi
commit
a37a25789f

+ 121 - 0
frontend/src/components/shell/AppShell.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="app-shell">
+    <ModuleRail :active-module="activeModule" @select="onModuleSelect" />
+    <ContextNav
+      :title="contextTitle"
+      :items="contextItems"
+      :collapsed="contextCollapsed"
+      @toggle="contextCollapsed = !contextCollapsed"
+    />
+    <div class="app-shell__main">
+      <SystemStatusBar
+        :period-name="periodName"
+        :phase="phase"
+        :unread-count="0"
+      />
+      <main class="app-shell__content">
+        <router-view />
+      </main>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useWorkspaceStore } from '@/stores/workspace'
+import ModuleRail from './ModuleRail.vue'
+import ContextNav from './ContextNav.vue'
+import SystemStatusBar from './SystemStatusBar.vue'
+import {
+  HomeFilled, Aim, DataAnalysis, Setting,
+  Monitor, List, Share, TrendCharts,
+  DocumentChecked, ChatDotRound, Warning, Clock,
+  Calendar, OfficeBuilding, Notebook, Grid, Tickets
+} from '@element-plus/icons-vue'
+
+const route = useRoute()
+const router = useRouter()
+const workspace = useWorkspaceStore()
+
+const activeModule = ref('workspace')
+const contextCollapsed = ref(false)
+
+const moduleContexts = {
+  workspace: {
+    title: '工作台',
+    items: [
+      { to: '/workspace', label: '当前周期', icon: Monitor }
+    ]
+  },
+  okr: {
+    title: '目标与对齐',
+    items: [
+      { to: '/okr/my', label: '我的 OKR', icon: Aim },
+      { to: '/okr/team', label: '团队 OKR', icon: List },
+      { to: '/okr/alignment', label: 'OKR 对齐', icon: Share },
+      { to: '/okr/execution', label: '执行进度', icon: TrendCharts }
+    ]
+  },
+  performance: {
+    title: '绩效评审',
+    items: [
+      { to: '/scores/my', label: '我的绩效', icon: DocumentChecked },
+      { to: '/scores/review', label: '待我评审', icon: DataAnalysis },
+      { to: '/feedback', label: '绩效沟通', icon: ChatDotRound },
+      { to: '/scores/appeal', label: '绩效申诉', icon: Warning },
+      { to: '/scores/history', label: '历史绩效', icon: Clock }
+    ]
+  },
+  admin: {
+    title: '系统管理',
+    items: [
+      { to: '/period', label: '考核周期', icon: Calendar },
+      { to: '/org', label: '组织架构', icon: OfficeBuilding },
+      { to: '/period/templates', label: '绩效模板', icon: Notebook },
+      { to: '/dimensions', label: '考核维度', icon: Grid },
+      { to: '/logs', label: '操作日志', icon: Tickets }
+    ]
+  }
+}
+
+const contextTitle = computed(() => moduleContexts[activeModule.value]?.title || '')
+const contextItems = computed(() => moduleContexts[activeModule.value]?.items || [])
+const periodName = computed(() => workspace.currentPeriod?.name || '')
+const phase = computed(() => workspace.currentPhase || '')
+
+function onModuleSelect(key) {
+  activeModule.value = key
+  const first = moduleContexts[key]?.items[0]
+  if (first) router.push(first.to)
+}
+
+// Sync active module from route
+watch(() => route.path, (path) => {
+  for (const [mod, ctx] of Object.entries(moduleContexts)) {
+    if (ctx.items.some(i => path.startsWith(i.to))) {
+      activeModule.value = mod
+      return
+    }
+  }
+}, { immediate: true })
+</script>
+
+<style scoped>
+.app-shell {
+  display: flex;
+  min-height: 100vh;
+  background: var(--workspace-bg, #f5f8fa);
+}
+.app-shell__main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+}
+.app-shell__content {
+  flex: 1;
+  overflow-y: auto;
+  padding: var(--spacing-lg, 16px);
+}
+</style>

+ 88 - 0
frontend/src/components/shell/ContextNav.vue

@@ -0,0 +1,88 @@
+<template>
+  <aside class="context-nav" :class="{ 'is-collapsed': collapsed }">
+    <div class="context-nav__head" v-if="!collapsed">
+      <span class="context-nav__title">{{ title }}</span>
+      <el-button :icon="ArrowLeft" text size="small" @click="$emit('toggle')" />
+    </div>
+    <div class="context-nav__toggle" v-else @click="$emit('toggle')" title="展开导航">
+      <el-icon :size="16"><ArrowRight /></el-icon>
+    </div>
+    <nav class="context-nav__menu" v-if="!collapsed">
+      <router-link
+        v-for="item in items"
+        :key="item.to"
+        :to="item.to"
+        class="context-nav__link"
+        active-class="is-active"
+      >
+        <el-icon :size="16" v-if="item.icon"><component :is="item.icon" /></el-icon>
+        <span>{{ item.label }}</span>
+      </router-link>
+    </nav>
+  </aside>
+</template>
+
+<script setup>
+import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
+
+defineProps({
+  title: { type: String, default: '' },
+  items: { type: Array, default: () => [] },
+  collapsed: { type: Boolean, default: false }
+})
+
+defineEmits(['toggle'])
+</script>
+
+<style scoped>
+.context-nav {
+  width: var(--shell-context-width, 176px);
+  min-height: 100vh;
+  background: var(--shell-bg, #1d166b);
+  border-right: 1px solid rgba(255,255,255,0.08);
+  display: flex;
+  flex-direction: column;
+  transition: width var(--motion-fast, 160ms ease);
+  overflow: hidden;
+}
+.context-nav.is-collapsed { width: 40px; }
+.context-nav__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 12px;
+  border-bottom: 1px solid rgba(255,255,255,0.06);
+}
+.context-nav__title {
+  font-size: 11px;
+  font-weight: 700;
+  color: rgba(255,255,255,0.45);
+  text-transform: uppercase;
+  letter-spacing: 0.8px;
+}
+.context-nav__toggle {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: rgba(255,255,255,0.4);
+  transition: color var(--motion-fast, 160ms ease);
+}
+.context-nav__toggle:hover { color: var(--color-accent, #3ad4d8); }
+.context-nav__menu { padding: 8px; display: flex; flex-direction: column; gap: 2px; }
+.context-nav__link {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  border-radius: var(--radius-xs, 4px);
+  color: rgba(255,255,255,0.55);
+  font-size: 13px;
+  text-decoration: none;
+  transition: color var(--motion-fast, 160ms ease), background-color var(--motion-fast, 160ms ease);
+}
+.context-nav__link:hover { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.06); }
+.context-nav__link.is-active { color: var(--color-accent, #3ad4d8); background: rgba(58,212,216,0.08); }
+</style>

+ 93 - 0
frontend/src/components/shell/ModuleRail.vue

@@ -0,0 +1,93 @@
+<template>
+  <nav class="module-rail">
+    <div class="module-rail__brand">
+      <span class="module-rail__logo">医梦AI</span>
+    </div>
+    <ul class="module-rail__items">
+      <li v-for="mod in modules" :key="mod.key"
+          class="module-rail__item"
+          :class="{ 'is-active': activeModule === mod.key }"
+          @click="$emit('select', mod.key)"
+          :title="mod.label">
+        <el-icon :size="20"><component :is="mod.icon" /></el-icon>
+        <span class="module-rail__label">{{ mod.label }}</span>
+      </li>
+    </ul>
+    <div class="module-rail__spacer"></div>
+    <div class="module-rail__footer">
+      <slot name="footer" />
+    </div>
+  </nav>
+</template>
+
+<script setup>
+import { HomeFilled, Aim, DataAnalysis, Setting } from '@element-plus/icons-vue'
+
+defineProps({
+  activeModule: { type: String, default: 'workspace' }
+})
+
+defineEmits(['select'])
+
+const modules = [
+  { key: 'workspace', label: '工作台', icon: HomeFilled },
+  { key: 'okr', label: '目标与对齐', icon: Aim },
+  { key: 'performance', label: '绩效评审', icon: DataAnalysis },
+  { key: 'admin', label: '系统管理', icon: Setting }
+]
+</script>
+
+<style scoped>
+.module-rail {
+  width: var(--shell-rail-width, 64px);
+  min-height: 100vh;
+  background: var(--shell-bg-deep, #120d4a);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 12px 0;
+  user-select: none;
+}
+.module-rail__brand {
+  width: 40px;
+  height: 40px;
+  border-radius: var(--radius-sm, 6px);
+  background: rgba(255,255,255,0.08);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 20px;
+}
+.module-rail__logo {
+  font-size: 10px;
+  font-weight: 700;
+  color: rgba(255,255,255,0.5);
+  letter-spacing: 0.5px;
+}
+.module-rail__items {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.module-rail__item {
+  width: 44px;
+  height: 44px;
+  border-radius: var(--radius-sm, 6px);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2px;
+  cursor: pointer;
+  color: rgba(255,255,255,0.45);
+  transition: color var(--motion-fast, 160ms ease), background-color var(--motion-fast, 160ms ease);
+}
+.module-rail__item:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.06); }
+.module-rail__item.is-active { color: var(--color-accent, #3ad4d8); background: rgba(58,212,216,0.08); }
+.module-rail__label { font-size: 9px; font-weight: 500; }
+.module-rail__spacer { flex: 1; }
+.module-rail__footer { padding: 8px 0; }
+</style>

+ 98 - 0
frontend/src/components/shell/SystemStatusBar.vue

@@ -0,0 +1,98 @@
+<template>
+  <header class="system-status-bar">
+    <div class="system-status-bar__left">
+      <span class="system-status-bar__period" v-if="periodName">
+        <span class="status-dot" :class="`status-dot--${phaseClass}`"></span>
+        {{ periodName }}
+        <span class="system-status-bar__phase" v-if="phase">{{ phaseLabel }}</span>
+      </span>
+      <span class="system-status-bar__period" v-else>无活跃周期</span>
+    </div>
+    <div class="system-status-bar__right">
+      <el-badge :value="unreadCount" :hidden="!unreadCount" class="system-status-bar__bell">
+        <el-button :icon="Bell" text size="small" @click="$router.push('/notifications')" />
+      </el-badge>
+      <el-dropdown trigger="click">
+        <span class="system-status-bar__user">
+          <el-icon :size="16"><UserFilled /></el-icon>
+          <span>{{ userName }}</span>
+        </span>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item @click="$router.push('/scores/my')">我的绩效</el-dropdown-item>
+            <el-dropdown-item @click="handleLogout" divided>退出登录</el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+    </div>
+  </header>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { Bell, UserFilled } from '@element-plus/icons-vue'
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+
+const props = defineProps({
+  periodName: { type: String, default: '' },
+  phase: { type: String, default: '' },
+  unreadCount: { type: Number, default: 0 }
+})
+
+const router = useRouter()
+const auth = useAuthStore()
+
+const userName = computed(() => auth.user?.realName || auth.user?.username || '')
+
+const phaseClass = computed(() => {
+  const map = { DRAFT: 'info', OKR_ALIGN: 'active', EXECUTING: 'active', ASSESSING: 'warning', ARCHIVED: 'info' }
+  return map[props.phase] || 'info'
+})
+
+const phaseLabel = computed(() => {
+  const map = { DRAFT: '草稿', OKR_ALIGN: 'OKR 对齐', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
+  return map[props.phase] ? `· ${map[props.phase]}` : ''
+})
+
+function handleLogout() {
+  auth.logout()
+  router.push('/login')
+}
+</script>
+
+<style scoped>
+.system-status-bar {
+  height: var(--shell-status-height, 36px);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+  background: var(--workspace-surface, #fff);
+  border-bottom: 1px solid var(--color-border, #dfe7ee);
+  font-size: 12px;
+}
+.system-status-bar__left { display: flex; align-items: center; gap: 12px; }
+.system-status-bar__period { color: var(--color-text-secondary, #5f6b7a); display: flex; align-items: center; gap: 6px; }
+.system-status-bar__phase { color: var(--color-text-muted, #8a97a8); }
+.system-status-bar__right { display: flex; align-items: center; gap: 8px; }
+.system-status-bar__bell { margin-right: 4px; }
+.system-status-bar__user {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  cursor: pointer;
+  font-size: 12px;
+  color: var(--color-text-secondary, #5f6b7a);
+  padding: 4px 8px;
+  border-radius: var(--radius-xs, 4px);
+  transition: background-color var(--motion-fast, 160ms ease);
+}
+.system-status-bar__user:hover { background: var(--color-surface-subtle, #f8fafc); }
+
+/* Inline status dot */
+.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
+.status-dot--active { background: var(--color-success, #2e9d65); }
+.status-dot--warning { background: var(--color-warning, #c98219); }
+.status-dot--info { background: var(--color-text-muted, #8a97a8); }
+</style>

+ 24 - 8
frontend/src/router/index.js

@@ -7,27 +7,43 @@ const routes = [
     component: () => import('../views/auth/LoginView.vue'),
     meta: { guest: true, title: '登录' }
   },
+  // New surgical observatory shell
   {
     path: '/',
-    component: () => import('../components/MainLayout.vue'),
+    component: () => import('../components/shell/AppShell.vue'),
     children: [
-      { path: '', redirect: '/periods' },
+      { path: '', redirect: '/workspace' },
+      { path: 'workspace', name: 'Workspace', component: () => import('../views/workspace/WorkspaceView.vue'), meta: { title: '工作台' } },
       { path: 'periods', name: 'Periods', component: () => import('../views/period/PeriodListView.vue'), meta: { title: '考核周期' } },
+      { path: 'period/templates', name: 'KpiTemplates', component: () => import('../views/period/KpiTemplateView.vue'), meta: { title: '绩效模板' } },
       { path: 'okr/my', name: 'MyOkr', component: () => import('../views/okr/MyOkrView.vue'), meta: { title: '我的 OKR' }, props: route => ({ periodId: route.query.periodId ? Number(route.query.periodId) : null }) },
       { path: 'okr/team', name: 'TeamOkr', component: () => import('../views/okr/TeamOkrView.vue'), meta: { title: '团队 OKR' } },
       { path: 'okr/alignment', name: 'OkrAlignment', component: () => import('../views/okr/AlignmentView.vue'), meta: { title: 'OKR 对齐' } },
+      { path: 'okr/execution', name: 'OkrExecution', component: () => import('../views/okr/OkrExecutionView.vue'), meta: { title: '执行进度' } },
       { path: 'scores/my', name: 'MyScores', component: () => import('../views/scoring/MyResultView.vue'), meta: { title: '我的绩效' } },
       { path: 'scores/history', name: 'ScoreHistory', component: () => import('../views/scoring/HistoryView.vue'), meta: { title: '历史绩效' } },
-      { path: 'scoring', name: 'Scoring', component: () => import('../views/scoring/ScoringListView.vue'), meta: { title: '绩效打分' } },
-      { path: 'scoring/:userId', name: 'ScoringDetail', component: () => import('../views/scoring/ScoringEditView.vue'), meta: { title: '绩效考评' } },
+      { path: 'scores/review', name: 'ScoringList', component: () => import('../views/scoring/ScoringListView.vue'), meta: { title: '绩效打分' } },
+      { path: 'scores/review/:userId', name: 'ScoringDetail', component: () => import('../views/scoring/ScoringEditView.vue'), meta: { title: '绩效考评' } },
+      { path: 'scores/appeal', name: 'ScoreAppeals', component: () => import('../views/scoring/ScoreAppealView.vue'), meta: { title: '绩效申诉' } },
       { path: 'feedback', name: 'Feedback', component: () => import('../views/feedback/FeedbackView.vue'), meta: { title: '绩效沟通' } },
-      { path: 'score-appeals', name: 'ScoreAppeals', component: () => import('../views/scoring/ScoreAppealView.vue'), meta: { title: '绩效申诉' } },
       { path: 'org', name: 'Org', component: () => import('../views/org/OrgView.vue'), meta: { title: '组织架构' } },
-      { path: 'kpi/templates', name: 'KpiTemplates', component: () => import('../views/period/KpiTemplateView.vue'), meta: { title: '绩效模板' } },
-      { path: 'system/dimensions', name: 'Dimensions', component: () => import('../views/system/DimensionConfigView.vue'), meta: { title: '考核维度' } },
-      { path: 'system/logs', name: 'SystemLogs', component: () => import('../views/system/LogView.vue'), meta: { title: '操作日志' } },
+      { path: 'dimensions', name: 'Dimensions', component: () => import('../views/system/DimensionConfigView.vue'), meta: { title: '考核维度' } },
+      { path: 'logs', name: 'SystemLogs', component: () => import('../views/system/LogView.vue'), meta: { title: '操作日志' } },
       { path: 'notifications', name: 'Notifications', component: () => import('../views/notification/NotificationView.vue'), meta: { title: '消息通知' } }
     ]
+  },
+  // Legacy shell — keep for backward compatibility
+  {
+    path: '/legacy',
+    component: () => import('../components/MainLayout.vue'),
+    children: [
+      { path: 'scoring', name: 'ScoringLegacy', component: () => import('../views/scoring/ScoringListView.vue'), meta: { title: '绩效打分' } },
+      { path: 'scoring/:userId', name: 'ScoringDetailLegacy', component: () => import('../views/scoring/ScoringEditView.vue'), meta: { title: '绩效考评' } },
+      { path: 'kpi/templates', name: 'KpiTemplatesLegacy', component: () => import('../views/period/KpiTemplateView.vue'), meta: { title: '绩效模板' } },
+      { path: 'system/dimensions', name: 'DimensionsLegacy', component: () => import('../views/system/DimensionConfigView.vue'), meta: { title: '考核维度' } },
+      { path: 'system/logs', name: 'SystemLogsLegacy', component: () => import('../views/system/LogView.vue'), meta: { title: '操作日志' } },
+      { path: 'score-appeals', name: 'ScoreAppealsLegacy', component: () => import('../views/scoring/ScoreAppealView.vue'), meta: { title: '绩效申诉' } }
+    ]
   }
 ]
 

+ 6 - 0
frontend/src/views/okr/OkrExecutionView.vue

@@ -0,0 +1,6 @@
+<template>
+  <div class="okr-execution-view">
+    <h2>执行进度</h2>
+    <p>OKR 执行进度页面(将在任务12完善)</p>
+  </div>
+</template>

+ 30 - 0
frontend/src/views/workspace/WorkspaceView.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="workspace-view">
+    <div class="data-toolbar">
+      <span class="data-toolbar__title">工作台</span>
+      <span class="data-toolbar__sep">|</span>
+      <span class="data-toolbar__meta" v-if="workspace.currentPeriod">
+        {{ workspace.currentPeriod.name }} · {{ phaseLabel }}
+      </span>
+    </div>
+    <div class="workspace-grid">
+      <p>工作台页面(将在任务9完善)</p>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted } from 'vue'
+import { useWorkspaceStore } from '@/stores/workspace'
+
+const workspace = useWorkspaceStore()
+
+const phaseLabel = computed(() => {
+  const map = { DRAFT: '草稿', OKR_ALIGN: 'OKR 对齐', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
+  return map[workspace.currentPhase] || workspace.currentPhase || ''
+})
+
+onMounted(() => {
+  workspace.refreshOverview()
+})
+</script>

+ 6 - 0
frontend/vite.config.js

@@ -1,8 +1,14 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
+import { fileURLToPath } from 'node:url'
 
 export default defineConfig({
   plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
   server: {
     port: 5173,
     proxy: {