feat: 登录页面联调
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
|||||||
|
let apiBaseUrl = 'http://192.168.2.76:8000/api'
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
apiBaseUrl = '/backend-api'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export const API_BASE_URL = apiBaseUrl
|
||||||
|
|
||||||
|
export type SendCodePayload = {
|
||||||
|
phone: string
|
||||||
|
scene: 'register' | 'login' | 'reset'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendCodeResponse = {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginCodePayload = {
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackendUser = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
phone: string
|
||||||
|
real_name: string
|
||||||
|
role_type: string
|
||||||
|
institution: string | null
|
||||||
|
department: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
message: string
|
||||||
|
user?: Partial<BackendUser> & Record<string, unknown>
|
||||||
|
tokens: {
|
||||||
|
access: string
|
||||||
|
refresh: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
code?: string
|
||||||
|
statusCode?: number
|
||||||
|
|
||||||
|
constructor(message: string, code?: string, statusCode?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
this.code = code
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorMessage(data: unknown, fallback: string) {
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const payload = data as Record<string, unknown>
|
||||||
|
const message = payload.message || payload.detail || payload.error
|
||||||
|
if (typeof message === 'string' && message.trim()) return message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<T>(url: string, data: unknown): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: `${API_BASE_URL}${url}`,
|
||||||
|
method: 'POST',
|
||||||
|
timeout: 10000,
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
success: response => {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
resolve(response.data as T)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = response.data as Record<string, unknown> | undefined
|
||||||
|
const code = typeof payload?.code === 'string' ? payload.code : undefined
|
||||||
|
reject(new ApiRequestError(readErrorMessage(response.data, `请求失败(${response.statusCode})`), code, response.statusCode))
|
||||||
|
},
|
||||||
|
fail: error => {
|
||||||
|
reject(new ApiRequestError(error.errMsg || '无法连接服务'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoginResponse(data: unknown): data is LoginResponse {
|
||||||
|
if (!data || typeof data !== 'object') return false
|
||||||
|
const payload = data as Partial<LoginResponse>
|
||||||
|
const tokens = payload.tokens as Partial<LoginResponse['tokens']> | undefined
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
payload.tokens &&
|
||||||
|
typeof tokens?.access === 'string' &&
|
||||||
|
typeof tokens?.refresh === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendLoginCode(phone: string, scene: SendCodePayload['scene'] = 'login'): Promise<SendCodeResponse> {
|
||||||
|
return request<SendCodeResponse>('/user/auth/send-code/', {
|
||||||
|
phone,
|
||||||
|
scene
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginWithCode(payload: LoginCodePayload): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('/user/auth/login-code/', payload).then(response => {
|
||||||
|
if (isLoginResponse(response)) return response
|
||||||
|
throw new Error('登录接口返回数据格式异常')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -68,5 +68,18 @@
|
|||||||
"uniStatistics" : {
|
"uniStatistics" : {
|
||||||
"enable" : false
|
"enable" : false
|
||||||
},
|
},
|
||||||
|
"h5" : {
|
||||||
|
"devServer" : {
|
||||||
|
"proxy" : {
|
||||||
|
"/backend-api" : {
|
||||||
|
"target" : "http://192.168.2.76:8000",
|
||||||
|
"changeOrigin" : true,
|
||||||
|
"pathRewrite" : {
|
||||||
|
"^/backend-api" : "/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"vueVersion" : "3"
|
"vueVersion" : "3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,9 +114,10 @@ type OptionGroup = keyof ConfigOptions
|
|||||||
type SaveState = 'idle' | 'saved'
|
type SaveState = 'idle' | 'saved'
|
||||||
|
|
||||||
type LoginUser = {
|
type LoginUser = {
|
||||||
id?: string
|
id?: string | number
|
||||||
phone?: string
|
phone?: string
|
||||||
institutionId?: string
|
institutionId?: string
|
||||||
|
institution?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -229,9 +230,9 @@ function chooseOption(option: ConfigOption) {
|
|||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const user = (uni.getStorageSync('clinical-thinking-user') || {}) as LoginUser
|
const user = (uni.getStorageSync('clinical-thinking-user') || {}) as LoginUser
|
||||||
const payload: ClinicalConfigPayload = {
|
const payload: ClinicalConfigPayload = {
|
||||||
userId: user.id || 'mock-user-guest',
|
userId: user.id ? String(user.id) : 'mock-user-guest',
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
institutionId: user.institutionId || '',
|
institutionId: user.institutionId || user.institution || '',
|
||||||
department: form.department,
|
department: form.department,
|
||||||
title: form.title,
|
title: form.title,
|
||||||
experience: form.experience,
|
experience: form.experience,
|
||||||
|
|||||||
+178
-181
@@ -11,117 +11,130 @@
|
|||||||
@open-profile="showProfilePage = true"
|
@open-profile="showProfilePage = true"
|
||||||
/>
|
/>
|
||||||
<view v-if="!showConfigPage && !showProfilePage" class="auth-page">
|
<view v-if="!showConfigPage && !showProfilePage" class="auth-page">
|
||||||
<view class="auth-container">
|
<view class="auth-container">
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<text class="title">临床思维训练</text>
|
<image
|
||||||
<text class="subtitle">提升临床决策能力的高效平台</text>
|
class="logo"
|
||||||
</view>
|
mode="aspectFit"
|
||||||
|
src="/static/logo.png"
|
||||||
<view class="form">
|
/>
|
||||||
<view class="form-group">
|
<text class="title">临床思维训练</text>
|
||||||
<text class="label">手机号码</text>
|
<text class="subtitle">提升临床决策能力的高效平台</text>
|
||||||
<view class="input-wrap" :class="{ focused: activeField === 'phone' }">
|
|
||||||
<text class="country-code">+86</text>
|
|
||||||
<view class="separator"></view>
|
|
||||||
<input
|
|
||||||
class="input"
|
|
||||||
type="number"
|
|
||||||
maxlength="11"
|
|
||||||
v-model="form.phone"
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
placeholder-class="placeholder"
|
|
||||||
@focus="activeField = 'phone'"
|
|
||||||
@blur="activeField = ''"
|
|
||||||
@confirm="handleLogin"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-group">
|
<view class="form">
|
||||||
<text class="label">验证码</text>
|
<view class="form-group">
|
||||||
<view class="code-row">
|
<text class="label">手机号码</text>
|
||||||
<view class="input-wrap code-input-wrap" :class="{ focused: activeField === 'code' }">
|
<view class="input-wrap" :class="{ focused: activeField === 'phone' }">
|
||||||
<view class="shield-icon"></view>
|
<text class="country-code">+86</text>
|
||||||
|
<view class="separator"></view>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
maxlength="6"
|
maxlength="11"
|
||||||
v-model="form.code"
|
v-model="form.phone"
|
||||||
placeholder="短信验证码"
|
placeholder="请输入手机号"
|
||||||
placeholder-class="placeholder"
|
placeholder-class="placeholder"
|
||||||
@focus="activeField = 'code'"
|
@focus="activeField = 'phone'"
|
||||||
@blur="activeField = ''"
|
@blur="activeField = ''"
|
||||||
@confirm="handleLogin"
|
@confirm="handleLogin"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<button class="code-button" :class="{ disabled: isCounting }" :disabled="isCounting" @click="handleSendCode">
|
</view>
|
||||||
{{ codeButtonText }}
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">验证码</text>
|
||||||
|
<view class="code-row">
|
||||||
|
<view class="input-wrap code-input-wrap" :class="{ focused: activeField === 'code' }">
|
||||||
|
<view class="verified-icon" aria-hidden="true"></view>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
maxlength="6"
|
||||||
|
v-model="form.code"
|
||||||
|
placeholder="短信验证码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@focus="activeField = 'code'"
|
||||||
|
@blur="activeField = ''"
|
||||||
|
@confirm="handleLogin"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="code-button"
|
||||||
|
:class="{ disabled: isCounting || sendingCode }"
|
||||||
|
:disabled="isCounting || sendingCode"
|
||||||
|
@click="handleSendCode"
|
||||||
|
>
|
||||||
|
{{ codeButtonText }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="label">所属机构</text>
|
||||||
|
<view class="select-wrap" @click="institutionPickerVisible = true">
|
||||||
|
<text class="select-text" :class="{ muted: !selectedInstitution }">
|
||||||
|
{{ selectedInstitution ? selectedInstitution.name : '选择医院或医学院校' }}
|
||||||
|
</text>
|
||||||
|
<view class="select-arrow" aria-hidden="true"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="hint">* 未注册手机号登录时将自动创建账号</text>
|
||||||
|
|
||||||
|
<view class="login-area">
|
||||||
|
<button class="login-button" :class="{ loading: submitting }" :disabled="submitting" @click="handleLogin">
|
||||||
|
<view v-if="submitting" class="loading-spinner"></view>
|
||||||
|
<text v-else>登录 / 注册</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-group">
|
<view class="footer">
|
||||||
<text class="label">所属机构</text>
|
<view class="agreement" :class="{ shake: agreementShake }" @click="toggleAgreement">
|
||||||
<view class="select-wrap" @click="institutionPickerVisible = true">
|
<view class="checkbox" :class="{ checked: agreed }"></view>
|
||||||
<text class="select-text" :class="{ muted: !selectedInstitution }">
|
<view class="agreement-copy">
|
||||||
{{ selectedInstitution ? selectedInstitution.name : '选择医院或医学院校' }}
|
<text class="agreement-text">我已阅读并同意 </text>
|
||||||
</text>
|
<text class="agreement-link" @click.stop="openAgreement('service')">《用户服务协议》</text>
|
||||||
<view class="expand-icon"></view>
|
<text class="agreement-text"> 与 </text>
|
||||||
</view>
|
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私保护政策》</text>
|
||||||
</view>
|
|
||||||
|
|
||||||
<text class="hint">* 未注册手机号登录时将自动创建账号</text>
|
|
||||||
|
|
||||||
<button class="login-button" :class="{ loading: submitting }" :disabled="submitting" @click="handleLogin">
|
|
||||||
<view v-if="submitting" class="loading-spinner"></view>
|
|
||||||
<text v-else>登录 / 注册</text>
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="footer">
|
|
||||||
<view class="agreement" :class="{ shake: agreementShake }" @click="toggleAgreement">
|
|
||||||
<view class="checkbox" :class="{ checked: agreed }"></view>
|
|
||||||
<view class="agreement-copy">
|
|
||||||
<text class="agreement-text">我已阅读并同意 </text>
|
|
||||||
<text class="agreement-link" @click.stop="openAgreement('service')">《用户服务协议》</text>
|
|
||||||
<text class="agreement-text"> 与 </text>
|
|
||||||
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私保护政策》</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
|
|
||||||
|
|
||||||
<view v-if="institutionPickerVisible" class="picker-mask" @click="institutionPickerVisible = false">
|
|
||||||
<view class="picker-panel" @click.stop>
|
|
||||||
<view class="picker-header">
|
|
||||||
<text class="picker-title">选择所属机构</text>
|
|
||||||
<text class="picker-close" @click="institutionPickerVisible = false">关闭</text>
|
|
||||||
</view>
|
|
||||||
<scroll-view class="institution-list" scroll-y>
|
|
||||||
<view
|
|
||||||
v-for="institution in institutions"
|
|
||||||
:key="institution.id"
|
|
||||||
class="institution-item"
|
|
||||||
:class="{ active: form.institutionId === institution.id }"
|
|
||||||
@click="chooseInstitution(institution)"
|
|
||||||
>
|
|
||||||
<view class="institution-copy">
|
|
||||||
<text class="institution-name">{{ institution.name }}</text>
|
|
||||||
<text class="institution-meta">{{ institution.city }} · {{ institution.typeName }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="form.institutionId === institution.id" class="selected-mark"></view>
|
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
|
||||||
|
|
||||||
|
<view v-if="institutionPickerVisible" class="picker-mask" @click="institutionPickerVisible = false">
|
||||||
|
<view class="picker-panel" @click.stop>
|
||||||
|
<view class="picker-header">
|
||||||
|
<text class="picker-title">选择所属机构</text>
|
||||||
|
<text class="picker-close" @click="institutionPickerVisible = false">关闭</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="institution-list" scroll-y>
|
||||||
|
<view
|
||||||
|
v-for="institution in institutions"
|
||||||
|
:key="institution.id"
|
||||||
|
class="institution-item"
|
||||||
|
:class="{ active: form.institutionId === institution.id }"
|
||||||
|
@click="chooseInstitution(institution)"
|
||||||
|
>
|
||||||
|
<view class="institution-copy">
|
||||||
|
<text class="institution-name">{{ institution.name }}</text>
|
||||||
|
<text class="institution-meta">{{ institution.city }} · {{ institution.typeName }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="form.institutionId === institution.id" class="selected-mark"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onUnmounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ApiRequestError, loginWithCode, sendLoginCode } from '../../api/auth'
|
||||||
import ConfigPage from '../config/config.vue'
|
import ConfigPage from '../config/config.vue'
|
||||||
import ProfilePage from '../profile/profile.vue'
|
import ProfilePage from '../profile/profile.vue'
|
||||||
|
|
||||||
@@ -132,24 +145,11 @@ type Institution = {
|
|||||||
typeName: string
|
typeName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginUser = {
|
|
||||||
id: string
|
|
||||||
phone: string
|
|
||||||
institutionId: string
|
|
||||||
institutionName: string
|
|
||||||
token: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const institutions: Institution[] = [
|
const institutions: Institution[] = [
|
||||||
{ id: 'school-001', name: '北京协和医学院', city: '北京', typeName: '医学院校' },
|
{ id: '1', name: '协和医学院', city: '北京', typeName: '医学院校' },
|
||||||
{ id: 'hospital-001', name: '北京协和医院', city: '北京', typeName: '三甲医院' },
|
{ id: '2', name: '四川大学华西医院', city: '成都', typeName: '三甲医院' },
|
||||||
{ id: 'hospital-002', name: '四川大学华西医院', city: '成都', typeName: '三甲医院' },
|
{ id: '3', name: '复旦大学附属中山医院', city: '上海', typeName: '三甲医院' },
|
||||||
{ id: 'hospital-003', name: '复旦大学附属中山医院', city: '上海', typeName: '三甲医院' },
|
{ id: '4', name: '中山大学孙逸仙纪念医院', city: '广州', typeName: '三甲医院' },
|
||||||
{ id: 'hospital-004', name: '中山大学孙逸仙纪念医院', city: '广州', typeName: '三甲医院' },
|
|
||||||
{ id: 'hospital-005', name: '上海交通大学医学院附属瑞金医院', city: '上海', typeName: '三甲医院' },
|
|
||||||
{ id: 'school-002', name: '北京大学医学部', city: '北京', typeName: '医学院校' },
|
|
||||||
{ id: 'school-003', name: '复旦大学上海医学院', city: '上海', typeName: '医学院校' },
|
|
||||||
{ id: 'school-004', name: '浙江大学医学院', city: '杭州', typeName: '医学院校' },
|
|
||||||
{ id: 'others', name: '其他机构', city: '其他', typeName: '手动登记' }
|
{ id: 'others', name: '其他机构', city: '其他', typeName: '手动登记' }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -159,17 +159,17 @@ const form = reactive({
|
|||||||
institutionId: ''
|
institutionId: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const institutionPickerVisible = ref(false)
|
|
||||||
const mockVerifyCode = ref('')
|
|
||||||
const countdown = ref(0)
|
|
||||||
const activeField = ref('')
|
const activeField = ref('')
|
||||||
const agreed = ref(false)
|
const agreed = ref(false)
|
||||||
const agreementShake = ref(false)
|
const agreementShake = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const countdown = ref(0)
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
const toastVisible = ref(false)
|
const toastVisible = ref(false)
|
||||||
const showConfigPage = ref(false)
|
const showConfigPage = ref(false)
|
||||||
const showProfilePage = ref(false)
|
const showProfilePage = ref(false)
|
||||||
|
const institutionPickerVisible = ref(false)
|
||||||
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -181,7 +181,8 @@ const selectedInstitution = computed(() => {
|
|||||||
const isCounting = computed(() => countdown.value > 0)
|
const isCounting = computed(() => countdown.value > 0)
|
||||||
|
|
||||||
const codeButtonText = computed(() => {
|
const codeButtonText = computed(() => {
|
||||||
return isCounting.value ? `${countdown.value}s 后重新获取` : '获取验证码'
|
if (sendingCode.value) return '发送中...'
|
||||||
|
return isCounting.value ? `${countdown.value}s` : '获取验证码'
|
||||||
})
|
})
|
||||||
|
|
||||||
function chooseInstitution(institution: Institution) {
|
function chooseInstitution(institution: Institution) {
|
||||||
@@ -193,33 +194,38 @@ function toggleAgreement() {
|
|||||||
agreed.value = !agreed.value
|
agreed.value = !agreed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendCode() {
|
async function handleSendCode() {
|
||||||
|
if (sendingCode.value || isCounting.value) return
|
||||||
if (!validatePhone()) return
|
if (!validatePhone()) return
|
||||||
|
|
||||||
mockVerifyCode.value = String(Math.floor(100000 + Math.random() * 900000))
|
sendingCode.value = true
|
||||||
form.code = mockVerifyCode.value
|
try {
|
||||||
countdown.value = 60
|
let result
|
||||||
clearCountdownTimer()
|
try {
|
||||||
countdownTimer = setInterval(() => {
|
result = await sendLoginCode(form.phone)
|
||||||
countdown.value -= 1
|
} catch (error) {
|
||||||
if (countdown.value <= 0) {
|
if (error instanceof ApiRequestError && error.code === 'AUTH_PHONE_NOT_FOUND') {
|
||||||
clearCountdownTimer()
|
result = await sendLoginCode(form.phone, 'register')
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 1000)
|
showToast(result.message || '验证码已发送')
|
||||||
showToast(`模拟验证码:${mockVerifyCode.value}`)
|
startCountdown()
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
|
} finally {
|
||||||
|
sendingCode.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogin() {
|
function handleLogin() {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
if (!validatePhone()) return
|
if (!validatePhone()) return
|
||||||
if (!form.code) {
|
if (!form.code.trim()) {
|
||||||
showToast('请输入短信验证码')
|
showToast('请输入短信验证码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mockVerifyCode.value && form.code !== mockVerifyCode.value) {
|
|
||||||
showToast('验证码不正确')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedInstitution.value) {
|
if (!selectedInstitution.value) {
|
||||||
showToast('请选择所属机构')
|
showToast('请选择所属机构')
|
||||||
return
|
return
|
||||||
@@ -231,22 +237,41 @@ function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
setTimeout(() => {
|
loginWithCode({
|
||||||
const user: LoginUser = {
|
phone: form.phone,
|
||||||
id: `mock-user-${Date.now()}`,
|
code: form.code
|
||||||
phone: form.phone,
|
}).then(result => {
|
||||||
|
const backendUser = result.user || {}
|
||||||
|
const normalizedUser = {
|
||||||
|
...backendUser,
|
||||||
|
id: backendUser.id ? String(backendUser.id) : '',
|
||||||
|
phone: backendUser.phone || form.phone,
|
||||||
institutionId: selectedInstitution.value?.id || '',
|
institutionId: selectedInstitution.value?.id || '',
|
||||||
institutionName: selectedInstitution.value?.name || '',
|
institutionName: selectedInstitution.value?.name || ''
|
||||||
token: `mock-token-${Date.now()}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uni.setStorageSync('clinical-thinking-user', user)
|
uni.setStorageSync('clinical-thinking-user', normalizedUser)
|
||||||
showToast('正在进入系统...')
|
uni.setStorageSync('clinical-thinking-tokens', result.tokens)
|
||||||
setTimeout(() => {
|
uni.setStorageSync('clinical-thinking-access-token', result.tokens.access)
|
||||||
submitting.value = false
|
uni.setStorageSync('clinical-thinking-refresh-token', result.tokens.refresh)
|
||||||
showConfigPage.value = true
|
showToast(result.message || '正在进入系统...')
|
||||||
}, 300)
|
showConfigPage.value = true
|
||||||
}, 500)
|
}).catch(error => {
|
||||||
|
showToast(error instanceof Error ? error.message : '登录失败,请稍后重试')
|
||||||
|
}).finally(() => {
|
||||||
|
submitting.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
countdown.value = 60
|
||||||
|
clearCountdownTimer()
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value -= 1
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
clearCountdownTimer()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePhone() {
|
function validatePhone() {
|
||||||
@@ -282,9 +307,7 @@ function triggerAgreementShake() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message: string) {
|
function showToast(message: string) {
|
||||||
if (toastTimer) {
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
clearTimeout(toastTimer)
|
|
||||||
}
|
|
||||||
toastMessage.value = message
|
toastMessage.value = message
|
||||||
toastVisible.value = true
|
toastVisible.value = true
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -305,9 +328,7 @@ function clearCountdownTimer() {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearCountdownTimer()
|
clearCountdownTimer()
|
||||||
if (toastTimer) {
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
clearTimeout(toastTimer)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -452,33 +473,17 @@ page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shield-icon {
|
.verified-icon {
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
box-sizing: border-box;
|
width: 28px;
|
||||||
width: 22px;
|
height: 28px;
|
||||||
height: 24px;
|
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
border: 3px solid #727783;
|
background: center / 24px 24px no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24' fill='none' stroke='%23727783' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l7 3v5c0 4.5-3 7.8-7 10-4-2.2-7-5.5-7-10V6l7-3z'/%3E%3Cpath d='M9 12l2 2 4-4'/%3E%3C/svg%3E");
|
||||||
border-radius: 7px 7px 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shield-icon::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 5px;
|
|
||||||
top: 5px;
|
|
||||||
width: 8px;
|
|
||||||
height: 5px;
|
|
||||||
border-left: 2px solid #727783;
|
|
||||||
border-bottom: 2px solid #727783;
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-button {
|
.code-button {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 124px;
|
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border: 1px solid #00478d;
|
border: 1px solid #00478d;
|
||||||
@@ -507,6 +512,7 @@ page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-wrap {
|
.select-wrap {
|
||||||
|
position: relative;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,24 +531,12 @@ page {
|
|||||||
color: rgba(114, 119, 131, 0.6);
|
color: rgba(114, 119, 131, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.select-arrow {
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
background: center / 28px 28px no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24' fill='none' stroke='%23727783' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 8l5 5 5-5'/%3E%3Cpath d='M7 12l5 5 5-5'/%3E%3C/svg%3E");
|
||||||
|
|
||||||
.expand-icon::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 6px;
|
|
||||||
top: 7px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-right: 2px solid #727783;
|
|
||||||
border-bottom: 2px solid #727783;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
@@ -556,10 +550,13 @@ page {
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-area {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button {
|
.login-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
margin-top: 16px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #00478d;
|
background: #00478d;
|
||||||
box-shadow: 0 4px 12px rgba(0, 71, 141, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 71, 141, 0.2);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [uni()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/backend-api': {
|
||||||
|
target: 'http://192.168.2.76:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: path => path.replace(/^\/backend-api/, '/api')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user