瀏覽代碼

feat: 新增登录页与 web-app 路由 / 头部导航改造

ligb 3 周之前
父節點
當前提交
c1fe1bb0c6

+ 4 - 29
main-app/src/App.vue

@@ -1,33 +1,8 @@
 <template>
-  <div class="app-shell" :class="{ 'is-mobile': deviceStore.isMobile }">
-    <!-- 顶部导航(pc 端展示完整导航,移动端收起) -->
-    <header class="app-header">
-      <div class="brand">微前端平台</div>
-      <nav class="top-nav" v-if="!deviceStore.isMobile">
-        <router-link to="/" exact>首页</router-link>
-        <router-link to="/web">PC 业务</router-link>
-        <router-link to="/mobile">移动端</router-link>
-      </nav>
-      <div class="device-switch">
-        <button
-          v-for="m in ['pc', 'mobile']"
-          :key="m"
-          :class="{ active: deviceStore.mode === m }"
-          @click="deviceStore.switchTo(m)"
-        >
-          {{ m === 'pc' ? '电脑端' : '手机端' }}
-        </button>
-      </div>
-    </header>
-
-    <main class="app-content">
-      <router-view />
-    </main>
-  </div>
+  <router-view />
 </template>
 
 <script setup>
-import { useDeviceStore } from '@/store/device'
-
-const deviceStore = useDeviceStore()
-</script>
+// 母应用仅作为微前端承载壳层:不渲染任何 chrome / 样式 / 导航,
+// 所有 UI 由各子应用(web-app、mobile-app)自行负责。
+</script>

+ 0 - 2
main-app/src/main.js

@@ -3,8 +3,6 @@ import { createPinia } from 'pinia'
 import App from './App.vue'
 import router from './router'
 import { registerApp, startApp } from './micro'
-import './assets/base.css'
-import './assets/main.css'
 
 const app = createApp(App)
 

+ 5 - 5
main-app/src/router/index.js

@@ -11,15 +11,15 @@ const router = createRouter({
       component: () => import('@/views/HomeView.vue'),
     },
     {
-      path: '/web',
+      // /web 下所有子路径(/web/login、/web/list 等)都映射到同一个承载容器,
+      // qiankun 会接管子应用自身的路由
+      path: '/web/:pathMatch(.*)*',
       name: 'web-host',
-      // PC 子应用承载容器(activeRule: '/web' 与之对应)
       component: () => import('@/views/WebHost.vue'),
     },
     {
-      path: '/mobile',
+      path: '/mobile/:pathMatch(.*)*',
       name: 'mobile-host',
-      // 移动端子应用承载容器(activeRule: '/mobile' 与之对应)
       component: () => import('@/views/MobileHost.vue'),
     },
   ],
@@ -34,4 +34,4 @@ router.beforeEach((to, _from, next) => {
   next()
 })
 
-export default router
+export default router

+ 1 - 1
web-app/index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>PC 业务端</title>
+    <title>南银理财 · 投研平台</title>
   </head>
   <body>
     <div id="app"></div>

+ 29 - 11
web-app/src/App.vue

@@ -1,16 +1,34 @@
 <template>
-  <div class="web-app">
-    <aside class="side-nav">
-      <h3>PC 业务端</h3>
-      <router-link to="/">工作台</router-link>
-      <router-link to="/list">业务列表</router-link>
-    </aside>
-    <section class="content">
-      <router-view />
-    </section>
+  <div :class="['web-app', layoutClass]">
+    <template v-if="isAppLayout">
+      <div class="app-shell">
+        <TopNavBar @logout="onLogout" />
+      </div>
+    </template>
+    <router-view v-else />
   </div>
 </template>
 
 <script setup>
-// PC 端子应用根组件
-</script>
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useAuth } from '@/store/auth'
+import { useSyncDocumentTitle } from '@/composables/useSyncDocumentTitle'
+import TopNavBar from '@/components/TopNavBar.vue'
+
+const route = useRoute()
+const router = useRouter()
+const { logout } = useAuth()
+
+// 跟随路由同步浏览器 title
+useSyncDocumentTitle()
+
+// meta.layout: 'blank' => 全屏独立布局(登录页);其他 => 带侧边栏的业务布局
+const isAppLayout = computed(() => route.meta?.layout !== 'blank')
+const layoutClass = computed(() => (isAppLayout.value ? 'layout-app' : 'layout-blank'))
+
+function onLogout() {
+  logout()
+  router.replace('/login')
+}
+</script>

二進制
web-app/src/assets/banner_logo.png


+ 76 - 0
web-app/src/assets/login/tokens.css

