liujunjie před 2 týdny
rodič
revize
1245c16e38
3 změnil soubory, kde provedl 540 přidání a 70 odebrání
  1. 170 38
      src/SimubusLanding.vue
  2. 40 29
      src/services/auth.js
  3. 330 3
      src/style.css

+ 170 - 38
src/SimubusLanding.vue

@@ -1,6 +1,9 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
 import lottie from 'lottie-web'
+import { clearSession, loadSession, saveSession } from './services/auth'
+
+const AUTH_MOCK_ENABLED = import.meta.env.VITE_AUTH_MOCK !== 'false'
 
 const activeQr = ref(null)
 const previewRefs = ref({})
@@ -8,7 +11,10 @@ const lottieInstances = new Map()
 const currentLocale = ref('en')
 const languageMenuOpen = ref(false)
 const languageMenuRef = ref(null)
+const profileMenuOpen = ref(false)
 const loginModalOpen = ref(false)
+const logoutModalOpen = ref(false)
+const session = ref(loadSession())
 
 const languageOptions = [
   { value: 'en', label: 'English' },
@@ -90,6 +96,7 @@ const scenes = Object.entries(sceneDataModules)
 const heroScene = computed(() => scenes.find((scene) => scene.id === '自锁带点动') ?? null)
 const interactiveScenes = computed(() => scenes.filter((scene) => scene.id === '家庭双控'))
 const simulationScenes = computed(() => scenes.filter((scene) => scene.id === '工业自锁'))
+const isAuthenticated = computed(() => Boolean(session.value?.token))
 
 function setPreviewRef(id, el) {
   if (el) {
@@ -146,18 +153,79 @@ function setLocale(locale) {
 
 function openLoginModal() {
   loginModalOpen.value = true
+  logoutModalOpen.value = false
+  profileMenuOpen.value = false
 }
 
 function closeLoginModal() {
   loginModalOpen.value = false
 }
 
+function openLogoutModal() {
+  logoutModalOpen.value = true
+  profileMenuOpen.value = false
+}
+
+function closeLogoutModal() {
+  logoutModalOpen.value = false
+}
+
+function openProfileMenu() {
+  if (!isAuthenticated.value) return
+  profileMenuOpen.value = true
+}
+
+function closeProfileMenu() {
+  profileMenuOpen.value = false
+}
+
 function handleDocumentClick(event) {
   if (!languageMenuRef.value?.contains(event.target)) {
     languageMenuOpen.value = false
   }
 }
 
+function applyMockLogin(provider) {
+  const nextSession = {
+    token: `mock-${provider}-token`,
+    userId: provider === 'google' ? '10001' : '10002',
+    isNewUser: false,
+    provider,
+    nickname: 'Andy',
+    avatarUrl: 'https://www.figma.com/api/mcp/asset/ad158d05-a000-4d0d-b32d-5e1881f01559',
+    email: '56998882589@gmail.com',
+  }
+  saveSession(nextSession)
+  session.value = nextSession
+  closeLoginModal()
+  openProfileMenu()
+}
+
+function handleGoogleClick() {
+  if (!AUTH_MOCK_ENABLED) return
+  applyMockLogin('google')
+}
+
+function handleAppleClick() {
+  if (!AUTH_MOCK_ENABLED) return
+  applyMockLogin('apple')
+}
+
+function handleProfileLogoutClick() {
+  closeProfileMenu()
+  openLogoutModal()
+}
+
+function confirmLogout() {
+  session.value = null
+  clearSession()
+  closeLogoutModal()
+}
+
+function useProfileLanguage() {
+  setLocale('en')
+}
+
 onMounted(() => {
   mountAllAnimations()
   document.addEventListener('click', handleDocumentClick)
@@ -241,6 +309,15 @@ const copy = {
     modalSuffix: '. New users will be registered automatically.',
     modalGoogle: 'Continue with Google',
     modalApple: 'Continue with Apple',
+    logoutTitle: 'Are you sure you want to log out?',
+    logoutBody: 'Do you want to log out of Simubus as 3180349346@qq.com?',
+    logoutAction: 'Log Out Of The Account',
+    cancel: 'Cancel',
+    profileName: 'Andy',
+    profileEmail: '56998882589@gmail.com',
+    language: 'English',
+    footerTerms: 'Terms',
+    footerPrivacy: 'Privacy Policy',
   },
   zh: {
     login: '登录',
@@ -263,16 +340,16 @@ const copy = {
       temperatureController: '温度控制器',
       automaticTransferSwitch: '双电源自动转换开关',
     },
-    benefitsTitle: '为什么选择Simubus',
+    benefitsTitle: '为什么选择 Simubus',
     benefits: {
-      secure: { title: '安全可靠', body: '你的项目会安全存储在云端,并定期备份。的数据隐私是我们的首要任务。' },
+      secure: { title: '安全可靠', body: '你的项目会安全存储在云端,并定期备份。的数据隐私是我们的首要任务。' },
       professional: { title: '职业级', body: '行业标准的仿真引擎为教育和专业用途提供准确结果。' },
       crossPlatform: { title: '跨平台', body: '在桌面、平板和移动设备上无缝运行。你的模拟会跟随你走到哪里。' },
       cloud: { title: '基于云平台', body: '无需安装。随时随地,直接从浏览器访问强大的模拟工具。' },
     },
     footerSlogan: '你的虚拟电气实验室。',
     quickLinksTitle: '快速链接',
-    quickLinks: ['首页', '隐私政策', '使用条款', '订阅条款', '下载APP'],
+    quickLinks: ['首页', '隐私政策', '使用条款', '订阅条款', '下载 APP'],
     contactTitle: '联系我们',
     contactBody: '有问题或建议吗?我们很期待听到你的声音。',
     copyright: 'Copyright © 2025 Simubus 保留所有权利。',
@@ -288,6 +365,15 @@ const copy = {
     modalSuffix: '。新用户将自动注册。',
     modalGoogle: '使用 Google 继续',
     modalApple: '使用 Apple 继续',
+    logoutTitle: '确定要退出登录吗?',
+    logoutBody: '你要退出 3180349346@qq.com 的 Simubus 账号吗?',
+    logoutAction: '退出当前账号',
+    cancel: '取消',
+    profileName: 'Andy',
+    profileEmail: '56998882589@gmail.com',
+    language: '中文',
+    footerTerms: '使用条款',
+    footerPrivacy: '隐私政策',
   },
 }
 
@@ -301,39 +387,76 @@ const messages = computed(() => copy[currentLocale.value])
         <img class="hero-map" :src="figma.map" alt="" data-node-id="2573:4526" />
       </div>
 
-      <header class="figma-header container">
-        <div class="figma-logo" data-node-id="3059:7206">
-          <img class="figma-logo-icon" :src="figma.logoIcon" alt="" />
-          <img class="figma-logo-text" :src="figma.logoText" alt="Simubus" />
-        </div>
+      <header class="figma-header">
+        <div class="container figma-header__inner">
+          <div class="figma-logo" data-node-id="3059:7206">
+            <img class="figma-logo-icon" :src="figma.logoIcon" alt="" />
+            <img class="figma-logo-text" :src="figma.logoText" alt="Simubus" />
+          </div>
 
-        <div class="header-actions">
-          <button class="login-btn" type="button" @click="openLoginModal">{{ messages.login }}</button>
-
-          <div ref="languageMenuRef" class="language-menu">
-            <button
-              class="language-btn"
-              type="button"
-              aria-haspopup="true"
-              :aria-expanded="languageMenuOpen ? 'true' : 'false'"
-              @click.stop="toggleLanguageMenu"
-            >
-              <span>{{ messages.languageButton }}</span>
-              <span class="language-caret"></span>
-            </button>
-
-            <div v-if="languageMenuOpen" class="language-dropdown">
-              <button
-                v-for="option in languageOptions"
-                :key="option.value"
-                class="language-option"
-                :class="{ active: currentLocale === option.value }"
-                type="button"
-                @click="setLocale(option.value)"
-              >
-                {{ option.label }}
-              </button>
-            </div>
+          <div class="header-actions">
+            <template v-if="isAuthenticated">
+              <div class="header-pro-badge">PRO</div>
+              <div class="header-profile-menu" @mouseenter="openProfileMenu" @mouseleave="closeProfileMenu">
+                <button class="header-avatar-button" type="button" @click="openProfileMenu">
+                  <img class="header-avatar-image" :src="session?.avatarUrl || 'https://www.figma.com/api/mcp/asset/ad158d05-a000-4d0d-b32d-5e1881f01559'" alt="User avatar" />
+                </button>
+
+                <div v-if="profileMenuOpen" class="header-profile-dropdown">
+                  <div class="header-profile-dropdown__card">
+                    <div class="login-modal__profile-avatar-wrap">
+                      <img class="login-modal__profile-avatar" :src="session?.avatarUrl || 'https://www.figma.com/api/mcp/asset/ad158d05-a000-4d0d-b32d-5e1881f01559'" alt="User avatar" />
+                    </div>
+
+                    <h2 class="login-modal__profile-name">{{ session?.nickname || messages.profileName }}</h2>
+                    <p class="login-modal__profile-email">{{ session?.email || messages.profileEmail }}</p>
+
+                    <button class="login-modal__ghost-button" type="button" @click="handleProfileLogoutClick">{{ messages.logoutAction }}</button>
+                    <button class="login-modal__ghost-button login-modal__ghost-button--language" type="button" @click="useProfileLanguage">
+                      <span>{{ messages.language }}</span>
+                      <img class="login-modal__chevron" src="https://www.figma.com/api/mcp/asset/3bc941af-ef23-4f72-85ce-604343b147f1" alt="Open language menu" />
+                    </button>
+
+                    <p class="login-modal__footer-links">
+                      <a href="/">{{ messages.footerTerms }}</a>
+                      <span>·</span>
+                      <a href="/">{{ messages.footerPrivacy }}</a>
+                      <span>·</span>
+                      <a href="/">{{ messages.footerPrivacy }}</a>
+                    </p>
+                  </div>
+                </div>
+              </div>
+            </template>
+            <template v-else>
+              <button class="login-btn" type="button" @click="openLoginModal">{{ messages.login }}</button>
+
+              <div ref="languageMenuRef" class="language-menu">
+                <button
+                  class="language-btn"
+                  type="button"
+                  aria-haspopup="true"
+                  :aria-expanded="languageMenuOpen ? 'true' : 'false'"
+                  @click.stop="toggleLanguageMenu"
+                >
+                  <span>{{ messages.languageButton }}</span>
+                  <span class="language-caret"></span>
+                </button>
+
+                <div v-if="languageMenuOpen" class="language-dropdown">
+                  <button
+                    v-for="option in languageOptions"
+                    :key="option.value"
+                    class="language-option"
+                    :class="{ active: currentLocale === option.value }"
+                    type="button"
+                    @click="setLocale(option.value)"
+                  >
+                    {{ option.label }}
+                  </button>
+                </div>
+              </div>
+            </template>
           </div>
         </div>
       </header>
@@ -474,12 +597,12 @@ const messages = computed(() => copy[currentLocale.value])
 
         <h2 class="login-modal__title">{{ messages.modalTitle }}</h2>
 
-        <button class="login-modal__signin-option" type="button">
+        <button class="login-modal__signin-option" type="button" @click="handleGoogleClick">
           <img class="login-modal__signin-icon" src="/auth-icons/google-icon.svg" alt="Google" />
           <span>{{ messages.modalGoogle }}</span>
         </button>
 
-        <button class="login-modal__signin-option" type="button">
+        <button class="login-modal__signin-option" type="button" @click="handleAppleClick">
           <img class="login-modal__signin-icon" src="/auth-icons/apple-icon.svg" alt="Apple" />
           <span>{{ messages.modalApple }}</span>
         </button>
@@ -493,6 +616,15 @@ const messages = computed(() => copy[currentLocale.value])
         </p>
       </section>
     </div>
+
+    <div v-if="logoutModalOpen" class="login-modal-backdrop" @click="closeLogoutModal">
+      <section class="login-modal login-modal--logout" @click.stop>
+        <button class="login-modal__close" type="button" @click="closeLogoutModal">×</button>
+        <h2 class="login-modal__panel-title">{{ messages.logoutTitle }}</h2>
+        <p class="login-modal__panel-copy">{{ messages.logoutBody }}</p>
+        <button class="login-modal__ghost-button" type="button" @click="confirmLogout">{{ messages.logoutAction }}</button>
+        <button class="login-modal__ghost-button" type="button" @click="closeLogoutModal">{{ messages.cancel }}</button>
+      </section>
+    </div>
   </div>
 </template>
-

+ 40 - 29
src/services/auth.js

@@ -5,6 +5,8 @@ const TOKEN_HEADER_NAME = 'snToken'
 const DEFAULT_PACKAGE_NAME = import.meta.env.VITE_APP_PACKAGE_NAME || 'simubus-web'
 const DEFAULT_CHANNEL = import.meta.env.VITE_APP_CHANNEL || 'official'
 const DEFAULT_VERSION = import.meta.env.VITE_APP_VERSION || '0.0.1'
+const LOGIN_TYPE_GOOGLE = '1'
+const LOGIN_TYPE_APPLE = '2'
 
 function joinUrl(path) {
   if (!API_BASE_URL) {
@@ -27,6 +29,10 @@ function setStoredJson(key, value) {
   localStorage.setItem(key, JSON.stringify(value))
 }
 
+export function saveSession(session) {
+  setStoredJson(TOKEN_STORAGE_KEY, session)
+}
+
 export function getDeviceId() {
   const cached = localStorage.getItem(DEVICE_ID_STORAGE_KEY)
   if (cached) {
@@ -81,40 +87,42 @@ async function request(path, { method = 'POST', body, token } = {}) {
   return payload.data
 }
 
-export async function passwordLogin(credentials, locale) {
-  const basePayload = buildApiPayload(locale)
-  const registerPayload = {
-    ...basePayload,
-    user_name: credentials.username,
-    password: credentials.password,
+export async function socialLogin(
+  {
+    provider,
+    token,
+    uuid = '',
+    nickname = '',
+    avatarUrl = '',
+    email = '',
+  },
+  locale,
+) {
+  const loginType =
+    provider === 'google'
+      ? LOGIN_TYPE_GOOGLE
+      : provider === 'apple'
+        ? LOGIN_TYPE_APPLE
+        : null
+
+  if (!loginType) {
+    throw new Error(`Unsupported provider: ${provider}`)
   }
 
-  let openid
-
-  try {
-    const loginData = await request('/api/sh/user/login', {
-      body: registerPayload,
-    })
-    openid = loginData.openid
-  } catch (error) {
-    const message = String(error?.message || '')
-    if (!message.includes('User information error')) {
-      throw error
-    }
-
-    const registerData = await request('/api/sh/user/register', {
-      body: registerPayload,
-    })
-    openid = registerData.openid
+  if (!token) {
+    throw new Error('Missing login token')
   }
 
+  const basePayload = buildApiPayload(locale)
   const loginData = await request('/api/login/login', {
     body: {
       ...basePayload,
-      type: '3',
-      uuid: openid,
-      token: openid,
-      nickname: credentials.username,
+      type: loginType,
+      uuid,
+      token,
+      nickname,
+      avatar_url: avatarUrl,
+      email,
     },
   })
 
@@ -122,10 +130,13 @@ export async function passwordLogin(credentials, locale) {
     token: loginData.token,
     userId: loginData.user_id,
     isNewUser: Boolean(loginData.is_new_user),
-    username: credentials.username,
+    provider,
+    nickname,
+    avatarUrl,
+    email,
   }
 
-  setStoredJson(TOKEN_STORAGE_KEY, session)
+  saveSession(session)
   return session
 }
 

+ 330 - 3
src/style.css

@@ -1,4 +1,4 @@
-:root {
+:root {
   --green: #07c585;
   --green-soft: #eef8f8;
   --dark: #02150d;
@@ -35,6 +35,10 @@ button {
   background: none;
 }
 
+button:disabled {
+  cursor: not-allowed;
+}
+
 a {
   color: inherit;
   text-decoration: none;
@@ -127,6 +131,21 @@ img {
   gap: 10px;
 }
 
+.login-user-chip {
+  display: inline-flex;
+  align-items: center;
+  max-width: 220px;
+  min-height: 42px;
+  padding: 0 16px;
+  border-radius: 999px;
+  background: #f6f6f6;
+  color: #3b3b3b;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
 .language-menu {
   position: relative;
 }
@@ -146,6 +165,11 @@ img {
   color: #fff;
 }
 
+.login-btn:disabled,
+.login-modal__signin-option:disabled {
+  opacity: 0.6;
+}
+
 .language-btn {
   display: inline-flex;
   align-items: center;
@@ -523,6 +547,7 @@ img {
   max-width: 100%;
   object-fit: contain;
 }
+
 .benefit-copy-overlay {
   display: flex;
   flex-direction: column;
@@ -685,6 +710,50 @@ img {
   min-height: clamp(320px, 34vw, 500px);
 }
 
+.google-signin-slot {
+  position: relative;
+  margin-top: 20px;
+}
+
+.google-signin-slot__button {
+  width: 100%;
+  min-height: 66px;
+}
+
+.google-signin-slot__button :deep(div),
+.google-signin-slot__button :deep(iframe) {
+  width: 100% !important;
+}
+
+.google-signin-slot__fallback {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 66px;
+  border: 1px solid #eff1f3;
+  border-radius: 12px;
+  color: #666;
+  font-size: 20px;
+}
+
+.login-modal__status,
+.login-modal__error {
+  margin: 16px 0 0;
+  text-align: center;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.login-modal__status {
+  color: #67716d;
+}
+
+.login-modal__error {
+  color: #d64545;
+}
+
 @media (min-width: 1024px) {
   .benefits-content {
     width: min(100%, 1200px);
@@ -752,12 +821,17 @@ img {
     padding: 0 8px;
   }
 
-
   .login-btn,
-  .language-menu {
+  .language-menu,
+  .login-user-chip {
     flex: 1 1 0;
   }
 
+  .login-user-chip {
+    max-width: none;
+    justify-content: center;
+  }
+
   .language-btn {
     width: 100%;
   }
@@ -775,6 +849,10 @@ img {
   .download-button {
     width: 100%;
   }
+
+  .google-signin-slot__fallback {
+    font-size: 18px;
+  }
 }
 
 @media (max-width: 640px) {
@@ -811,3 +889,252 @@ img {
     scroll-behavior: auto !important;
   }
 }
+
+.login-modal--logout {
+  width: min(100%, 440px);
+  min-height: 414px;
+  padding: 80px 40px 40px;
+}
+
+.login-modal--profile {
+  width: min(100%, 440px);
+  min-height: 476px;
+  padding: 44px 40px 40px;
+}
+
+.login-modal__panel-title {
+  margin: 0;
+  color: #333;
+  font-size: 28px;
+  font-weight: 600;
+  line-height: 1.33;
+}
+
+.login-modal__panel-copy {
+  margin: 8px 0 0;
+  color: #85908c;
+  font-size: 18px;
+  line-height: 1.33;
+}
+
+.login-modal__ghost-button {
+  width: 100%;
+  height: 52px;
+  margin-top: 20px;
+  border: 1px solid #eff1f3;
+  border-radius: 8px;
+  background: #fff;
+  color: #333;
+  font-size: 20px;
+  line-height: 1.33;
+}
+
+.login-modal__profile-avatar-wrap {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 15px;
+}
+
+.login-modal__profile-avatar {
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
+  object-fit: cover;
+}
+
+.login-modal__profile-name {
+  margin: 0;
+  color: #333;
+  text-align: center;
+  font-size: 28px;
+  font-weight: 600;
+  line-height: 1.33;
+}
+
+.login-modal__profile-email {
+  margin: 8px 0 0;
+  color: #85908c;
+  text-align: center;
+  font-size: 20px;
+  line-height: 1.33;
+}
+
+.login-modal__ghost-button--language {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+}
+
+.login-modal__chevron {
+  width: 24px;
+  height: 24px;
+  object-fit: contain;
+}
+
+.login-modal__footer-links {
+  margin: 39px 0 0;
+  color: #85908c;
+  text-align: center;
+  font-size: 16px;
+  line-height: 1.33;
+}
+
+.login-modal__footer-links a {
+  color: #07c585;
+}.header-actions {
+  display: inline-flex;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 12px;
+}
+
+.header-pro-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 64px;
+  height: 34px;
+  padding: 0 14px;
+  border-radius: 999px;
+  background: linear-gradient(90deg, #fdeb9f 4.524%, #ffd16b 100%);
+  color: #7c5200;
+  font-size: 13px;
+  font-style: italic;
+  font-weight: 790;
+  line-height: 1;
+}
+
+.header-avatar-button {
+  width: 56px;
+  height: 56px;
+  padding: 0;
+  border-radius: 50%;
+  overflow: hidden;
+  flex: 0 0 auto;
+}
+
+.header-avatar-image {
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+@media (max-width: 768px) {
+  .header-pro-badge {
+    min-width: 0;
+    height: 32px;
+    padding: 0 12px;
+    font-size: 12px;
+  }
+
+  .header-avatar-button {
+    width: 44px;
+    height: 44px;
+  }
+}
+
+/* Header override */
+.figma-header {
+  position: relative !important;
+  z-index: 3 !important;
+  min-height: 70px !important;
+  padding-top: 10px !important;
+  padding-bottom: 10px !important;
+  background: #fff !important;
+}
+
+.figma-header__inner {
+  display: flex !important;
+  align-items: center !important;
+  justify-content: space-between !important;
+  gap: 16px !important;
+}
+
+.header-actions {
+  display: inline-flex !important;
+  flex: 0 0 auto !important;
+  align-items: center !important;
+  gap: 12px !important;
+  width: auto !important;
+}
+
+.header-pro-badge {
+  display: inline-flex !important;
+  align-items: center !important;
+  justify-content: center !important;
+  min-width: 44px !important;
+  height: 28px !important;
+  padding: 0 10px !important;
+  border-radius: 999px !important;
+  background: linear-gradient(90deg, #fdeb9f 4.524%, #ffd16b 100%) !important;
+  color: #7c5200 !important;
+  font-size: 12px !important;
+  font-style: italic !important;
+  font-weight: 790 !important;
+  line-height: 1 !important;
+}
+
+.header-avatar-button {
+  width: 40px !important;
+  height: 40px !important;
+  padding: 0 !important;
+  border-radius: 50% !important;
+  overflow: hidden !important;
+  flex: 0 0 auto !important;
+}
+
+.header-avatar-image {
+  display: block !important;
+  width: 100% !important;
+  height: 100% !important;
+  object-fit: cover !important;
+}
+
+@media (max-width: 768px) {
+  .figma-header {
+    min-height: 64px !important;
+  }
+
+  .figma-header__inner {
+    flex-wrap: wrap !important;
+  }
+
+  .header-actions {
+    width: 100% !important;
+    justify-content: flex-end !important;
+  }
+}
+
+.header-profile-menu {
+  position: relative;
+}
+
+.header-profile-dropdown {
+  position: absolute;
+  top: calc(100% + 2px);
+  right: 0;
+  z-index: 20;
+}
+
+.header-profile-dropdown__card {
+  width: 440px;
+  min-height: 476px;
+  padding: 44px 40px 40px;
+  border-radius: 20px;
+  background: #fff;
+  box-shadow: 0 4px 40px rgba(0, 0, 0, 0.1);
+}
+
+@media (max-width: 768px) {
+  .header-profile-dropdown {
+    right: -8px;
+  }
+
+  .header-profile-dropdown__card {
+    width: min(92vw, 440px);
+    min-height: 0;
+    padding: 32px 20px 28px;
+  }
+}