Login.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. <template>
  2. <div class="login-container">
  3. <el-card class="login-card" v-loading="checkLoading">
  4. <template #header>
  5. <div class="card-header">
  6. <img src="/logo.png" alt="Logo" class="logo-img" />
  7. <h2>韫珠科技统一登录平台</h2>
  8. <el-tag v-if="loginChallenge" type="warning" size="small">OIDC 登录</el-tag>
  9. <div v-if="ssoAppName" style="color: #67C23A; background-color: #f0f9eb; border: 1px solid #e1f3d8; padding: 6px 12px; border-radius: 4px; font-size: 16px; margin: 10px 0; display: inline-block;">{{ ssoAppName }}</div>
  10. </div>
  11. </template>
  12. <!-- SMS Enabled and NOT OIDC/SSO (Simple implementation for now) -->
  13. <el-tabs v-if="smsEnabled && !loginChallenge" v-model="activeTab" class="login-tabs" stretch>
  14. <el-tab-pane label="密码登录" name="password">
  15. <el-form :model="loginForm" label-width="0px" class="login-form">
  16. <el-form-item>
  17. <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
  18. </el-form-item>
  19. <el-form-item>
  20. <el-input
  21. v-model="loginForm.password"
  22. type="password"
  23. placeholder="密码"
  24. :prefix-icon="Lock"
  25. show-password
  26. />
  27. </el-form-item>
  28. <el-form-item>
  29. <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
  30. </el-form-item>
  31. <el-form-item>
  32. <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
  33. 登录
  34. </el-button>
  35. </el-form-item>
  36. <div style="text-align: center; margin-top: 10px;">
  37. <router-link to="/reset-password">忘记密码?</router-link>
  38. </div>
  39. </el-form>
  40. </el-tab-pane>
  41. <el-tab-pane label="验证码登录" name="sms">
  42. <el-form :model="smsForm" label-width="0px" class="login-form">
  43. <el-form-item>
  44. <el-input v-model="smsForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
  45. </el-form-item>
  46. <el-form-item>
  47. <el-row :gutter="10" style="width: 100%">
  48. <el-col :span="15">
  49. <el-input v-model="smsForm.code" placeholder="验证码" :prefix-icon="Key" />
  50. </el-col>
  51. <el-col :span="9">
  52. <el-button @click="handleSendCode" :disabled="countdown > 0" style="width: 100%">
  53. {{ countdown > 0 ? `${countdown}s` : '发送验证码' }}
  54. </el-button>
  55. </el-col>
  56. </el-row>
  57. </el-form-item>
  58. <el-form-item>
  59. <el-button type="primary" :loading="loading" @click="handleSmsLogin" style="width: 100%">
  60. 登录
  61. </el-button>
  62. </el-form-item>
  63. </el-form>
  64. </el-tab-pane>
  65. </el-tabs>
  66. <!-- Original Layout for fallback (OIDC/SSO or SMS disabled) -->
  67. <el-form v-else :model="loginForm" label-width="0px" class="login-form">
  68. <el-form-item>
  69. <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
  70. </el-form-item>
  71. <el-form-item>
  72. <el-input
  73. v-model="loginForm.password"
  74. type="password"
  75. placeholder="密码"
  76. :prefix-icon="Lock"
  77. show-password
  78. />
  79. </el-form-item>
  80. <el-form-item>
  81. <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
  82. </el-form-item>
  83. <el-form-item>
  84. <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
  85. 登录
  86. </el-button>
  87. </el-form-item>
  88. <div style="text-align: center; margin-top: 10px;">
  89. <router-link to="/reset-password">忘记密码?</router-link>
  90. </div>
  91. </el-form>
  92. </el-card>
  93. </div>
  94. </template>
  95. <script setup lang="ts">
  96. import { ref, reactive, onMounted } from 'vue'
  97. import { useRouter, useRoute } from 'vue-router'
  98. import { useAuthStore } from '../store/auth'
  99. import { getLoginRequest, acceptLogin } from '../api/oidc'
  100. import { getSystemStatus, getAppPublicInfo, ssoLogin } from '../api/public'
  101. import { getPublicConfigs } from '../api/systemConfig'
  102. import { sendSmsCode, loginWithSms } from '../api/smsAuth'
  103. import { ElMessage } from 'element-plus'
  104. import { Iphone, Lock, Key } from '@element-plus/icons-vue'
  105. const router = useRouter()
  106. const route = useRoute()
  107. const authStore = useAuthStore()
  108. const activeTab = ref('password')
  109. const smsEnabled = ref(false)
  110. const countdown = ref(0)
  111. let timer: any = null
  112. const loginForm = reactive({
  113. mobile: '',
  114. password: '',
  115. remember_me: true
  116. })
  117. const smsForm = reactive({
  118. mobile: '',
  119. code: ''
  120. })
  121. const loading = ref(false)
  122. const checkLoading = ref(true)
  123. const loginChallenge = ref('')
  124. const ssoAppId = ref('')
  125. const ssoAppName = ref('')
  126. const handleSendCode = async () => {
  127. if (!smsForm.mobile) {
  128. ElMessage.warning('请输入手机号')
  129. return
  130. }
  131. try {
  132. await sendSmsCode(smsForm.mobile, 'pc')
  133. ElMessage.success('验证码已发送')
  134. countdown.value = 60
  135. timer = setInterval(() => {
  136. countdown.value--
  137. if (countdown.value <= 0) {
  138. clearInterval(timer)
  139. }
  140. }, 1000)
  141. } catch (e) {
  142. // handled by interceptor
  143. }
  144. }
  145. const handleSmsLogin = async () => {
  146. if (!smsForm.mobile || !smsForm.code) {
  147. ElMessage.warning('请输入手机号和验证码')
  148. return
  149. }
  150. loading.value = true
  151. try {
  152. const res = await loginWithSms({
  153. mobile: smsForm.mobile,
  154. code: smsForm.code,
  155. platform: 'pc'
  156. })
  157. // Manual login success handling since authStore.login is for password
  158. const token = res.data.access_token
  159. authStore.token = token
  160. localStorage.setItem('token', token)
  161. await authStore.fetchUser()
  162. // SSO Redirect Check
  163. if (ssoAppId.value) {
  164. const ssoRes = await ssoLogin({
  165. app_id: ssoAppId.value,
  166. username: '',
  167. password: ''
  168. })
  169. if (ssoRes.data.redirect_url) {
  170. ElMessage.success('登录成功,正在跳转...')
  171. window.location.href = ssoRes.data.redirect_url
  172. return
  173. }
  174. }
  175. ElMessage.success('登录成功')
  176. router.push('/dashboard/launchpad')
  177. } catch (e) {
  178. // handled
  179. } finally {
  180. loading.value = false
  181. }
  182. }
  183. const handleLogin = async () => {
  184. if (!loginForm.mobile || !loginForm.password) {
  185. ElMessage.warning('请输入手机号和密码')
  186. return
  187. }
  188. loading.value = true
  189. try {
  190. if (loginChallenge.value) {
  191. // OIDC Flow
  192. const res = await acceptLogin(loginChallenge.value, loginForm)
  193. if (res.data.redirect_to) {
  194. window.location.href = res.data.redirect_to
  195. } else {
  196. ElMessage.error('OIDC 响应异常')
  197. }
  198. } else if (ssoAppId.value) {
  199. // SSO Ticket Flow (Simple API)
  200. const res = await ssoLogin({
  201. app_id: ssoAppId.value,
  202. username: loginForm.mobile,
  203. password: loginForm.password
  204. })
  205. if (res.data.redirect_url) {
  206. ElMessage.success('登录成功,正在跳转...')
  207. window.location.href = res.data.redirect_url
  208. }
  209. } else {
  210. // Platform Login
  211. await authStore.login({
  212. mobile: loginForm.mobile,
  213. password: loginForm.password,
  214. remember_me: loginForm.remember_me
  215. })
  216. ElMessage.success('登录成功')
  217. router.push('/dashboard/launchpad')
  218. }
  219. } catch (error) {
  220. // Error handled in interceptor
  221. } finally {
  222. loading.value = false
  223. }
  224. }
  225. onMounted(async () => {
  226. // 0. Capture route params immediately to avoid async timing issues
  227. const challenge = route.query.login_challenge as string
  228. const appid = route.query.appid as string || route.query.app_id as string
  229. // Fetch Config
  230. try {
  231. const configRes = await getPublicConfigs()
  232. if (configRes.data) {
  233. const pcConfig = configRes.data.find((c: any) => c.key === 'sms_login_pc_enabled')
  234. if (pcConfig && pcConfig.value === 'true') {
  235. smsEnabled.value = true
  236. activeTab.value = 'sms'
  237. }
  238. }
  239. } catch(e) {
  240. console.error("Failed to fetch public config", e)
  241. }
  242. console.log('Login page mounted. Params:', { challenge, appid, fullQuery: route.query })
  243. // 1. Check System Initialization Status
  244. try {
  245. const statusRes = await getSystemStatus()
  246. if (!statusRes.data.initialized) {
  247. router.replace('/setup')
  248. return
  249. }
  250. } catch (e) {
  251. console.error("Failed to check system status", e)
  252. } finally {
  253. checkLoading.value = false
  254. }
  255. // If no context (neither OIDC challenge nor App SSO), we just stay here as Platform Login
  256. if (!challenge && !appid) {
  257. console.log('No login context, showing Platform Login')
  258. checkLoading.value = false
  259. return
  260. }
  261. if (challenge) {
  262. loginChallenge.value = challenge
  263. // Remove auto-accept skip logic from here since it's handled by AutoLogin.vue
  264. checkLoading.value = false
  265. } else if (appid) {
  266. // 3. Handle App SSO
  267. try {
  268. const appInfo = await getAppPublicInfo(appid)
  269. // Auto-Login Check (Only if we have a token/session)
  270. // Since `ssoLogin` endpoint now supports session-based auth, we can try calling it without password
  271. // But we need to know if we are logged in first.
  272. // Let's rely on `authStore.checkAuth()` or similar if available, or just try `ssoLogin` and catch 401.
  273. if (appInfo.data.protocol_type === 'OIDC') {
  274. // OIDC Logic
  275. // Redirect to Hydra Auth Endpoint via Nginx proxy (/hydra prefix)
  276. // We need to parse redirect_uris to get the first one
  277. let redirectUri = ''
  278. try {
  279. const uris = JSON.parse(appInfo.data.redirect_uris || '[]')
  280. if (uris.length > 0) redirectUri = uris[0]
  281. } catch(e) {}
  282. if (redirectUri) {
  283. const authUrl = `/hydra/oauth2/auth?client_id=${appid}&response_type=code&scope=openid offline profile&redirect_uri=${encodeURIComponent(redirectUri)}&state=init_sso`
  284. window.location.href = authUrl
  285. return
  286. } else {
  287. ElMessage.error('该应用未配置回调地址,无法跳转')
  288. }
  289. } else {
  290. // Simple API
  291. ssoAppId.value = appid
  292. ssoAppName.value = appInfo.data.app_name
  293. // Try Auto-Login (SSO)
  294. try {
  295. // Attempt SSO login without credentials (relying on cookie)
  296. const res = await ssoLogin({
  297. app_id: appid,
  298. username: '',
  299. password: ''
  300. }, { skipGlobalErrorHandler: true }) // Skip global 401 redirect/error
  301. if (res.data.redirect_url) {
  302. ElMessage.success(`检测到已登录,正在跳转到 ${appInfo.data.app_name}...`)
  303. window.location.href = res.data.redirect_url
  304. return
  305. }
  306. } catch (e) {
  307. // 401 means not logged in, show login form.
  308. console.log("Auto-login failed, showing login form")
  309. }
  310. }
  311. } catch (e) {
  312. ElMessage.error('无效的应用ID')
  313. }
  314. }
  315. })
  316. </script>
  317. <style scoped>
  318. .login-container {
  319. display: flex;
  320. justify-content: center;
  321. align-items: center;
  322. height: 100vh;
  323. background-color: #f0f2f5;
  324. }
  325. .login-card {
  326. width: 400px;
  327. }
  328. .card-header {
  329. text-align: center;
  330. display: flex;
  331. flex-direction: column;
  332. align-items: center;
  333. gap: 10px;
  334. }
  335. .logo-img {
  336. height: 60px;
  337. object-fit: contain;
  338. }
  339. </style>