@@ -0,0 +1,76 @@
+/**
+ * 登录页设计 token —— 颜色 / 阴影 / 字号等基础变量。
+ * 子应用内独立维护,不依赖母应用或全局样式。
+ */
+
+:root {
+  /* 主色 */
+  --login-nav: #0C2B5E;
+  --login-nav-lt: #143A80;
+  --login-brand: #1552D0;
+  --login-brand-d: #0E3A96;
+  --login-brand-lt: #EAF1FF;
+
+  /* 中性 */
+  --login-bg: #F2F5FA;
+  --login-card: #FFFFFF;
+  --login-line: #E4E9F2;
+  --login-line-d: #D1D8E5;
+  --login-ink: #1A2438;
+  --login-mut: #6B7A96;
+  --login-faint: #98A5BD;
+
+  /* 状态色 */
+  --login-up: #E13B3B;
+  --login-down: #189A65;
+  --login-warn: #E8912D;
+  --login-ok: #18A05E;
+
+  /* 河图传统色 */
+  --login-gold: #C8A95F;
+  --login-gold-d: #A88840;
+  --login-water: #234C8C;
+  --login-fire: #B53A2C;
+  --login-wood: #3F7E6B;
+  --login-metal: #D2B45A;
+
+  /* 字号阶梯 */
+  --login-fs-xs: 10px;
+  --login-fs-sm: 11px;
+  --login-fs-base: 13px;
+  --login-fs-md: 14px;
+  --login-fs-lg: 22px;
+  --login-fs-xl: 30px;
+
+  /* 间距 */
+  --login-gap-xs: 6px;
+  --login-gap-sm: 14px;
+  --login-gap-md: 24px;
+  --login-gap-lg: 42px;
+
+  /* 圆角 / 阴影 */
+  --login-radius-sm: 6px;
+  --login-radius-md: 10px;
+  --login-shadow-card: 0 8px 32px rgba(16, 32, 64, 0.06),
+                       0 1px 3px rgba(16, 32, 64, 0.04);
+  --login-shadow-btn: 0 4px 12px rgba(21, 82, 208, 0.25);
+  --login-shadow-btn-hover: 0 6px 16px rgba(21, 82, 208, 0.32);
+
+  /* 字体 */
+  --login-font-sans: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
+  --login-font-serif: "Songti SC", "Noto Serif SC", serif;
+}
+
+@media (max-width: 1100px) {
+  :root {
+    --login-fs-lg: 26px;
+    --login-fs-xl: 28px;
+  }
+}
+
+@media (max-width: 860px) {
+  :root {
+    --login-fs-lg: 20px;
+    --login-fs-xl: 24px;
+  }
+}

+ 39 - 0
web-app/src/assets/main.css

@@ -15,6 +15,27 @@ body {
   min-height: 100vh;
 }
 
+/* 业务布局外壳:顶部导航 + 主体(侧边栏 + 内容区) */
+.app-shell {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  min-height: 100vh;
+}
+
+.app-body {
+  display: flex;
+  flex: 1;
+  min-height: 0;
+}
+
+/* blank 布局(登录页):避免作为 flex item 收缩到内容宽度,
+   否则页面只占视口一部分、右侧露出背景空白 */
+.layout-blank {
+  display: block;
+  width: 100%;
+}
+
 .side-nav {
   width: 200px;
   padding: 16px 12px;
@@ -41,6 +62,24 @@ body {
   color: #fff;
 }
 
+.side-nav .logout-btn {
+  margin-top: 16px;
+  width: 100%;
+  padding: 8px 14px;
+  font-size: 13px;
+  color: #d1d5db;
+  background: transparent;
+  border: 1px solid #374151;
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.side-nav .logout-btn:hover {
+  background: #b91c1c;
+  border-color: #b91c1c;
+  color: #fff;
+}
+
 .content {
   flex: 1;
   padding: 24px;

+ 244 - 0
web-app/src/components/TopNavBar.vue

@@ -0,0 +1,244 @@
+<template>
+  <header class="topbar">
+    <div class="brand">
+      <div class="brand-logo">投</div>
+      <span class="brand-name">投研平台</span>
+    </div>
+
+    <nav class="nav-main">
+      <router-link to="/">首页</router-link>
+      <a class="on">基本面</a>
+      <a>政策面</a>
+      <a>资金面</a>
+      <a>债市分析</a>
+      <a>权益分析</a>
+      <a>多资产</a>
+      <a>研报</a>
+      <a>模型工具</a>
+    </nav>
+
+    <div class="search-box">
+      <input placeholder="搜索股票/债券/基金/研报/指标..." />
+    </div>
+
+    <div class="top-actions">
+      <div class="icon-btn">⚙</div>
+      <div class="icon-btn">🔔<span class="badge-dot"></span></div>
+      <div class="user-info" @click="$emit('logout')">
+        <div class="user-avatar">{{ avatarText }}</div>
+        <div class="meta">
+          <div>{{ displayName }}</div>
+          <div class="role">{{ roleText }}</div>
+        </div>
+      </div>
+    </div>
+  </header>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useAuth } from '@/store/auth'
+
+defineEmits(['logout'])
+
+const { auth } = useAuth()
+
+const displayName = computed(() => auth.account || '栗国斌')
+const avatarText = computed(() => (auth.account ? auth.account.charAt(0) : '栗'))
+const roleText = '投顾'
+</script>
+
+<style scoped>
+.topbar {
+  --brand: #1552d0;
+  --brand-d: #0e3a96;
+  --brand-lt: #eaf1ff;
+  --ink: #1a2438;
+  --mut: #6b7a96;
+  --faint: #98a5bd;
+  --line: #e4e9f2;
+  --up: #e13b3b;
+
+  height: 56px;
+  flex: none;
+  display: flex;
+  align-items: center;
+  padding: 0 20px;
+  gap: 18px;
+  background: #fff;
+  border-bottom: 1px solid var(--line);
+  z-index: 10;
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: var(--ink);
+  font-weight: 600;
+}
+
+.brand-logo {
+  width: 30px;
+  height: 30px;
+  background: linear-gradient(135deg, var(--brand), #0c2b5e);
+  border-radius: 7px;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 700;
+  letter-spacing: 0.5px;
+}
+
+.brand-name {
+  font-size: 15px;
+  letter-spacing: 0.5px;
+}
+
+.nav-main {
+  display: flex;
+  gap: 6px;
+  margin-left: 18px;
+}
+
+.nav-main a {
+  padding: 7px 14px;
+  border-radius: 6px;
+  font-size: 13px;
+  color: var(--mut);
+  transition: 0.15s;
+  cursor: pointer;
+}
+
+.nav-main a:hover {
+  background: #f0f3f8;
+  color: var(--ink);
+}
+
+.nav-main a.on {
+  background: var(--brand-lt);
+  color: var(--brand-d);
+  font-weight: 600;
+}
+
+.search-box {
+  position: relative;
+  flex: 0 1 300px;
+  margin-left: 14px;
+}
+
+.search-box input {
+  width: 100%;
+  height: 32px;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 0 12px 0 32px;
+  background: #fafbfe;
+  font-size: 12.5px;
+  font-family: inherit;
+  outline: none;
+  color: var(--ink);
+  transition: 0.15s;
+}
+
+.search-box input:focus {
+  border-color: var(--brand);
+  background: #fff;
+  box-shadow: 0 0 0 3px rgba(21, 82, 208, 0.08);
+}
+
+.search-box::before {
+  content: '';
+  position: absolute;
+  left: 11px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 14px;
+  height: 14px;
+  border: 1.6px solid var(--mut);
+  border-radius: 50%;
+}
+
+.search-box::after {
+  content: '';
+  position: absolute;
+  left: 6px;
+  top: 11px;
+  width: 7px;
+  height: 1.6px;
+  background: var(--mut);
+  transform: rotate(45deg);
+}
+
+.top-actions {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.icon-btn {
+  width: 32px;
+  height: 32px;
+  border-radius: 6px;
+  display: grid;
+  place-items: center;
+  cursor: pointer;
+  color: var(--mut);
+  position: relative;
+  font-size: 14px;
+}
+
+.icon-btn:hover {
+  background: #f0f3f8;
+  color: var(--ink);
+}
+
+.icon-btn .badge-dot {
+  position: absolute;
+  top: 6px;
+  right: 6px;
+  width: 6px;
+  height: 6px;
+  background: var(--up);
+  border-radius: 50%;
+  border: 1.5px solid #fff;
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 8px;
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.user-info:hover {
+  background: #f0f3f8;
+}
+
+.user-avatar {
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #5ba0f2, #1552d0);
+  color: #fff;
+  display: grid;
+  place-items: center;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.user-info .meta {
+  font-size: 12px;
+  color: var(--ink);
+  line-height: 1.3;
+}
+
+.user-info .meta .role {
+  font-size: 10.5px;
+  color: var(--faint);
+}
+</style>

+ 136 - 0
web-app/src/components/login/HetuPattern.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="hetu">
+    <svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+      <defs>
+        <radialGradient id="bgHetu" cx=".5" cy=".5">
+          <stop offset="0" stop-color="#FFFFFF" />
+          <stop offset=".85" stop-color="#FFFFFF" stop-opacity=".5" />
+          <stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
+        </radialGradient>
+      </defs>
+
+      <circle cx="200" cy="200" r="180" fill="url(#bgHetu)" class="hetu__ring hetu__ring--strong" />
+      <circle cx="200" cy="200" r="150" fill="none" class="hetu__ring hetu__ring--mid" />
+      <circle cx="200" cy="200" r="195" fill="none" class="hetu__ring hetu__ring--dash" />
+
+      <text v-for="c in directions" :key="c.text"
+        :y="c.y" :x="c.x" text-anchor="middle"
+        class="hetu__label" font-size="13" font-family="'Songti SC',serif"
+        letter-spacing="4" font-weight="500">{{ c.text }}</text>
+
+      <circle v-for="d in dotPoints" :key="`dot-${d.cx}-${d.cy}`"
+        :cx="d.cx" :cy="d.cy" r="3" :fill="d.fill" />
+
+      <g v-for="(g, gi) in groups" :key="gi" :transform="`translate(${g.x},${g.y})`" :fill="g.fill">
+        <circle v-for="(c, ci) in g.circles" :key="ci" :cx="c.x" :cy="c.y" :r="c.r" :fill="c.fill" />
+      </g>
+      <text v-for="(cap, ci) in captions" :key="`cap-${ci}`"
+        :x="cap.x" :y="cap.y" text-anchor="middle"
+        class="hetu__caption" font-size="11" letter-spacing="3" font-weight="500">{{ cap.text }}</text>
+
+      <g class="hetu__center">
+        <circle r="9" fill="var(--login-gold)" />
+        <circle r="6" fill="#FFFFFF" />
+        <text y="4" text-anchor="middle" fill="#8C1810" font-size="11" font-weight="700">五</text>
+        <circle v-for="(c, ci) in centerDots" :key="ci" :cx="c.x" :cy="c.y" r="3.2" />
+      </g>
+
+      <g class="hetu__tick" fill="none">
+        <line x1="200" y1="14" x2="200" y2="22" />
+        <line x1="200" y1="378" x2="200" y2="386" />
+        <line x1="378" y1="200" x2="386" y2="200" />
+        <line x1="14" y1="200" x2="22" y2="200" />
+      </g>
+    </svg>
+  </div>
+</template>
+
+<script setup>
+const directions = [
+  { text: '北', x: 200, y: 38 },
+  { text: '南', x: 200, y: 378 },
+  { text: '东', x: 365, y: 206 },
+  { text: '西', x: 35, y: 206 },
+]
+
+const dotPoints = [
+  { cx: 200, cy: 105, fill: 'var(--login-water)' },
+  { cx: 200, cy: 295, fill: 'var(--login-fire)' },
+  { cx: 295, cy: 200, fill: 'var(--login-wood)' },
+  { cx: 105, cy: 200, fill: 'var(--login-metal)' },
+]
+
+// 河图四象数据:每象 5 黑 + 10 白/金 圆点
+const groups = [
+  { x: 200, y: 100, fill: 'var(--login-ink)', circles: [
+    { x: -12, y: 0, r: 5.5, fill: 'var(--login-ink)' },
+    { x: 0, y: -12, r: 5.5, fill: 'var(--login-ink)' },
+    { x: 0, y: 0, r: 5.5, fill: 'var(--login-ink)' },
+    { x: 0, y: 12, r: 5.5, fill: 'var(--login-ink)' },
+    { x: 12, y: 0, r: 5.5, fill: 'var(--login-ink)' },
+    ...goldDots([-18, -6, -6, -6, 6, 6, 18], [0, -12, 0, 12, -12, 12, 0]),
+  ] },
+  { x: 200, y: 300, fill: 'var(--login-gold)', circles: [
+    { x: -7, y: -7, r: 5.5 }, { x: 7, y: 7, r: 5.5 },
+    ...goldDots([-21, -7, 0, 7, 21, -7, 7], [0, -21, 0, 21, 0, 21, -21]),
+  ] },
+  { x: 300, y: 200, fill: 'var(--login-gold)', circles: [
+    { x: -7, y: 0, r: 5.5 }, { x: 7, y: -7, r: 5.5 }, { x: 7, y: 7, r: 5.5 },
+    ...goldDots([-21, -21, -7, -7, -7, 7, 21, 21], [-7, 7, -21, 0, 21, 0, -7, 7]),
+  ] },
+  { x: 100, y: 200, fill: 'var(--login-gold)', circles: [
+    { x: -7, y: -7, r: 5.5 }, { x: 7, y: -7, r: 5.5 },
+    { x: -7, y: 7, r: 5.5 }, { x: 7, y: 7, r: 5.5 },
+    ...goldDots(
+      [-21, -21, -21, -7, 7, -7, 7, -7, 7, 21, 21, 21],
+      [-12, 0, 12, -21, -21, 0, 0, 21, 21, -12, 0, 12],
+    ),
+  ] },
+]
+
+const captions = [
+  { x: 200, y: 156, text: '一 · 六' },
+  { x: 200, y: 356, text: '二 · 七' },
+  { x: 300, y: 252, text: '三 · 八' },
+  { x: 100, y: 252, text: '四 · 九' },
+]
+
+const centerDots = [
+  { x: -22, y: -22 }, { x: 0, y: -22 }, { x: 22, y: -22 },
+  { x: -22, y: 0 }, { x: 22, y: 0 },
+  { x: -22, y: 22 }, { x: 0, y: 22 }, { x: 22, y: 22 },
+]
+
+function goldDots(xs, ys) {
+  return xs.map((x, i) => ({ x, y: ys[i], r: 4, fill: 'var(--login-gold)' }))
+}
+</script>
+
+<style scoped>
+.hetu {
+  width: clamp(200px, 30vw, 340px);
+  aspect-ratio: 1 / 1;
+  margin: 0 auto;
+}
+
+.hetu svg {
+  width: 100%;
+  height: 100%;
+  filter: drop-shadow(0 8px 24px rgba(16, 32, 64, 0.08));
+}
+
+.hetu__ring {
+  stroke: var(--login-gold);
+}
+
+.hetu__ring--strong { stroke-opacity: 0.5; stroke-width: 1.5; fill: url(#bgHetu); }
+.hetu__ring--mid    { stroke-opacity: 0.25; stroke-width: 0.6; }
+.hetu__ring--dash   { stroke-opacity: 0.18; stroke-width: 0.6; stroke-dasharray: 2 3; }
+
+.hetu__label,
+.hetu__caption { fill: #8C7304; }
+
+.hetu__center circle:last-of-type { fill: var(--login-gold); }
+
+.hetu__tick { stroke: var(--login-gold); stroke-opacity: 0.4; stroke-width: 1.2; }
+</style>

+ 171 - 0
web-app/src/components/login/LoginBrandSide.vue

@@ -0,0 +1,171 @@
+<template>
+  <section class="brand">
+    <div class="brand__stars" />
+    <svg class="brand__mtn" viewBox="0 0 800 800" preserveAspectRatio="none" aria-hidden="true">
+      <defs>
+        <linearGradient id="mtnL" x1="0" y1="0" x2="0" y2="1">
+          <stop offset="0" stop-color="#D1D8E5" stop-opacity="0.5" />
+          <stop offset="1" stop-color="#D1D8E5" stop-opacity="0" />
+        </linearGradient>
+        <linearGradient id="mtnL2" x1="0" y1="0" x2="0" y2="1">
+          <stop offset="0" stop-color="#B5C0D5" stop-opacity="0.4" />
+          <stop offset="1" stop-color="#B5C0D5" stop-opacity="0" />
+        </linearGradient>
+      </defs>
+      <path d="M0,620 Q150,540 320,580 T620,560 T800,590 L800,800 L0,800 Z" fill="url(#mtnL)" />
+      <path d="M0,690 Q200,640 400,680 T800,670 L800,700 L0,700 Z" fill="url(#mtnL2)" />
+    </svg>
+
+    <div class="brand__top">
+      <img class="brand__logo" :src="logoUrl" alt="南银理财" />
+    </div>
+
+    <div class="brand__main">
+      <HetuPattern />
+      <div class="brand__caption">天一生水 · 地六成之 · <span class="brand__accent">河图</span> 启象</div>
+      <div class="brand__slogan">观 <span class="brand__accent">象</span> 于 <span class="brand__accent">势</span></div>
+      <div class="brand__sub">天机云图 · 投研有道</div>
+    </div>
+
+    <div class="brand__foot">南银理财 投资研究系统</div>
+  </section>
+</template>
+
+<script setup>
+import HetuPattern from './HetuPattern.vue'
+import logoUrl from '@/assets/banner_logo.png'
+</script>
+
+<style scoped>
+.brand {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  min-height: clamp(320px, 50vh, 520px);
+  padding: clamp(24px, 4vw, 50px);
+  background: linear-gradient(135deg, #FAFBFE 0%, #EEF2F9 60%, #E5EDF7 100%);
+  overflow: hidden;
+}
+
+@media (min-width: 861px) {
+  .brand { border-right: 1px solid var(--login-line); }
+}
+
+@media (max-width: 860px) {
+  .brand { border-bottom: 1px solid var(--login-line); }
+}
+
+.brand::before,
+.brand::after {
+  content: '';
+  position: absolute;
+  pointer-events: none;
+  border-radius: 50%;
+  filter: blur(60px);
+}
+
+.brand::before {
+  width: 380px;
+  height: 380px;
+  background: radial-gradient(circle, rgba(21, 82, 208, 0.1), transparent 70%);
+  top: -80px;
+  left: -80px;
+}
+
+.brand::after {
+  width: 420px;
+  height: 420px;
+  background: radial-gradient(circle, rgba(200, 169, 95, 0.18), transparent 70%);
+  bottom: -100px;
+  right: -100px;
+}
+
+.brand__stars {
+  position: absolute;
+  inset: 0;
+  background-image: radial-gradient(circle, rgba(21, 82, 208, 0.07) 1px, transparent 1.5px);
+  background-size: 24px 24px;
+  pointer-events: none;
+}
+
+.brand__mtn {
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  pointer-events: none;
+  opacity: 0.5;
+}
+
+.brand__top {
+  position: absolute;
+  top: clamp(20px, 3vw, 32px);
+  left: clamp(24px, 4vw, 40px);
+  z-index: 2;
+}
+
+.brand__logo {
+  height: clamp(28px, 3.5vw, 38px);
+  width: auto;
+  display: block;
+}
+
+.brand__main {
+  position: relative;
+  z-index: 2;
+  text-align: center;
+  margin-top: clamp(-10px, -2vw, -30px);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: clamp(8px, 1.5vw, 24px);
+}
+
+.brand__caption,
+.brand__slogan,
+.brand__sub {
+  font-family: var(--login-font-serif);
+}
+
+.brand__caption {
+  font-size: clamp(11px, 1vw, 12px);
+  letter-spacing: clamp(4px, 0.8vw, 8px);
+  color: var(--login-mut);
+}
+
+.brand__slogan {
+  font-size: clamp(20px, 2.5vw, 30px);
+  font-weight: 600;
+  letter-spacing: clamp(4px, 0.8vw, 8px);
+  line-height: 1.4;
+  color: var(--login-ink);
+}
+
+.brand__sub {
+  font-size: clamp(11px, 1vw, 12px);
+  letter-spacing: clamp(3px, 0.6vw, 6px);
+  color: var(--login-mut);
+  margin-top: clamp(-4px, -0.5vw, -8px);
+}
+
+.brand__accent {
+  color: var(--login-brand-d);
+  font-weight: 700;
+}
+
+.brand__caption .brand__accent { font-weight: 600; }
+
+.brand__foot {
+  position: absolute;
+  bottom: clamp(20px, 3vw, 32px);
+  left: 0;
+  right: 0;
+  text-align: center;
+  font-size: clamp(9px, 0.8vw, 10px);
+  color: var(--login-mut);
+  letter-spacing: clamp(3px, 0.5vw, 5px);
+  z-index: 2;
+}
+</style>

+ 256 - 0
web-app/src/components/login/LoginFormCard.vue

@@ -0,0 +1,256 @@
+<template>
+  <section class="form-side">
+    <div class="form-card">
+      <i class="corner tl" />
+      <i class="corner tr" />
+      <i class="corner bl" />
+      <i class="corner br" />
+
+      <div class="form-card__hd">
+        <SystemMark />
+        <h1 class="form-card__title">投资研究系统</h1>
+        <div class="form-card__sub">INVESTMENT RESEARCH SYSTEM</div>
+      </div>
+
+      <form class="form" @submit.prevent="emit('submit')">
+        <div class="field">
+          <label for="login-account">工 号 / 邮 箱</label>
+          <input
+            id="login-account"
+            :value="account"
+            class="input"
+            type="text"
+            placeholder="请输入工号或邮箱"
+            autocomplete="username"
+            @input="emit('update:account', $event.target.value)"
+          />
+          <svg class="field__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
+            <circle cx="8" cy="5" r="3" />
+            <path d="M2 14 C2 10 5 8 8 8 C11 8 14 10 14 14" />
+          </svg>
+        </div>
+
+        <div class="field">
+          <label for="login-password">密 码</label>
+          <input
+            id="login-password"
+            :value="password"
+            class="input"
+            :type="showPwd ? 'text' : 'password'"
+            placeholder="请输入登录密码"
+            autocomplete="current-password"
+            @input="emit('update:password', $event.target.value)"
+          />
+          <svg class="field__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
+            <rect x="3" y="7" width="10" height="7" rx="1" />
+            <path d="M5 7 V5 C5 3 6 2 8 2 C10 2 11 3 11 5 V7" />
+          </svg>
+          <button type="button" class="field__toggle" @click="showPwd = !showPwd">
+            {{ showPwd ? '隐藏' : '显示' }}
+          </button>
+        </div>
+
+        <button class="btn-primary" type="submit">登 录 投 研</button>
+      </form>
+
+      <div class="form-card__foot">
+        <div class="form-card__status">
+          <span class="form-card__dot" />
+          <span>系统服务正常</span>
+        </div>
+        <span>南银理财 · 市场研究部</span>
+        <span>v 1.0.0 · 2026</span>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import SystemMark from './SystemMark.vue'
+
+defineProps({
+  account: { type: String, default: '' },
+  password: { type: String, default: '' },
+})
+const emit = defineEmits(['update:account', 'update:password', 'submit'])
+const showPwd = ref(false)
+</script>
+
+<style scoped>
+.form-side {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: clamp(20px, 4vw, 40px);
+  width: 100%;
+}
+
+.form-card {
+  position: relative;
+  width: 100%;
+  max-width: 440px;
+  background: var(--login-card);
+  border: 1px solid var(--login-line);
+  border-radius: var(--login-radius-md);
+  padding: clamp(24px, 4vw, 42px);
+  box-shadow: var(--login-shadow-card);
+}
+
+.form-card::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 32px;
+  right: 32px;
+  height: 3px;
+  background: linear-gradient(
+    to right,
+    transparent,
+    var(--login-gold),
+    var(--login-brand),
+    var(--login-gold),
+    transparent
+  );
+  border-radius: 0 0 2px 2px;
+}
+
+.corner {
+  position: absolute;
+  width: 18px;
+  height: 18px;
+  border-color: var(--login-gold);
+}
+
+.corner.tl { top: 12px; left: 12px; border-top: 1.5px solid; border-left: 1.5px solid; }
+.corner.tr { top: 12px; right: 12px; border-top: 1.5px solid; border-right: 1.5px solid; }
+.corner.bl { bottom: 12px; left: 12px; border-bottom: 1.5px solid; border-left: 1.5px solid; }
+.corner.br { bottom: 12px; right: 12px; border-bottom: 1.5px solid; border-right: 1.5px solid; }
+
+.form-card__hd { text-align: center; margin-bottom: clamp(18px, 3vw, 30px); }
+
+.form-card__title {
+  font-family: var(--login-font-serif);
+  font-size: clamp(18px, 2vw, 22px);
+  font-weight: 700;
+  letter-spacing: 6px;
+  color: var(--login-ink);
+  margin: 14px 0 4px;
+}
+
+.form-card__sub {
+  font-size: clamp(10px, 0.9vw, 11px);
+  letter-spacing: 5px;
+  color: var(--login-mut);
+}
+
+.form { display: block; }
+
+.field { margin-bottom: 14px; position: relative; }
+
+.field label {
+  display: block;
+  font-size: clamp(10px, 0.9vw, 11px);
+  letter-spacing: 2px;
+  color: var(--login-mut);
+  margin-bottom: 6px;
+  font-weight: 500;
+}
+
+.input {
+  width: 100%;
+  height: 42px;
+  background: #FAFBFE;
+  border: 1px solid var(--login-line);
+  border-radius: var(--login-radius-sm);
+  padding: 0 38px;
+  color: var(--login-ink);
+  font-size: clamp(12px, 1vw, 13px);
+  outline: none;
+  font-family: inherit;
+  transition: 0.2s;
+}
+
+.input::placeholder { color: var(--login-faint); }
+
+.input:focus {
+  border-color: var(--login-brand);
+  background: var(--login-card);
+  box-shadow: 0 0 0 3px rgba(21, 82, 208, 0.08);
+}
+
+.field__icon {
+  position: absolute;
+  left: 13px;
+  bottom: 13px;
+  color: var(--login-mut);
+  pointer-events: none;
+  width: 16px;
+  height: 16px;
+}
+
+.field__toggle {
+  position: absolute;
+  right: 14px;
+  bottom: 13px;
+  background: none;
+  border: none;
+  padding: 0;
+  color: var(--login-mut);
+  font-size: clamp(10px, 0.9vw, 11px);
+  cursor: pointer;
+}
+
+.field__toggle:hover { color: var(--login-brand-d); }
+
+.btn-primary {
+  width: 100%;
+  height: 44px;
+  border: none;
+  border-radius: var(--login-radius-sm);
+  cursor: pointer;
+  background: linear-gradient(135deg, var(--login-brand) 0%, var(--login-brand-d) 100%);
+  color: #fff;
+  font-size: clamp(13px, 1vw, 14px);
+  font-weight: 600;
+  letter-spacing: 6px;
+  font-family: inherit;
+  box-shadow: var(--login-shadow-btn);
+  transition: 0.2s;
+}
+
+.btn-primary:hover {
+  filter: brightness(1.08);
+  transform: translateY(-1px);
+  box-shadow: var(--login-shadow-btn-hover);
+}
+
+.btn-primary:active { transform: translateY(0); }
+
+.form-card__foot {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+  font-size: clamp(10px, 0.9vw, 11px);
+  color: var(--login-mut);
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px dashed var(--login-line);
+}
+
+.form-card__status { display: flex; align-items: center; gap: 6px; }
+
+.form-card__dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--login-ok);
+  box-shadow: 0 0 6px var(--login-ok);
+  animation: login-pulse 2s ease-in-out infinite;
+}
+
+@keyframes login-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
+</style>

+ 36 - 0
web-app/src/components/login/SystemMark.vue

@@ -0,0 +1,36 @@
+<template>
+  <svg class="system-mark" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+    <circle cx="50" cy="50" r="46" fill="#FAFBFE" class="ms__outer" />
+    <circle cx="50" cy="50" r="36" fill="none" class="ms__dash" />
+    <g class="ms__dots">
+      <circle cx="50" cy="26" r="3.5" />
+      <circle cx="46" cy="74" r="3.5" />
+      <circle cx="54" cy="74" r="3.5" />
+      <circle cx="74" cy="46" r="3.5" />
+      <circle cx="74" cy="54" r="3.5" />
+      <circle cx="74" cy="62" r="3.5" />
+      <circle cx="26" cy="42" r="3.5" />
+      <circle cx="26" cy="50" r="3.5" />
+      <circle cx="26" cy="58" r="3.5" />
+      <circle cx="26" cy="66" r="3.5" />
+    </g>
+    <circle cx="50" cy="50" r="7" class="ms__core" />
+    <circle cx="50" cy="50" r="4.5" fill="#FFFFFF" />
+    <text x="50" y="50" text-anchor="middle" fill="#8C1810" font-size="7"
+      font-weight="700" dy="2">五</text>
+  </svg>
+</template>
+
+<style scoped>
+.system-mark {
+  width: clamp(48px, 5vw, 64px);
+  height: clamp(48px, 5vw, 64px);
+  display: block;
+  margin: 0 auto;
+}
+
+.ms__outer { stroke: var(--login-gold); stroke-opacity: 0.7; stroke-width: 1.2; }
+.ms__dash  { stroke: var(--login-gold); stroke-opacity: 0.3; stroke-width: 0.6; stroke-dasharray: 2 2; }
+.ms__dots  { fill: var(--login-gold); }
+.ms__core  { fill: var(--login-gold); }
+</style>

+ 43 - 0
web-app/src/composables/useSyncDocumentTitle.js

@@ -0,0 +1,43 @@
+import { onBeforeUnmount, watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+// 子应用挂载时记下宿主页面的原始 title,卸载时还原,
+// 这样切换到其他子应用 / 返回主应用时浏览器标签不会残留子应用标题。
+const ORIGINAL_TITLE = typeof document !== 'undefined' ? document.title : ''
+
+// 子应用统一 title 模板:`<页面> · <子应用名> · <平台>`
+const APP_NAME = '南银理财 · 投研平台'
+
+function resolveTitle(route) {
+  // 优先取最近的 meta.title,缺省回退到应用名
+  const pageTitle = route.meta?.title
+  return pageTitle ? `${pageTitle} · ${APP_NAME}` : APP_NAME
+}
+
+/**
+ * 同步浏览器 title 到当前路由:
+ * 1. afterEach / watch 触发时立即写 document.title;
+ * 2. 组件卸载时还原宿主原 title(避免子应用卸载后浏览器 tab 仍是子页面)。
+ *
+ * 推荐在 App.vue 里调用一次即可,会自动跟随路由切换。
+ */
+export function useSyncDocumentTitle() {
+  const route = useRoute()
+
+  watch(
+    () => route.fullPath,
+    () => {
+      document.title = resolveTitle(route)
+    },
+    { immediate: true },
+  )
+
+  onBeforeUnmount(() => {
+    document.title = ORIGINAL_TITLE
+  })
+}
+
+/** 在子应用 qiankun unmount 时手动调用一次,避免某些情况下 watcher 没机会触发 */
+export function restoreOriginalTitle() {
+  if (typeof document !== 'undefined') document.title = ORIGINAL_TITLE
+}

+ 4 - 1
web-app/src/main.js

@@ -3,6 +3,7 @@ import App from './App.vue'
 import router from './router'
 import './assets/main.css'
 import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
+import { restoreOriginalTitle } from '@/composables/useSyncDocumentTitle'
 
 let instance = null
 
@@ -29,6 +30,8 @@ renderWithQiankun({
   },
   unmount() {
     console.log('[web-app] unmount')
+    // 卸载前还原宿主页面 title,避免浏览器 tab 残留子应用标题
+    restoreOriginalTitle()
     instance?.unmount()
     instance = null
   },
@@ -37,4 +40,4 @@ renderWithQiankun({
 // 独立运行时直接渲染(便于单独调试该子应用)
 if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
   render()
-}
+}

+ 44 - 3
web-app/src/router/index.js

@@ -1,13 +1,54 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
+import { useAuth } from '@/store/auth'
 
 // 子应用在 qiankun 环境下运行时,路由 base 采用其部署前缀 /web/
 const router = createRouter({
   history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/web/' : '/'),
   routes: [
-    { path: '/', name: 'home', component: () => import('@/views/Home.vue') },
-    { path: '/list', name: 'list', component: () => import('@/views/List.vue') },
+    {
+      path: '/login',
+      name: 'login',
+      component: () => import('@/views/Login.vue'),
+      meta: { layout: 'blank', public: true, title: '登录' },
+    },
+    {
+      path: '/',
+      name: 'home',
+      component: () => import('@/views/Home.vue'),
+      meta: { layout: 'app', title: '工作台' },
+    },
+    {
+      path: '/list',
+      name: 'list',
+      component: () => import('@/views/List.vue'),
+      meta: { layout: 'app', title: '业务列表' },
+    },
   ],
 })
 
-export default router
+// 全局守卫:未登录访问任意非公开路由 → 跳 /login;已登录访问 /login → 跳 /
+// 同时把基础布局元信息同步到 matched record(beforeEach 触发时已 matched)
+router.beforeEach((to) => {
+  const { isLoggedIn } = useAuth()
+  const isPublic = to.matched.some((r) => r.meta?.public)
+
+  if (!isLoggedIn() && !isPublic) {
+    return { path: '/login', query: { redirect: to.fullPath } }
+  }
+  if (isLoggedIn() && to.path === '/login') {
+    return { path: '/' }
+  }
+  return true
+})
+
+// 路由切换后立即同步浏览器 title(兜底,App.vue 里也会同步一次)
+router.afterEach((to) => {
+  if (typeof document === 'undefined') return
+  const pageTitle = to.meta?.title
+  document.title = pageTitle
+    ? `${pageTitle} · 南银理财 · 投研平台`
+    : '南银理财 · 投研平台'
+})
+
+export default router

+ 52 - 0
web-app/src/store/auth.js

@@ -0,0 +1,52 @@
+import { reactive, readonly } from 'vue'
+
+const STORAGE_KEY = 'web-app.auth'
+
+function loadFromStorage() {
+  if (typeof window === 'undefined') return null
+  try {
+    const raw = window.localStorage.getItem(STORAGE_KEY)
+    return raw ? JSON.parse(raw) : null
+  } catch {
+    return null
+  }
+}
+
+function saveToStorage(payload) {
+  if (typeof window === 'undefined') return
+  try {
+    if (payload) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
+    else window.localStorage.removeItem(STORAGE_KEY)
+  } catch {
+    // 忽略 storage 异常(隐私模式 / 容量满)
+  }
+}
+
+const state = reactive({
+  token: '',
+  account: '',
+  // 登录态由 token 是否存在判定;isLoggedIn 为派生只读值
+})
+
+const persisted = loadFromStorage()
+if (persisted?.token) {
+  state.token = persisted.token
+  state.account = persisted.account || ''
+}
+
+export function useAuth() {
+  return {
+    auth: readonly(state),
+    isLoggedIn: () => Boolean(state.token),
+    login(payload) {
+      state.token = payload.token
+      state.account = payload.account || ''
+      saveToStorage({ token: state.token, account: state.account })
+    },
+    logout() {
+      state.token = ''
+      state.account = ''
+      saveToStorage(null)
+    },
+  }
+}

+ 64 - 0
web-app/src/views/Login.vue

@@ -0,0 +1,64 @@
+<template>
+  <div class="login-page">
+    <LoginBrandSide class="login-page__brand" />
+    <LoginFormCard
+      class="login-page__form"
+      v-model:account="form.account"
+      v-model:password="form.password"
+      @submit="onSubmit"
+    />
+  </div>
+</template>
+
+<script setup>
+import { reactive } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useAuth } from '@/store/auth'
+import LoginBrandSide from '@/components/login/LoginBrandSide.vue'
+import LoginFormCard from '@/components/login/LoginFormCard.vue'
+import '@/assets/login/tokens.css'
+
+const route = useRoute()
+const router = useRouter()
+const { login } = useAuth()
+
+const form = reactive({
+  account: '',
+  password: '',
+})
+
+function onSubmit() {
+  if (!form.account || !form.password) return
+  login({ token: `mock-${Date.now()}`, account: form.account })
+  const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
+  router.replace(redirect)
+}
+</script>
+
+<style>
+/*
+ * 不使用 scoped:父组件需要把布局类透传到子组件根元素,
+ * scoped 会因 data-v-hash 不匹配而失效,导致小屏无法切换布局。
+ */
+.login-page {
+  display: flex;
+  flex-wrap: wrap;
+  min-height: 100vh;
+  background: var(--login-bg);
+  color: var(--login-ink);
+  font-family: var(--login-font-sans);
+}
+
+.login-page__brand { flex: 1 1 48%; }
+.login-page__form  { flex: 1 1 52%; }
+
+@media (max-width: 1100px) {
+  .login-page__brand { flex-basis: 42%; }
+  .login-page__form  { flex-basis: 58%; }
+}
+
+@media (max-width: 860px) {
+  .login-page__brand,
+  .login-page__form { flex: 1 0 100%; }
+}
+</style>