feat:注册功能

This commit is contained in:
李伊帆
2026-09-20 17:00:55 +08:00
parent 0e80de4548
commit ce93816fd5
22 changed files with 722 additions and 51 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://192.168.1.187:8080
# 后台「系统管理 -> 客户端管理」中 APP 端的 clientId
VITE_AUTH_CLIENT_ID=

1
api-docs.json Normal file

File diff suppressed because one or more lines are too long

38
src/api/auth.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { LoginResult, PasswordLoginParams, RegisterParams, RegisterResult, SendSmsCodeParams } from '@/types/auth'
import { AUTH_CLIENT_ID } from '@/constants/config'
import { post } from '@/utils/request'
const AUTH_API = {
register: '/app/auth/register',
sendSmsCode: '/app/auth/sms/code',
loginByPassword: '/app/auth/login/password',
}
export function sendSmsCode(data: SendSmsCodeParams) {
return post<void>(AUTH_API.sendSmsCode, data)
}
export function register(data: RegisterParams) {
return post<RegisterResult>(AUTH_API.register, data)
}
export function loginByPassword(
data: PasswordLoginParams,
options?: { showErrorToast?: boolean },
) {
if (!AUTH_CLIENT_ID) {
const message = '未配置认证客户端 ID,请联系后端获取 clientId 并写入 .env.development'
uni.showToast({ title: message, icon: 'none', duration: 3000 })
return Promise.reject(new Error(message))
}
return post<LoginResult>(
AUTH_API.loginByPassword,
{
clientId: AUTH_CLIENT_ID,
phoneNumber: data.phoneNumber,
password: data.password,
},
options,
)
}

12
src/constants/config.ts Normal file
View File

@@ -0,0 +1,12 @@
/** 后端服务地址 */
export const API_HOST = '192.168.1.187'
export const API_PORT = 8080
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || `http://${API_HOST}:${API_PORT}`
/** 接口请求超时(毫秒) */
export const API_TIMEOUT = 15000
/** APP 端 OAuth 客户端 ID(sys_client 表,需与后端「客户端管理」一致) */
export const AUTH_CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || ''

62
src/hooks/use-sms-code.ts Normal file
View File

@@ -0,0 +1,62 @@
import { onUnmounted, ref } from 'vue'
import { sendSmsCode } from '@/api/auth'
import type { SmsScene } from '@/types/auth'
const PHONE_REG = /^1\d{10}$/
const COUNTDOWN_SECONDS = 60
export function useSmsCode(getPhone: () => string, scene: SmsScene) {
const countdown = ref(0)
const sending = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function startCountdown() {
countdown.value = COUNTDOWN_SECONDS
clearTimer()
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0)
clearTimer()
}, 1000)
}
async function handleSendCode() {
if (countdown.value > 0 || sending.value)
return
const phone = getPhone().trim()
if (!PHONE_REG.test(phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
sending.value = true
try {
await sendSmsCode({ phoneNumber: phone, scene })
uni.showToast({ title: '验证码已发送', icon: 'none' })
startCountdown()
}
catch {
// 错误提示由 request 统一处理
}
finally {
sending.value = false
}
}
onUnmounted(() => {
clearTimer()
})
return {
countdown,
sending,
handleSendCode,
}
}

View File

@@ -21,6 +21,7 @@
"modules": {}, "modules": {},
"distribute": { "distribute": {
"android": { "android": {
"usesCleartextTraffic": true,
"permissions": [ "permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onUnmounted, ref } from 'vue'
import type { SmsScene } from '@/types/auth'
import { requestSmsCode } from '@/utils/sms-code'
defineOptions({ defineOptions({
options: { options: {
@@ -7,8 +9,10 @@ defineOptions({
}, },
}) })
defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
phone: string
scene: SmsScene
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,17 +20,14 @@ const emit = defineEmits<{
}>() }>()
const countdown = ref(0) const countdown = ref(0)
const sending = ref(false)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) { function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value) emit('update:modelValue', e.detail.value)
} }
function handleGetCode() { function startCountdown() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60 countdown.value = 60
timer = setInterval(() => { timer = setInterval(() => {
countdown.value -= 1 countdown.value -= 1
@@ -36,6 +37,31 @@ function handleGetCode() {
} }
}, 1000) }, 1000)
} }
async function handleGetCode() {
if (countdown.value > 0 || sending.value)
return
sending.value = true
try {
const success = await requestSmsCode(props.phone, props.scene)
if (success)
startCountdown()
}
catch {
// 错误提示由 request 统一处理
}
finally {
sending.value = false
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script> </script>
<template> <template>
@@ -50,11 +76,11 @@ function handleGetCode() {
> >
<view <view
class="code-input__btn" class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }" :class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
@tap="handleGetCode" @tap="handleGetCode"
> >
<text class="code-input__btn-text"> <text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }} {{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text> </text>
</view> </view>
</view> </view>

View File

@@ -63,7 +63,11 @@ function handleUserAgreement() {
</view> </view>
<view class="forgot-page__field"> <view class="forgot-page__field">
<CodeInput v-model="code" /> <CodeInput
v-model="code"
:phone="phone"
scene="resetPwd"
/>
</view> </view>
<view class="forgot-page__field"> <view class="forgot-page__field">

View File

@@ -1,16 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { loginByPassword } from '@/api/auth'
import { setAccessToken } from '@/utils/auth-storage'
import { navigateToResultPage } from '@/utils/navigate-result'
import { validatePhone } from '@/utils/sms-code'
import LoginInput from './components/LoginInput.vue' import LoginInput from './components/LoginInput.vue'
const phone = ref('') const phone = ref('')
const password = ref('') const password = ref('')
const agreed = ref(false) const agreed = ref(false)
const submitting = ref(false)
function toggleAgreed() { function toggleAgreed() {
agreed.value = !agreed.value agreed.value = !agreed.value
} }
function handleLogin() { function shouldNavigateToRegister(msg: string) {
return /未注册|不存在|请先注册|创建账号/.test(msg)
}
async function handleLogin() {
if (submitting.value)
return
if (!agreed.value) { if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' }) uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return return
@@ -19,9 +31,37 @@ function handleLogin() {
uni.showToast({ title: '请输入手机号和密码', icon: 'none' }) uni.showToast({ title: '请输入手机号和密码', icon: 'none' })
return return
} }
if (!validatePhone(phone.value))
return
// TODO: 接入登录接口,失败时调用 navigateToResultPage('loginFailed') submitting.value = true
uni.redirectTo({ url: '/pages/home-index/index' }) uni.showLoading({ title: '登录中...', mask: true })
try {
const result = await loginByPassword({
phoneNumber: phone.value.trim(),
password: password.value,
}, { showErrorToast: false })
if (result.access_token)
setAccessToken(result.access_token)
uni.redirectTo({ url: '/pages/home-index/index' })
}
catch (error) {
const message = error instanceof Error ? error.message : '登录失败'
if (message.includes('未配置认证客户端'))
return
if (shouldNavigateToRegister(message)) {
navigateToResultPage('loginFailed')
return
}
uni.showToast({ title: message, icon: 'none' })
}
finally {
submitting.value = false
uni.hideLoading()
}
} }
function handleForgotPassword() { function handleForgotPassword() {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onUnmounted, ref } from 'vue'
import type { SmsScene } from '@/types/auth'
import { requestSmsCode } from '@/utils/sms-code'
defineOptions({ defineOptions({
options: { options: {
@@ -7,8 +9,10 @@ defineOptions({
}, },
}) })
defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
phone: string
scene: SmsScene
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,17 +20,14 @@ const emit = defineEmits<{
}>() }>()
const countdown = ref(0) const countdown = ref(0)
const sending = ref(false)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) { function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value) emit('update:modelValue', e.detail.value)
} }
function handleGetCode() { function startCountdown() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60 countdown.value = 60
timer = setInterval(() => { timer = setInterval(() => {
countdown.value -= 1 countdown.value -= 1
@@ -36,6 +37,31 @@ function handleGetCode() {
} }
}, 1000) }, 1000)
} }
async function handleGetCode() {
if (countdown.value > 0 || sending.value)
return
sending.value = true
try {
const success = await requestSmsCode(props.phone, props.scene)
if (success)
startCountdown()
}
catch {
// 错误提示由 request 统一处理
}
finally {
sending.value = false
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script> </script>
<template> <template>
@@ -55,11 +81,11 @@ function handleGetCode() {
> >
<view <view
class="code-input__btn" class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }" :class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
@tap="handleGetCode" @tap="handleGetCode"
> >
<text class="code-input__btn-text"> <text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }} {{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text> </text>
</view> </view>
</view> </view>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { register } from '@/api/auth'
import { getPasswordStrength } from '@/utils/password-strength' import { getPasswordStrength } from '@/utils/password-strength'
import { navigateToResultPage } from '@/utils/navigate-result' import { navigateToResultPage } from '@/utils/navigate-result'
import CodeInput from './components/CodeInput.vue' import CodeInput from './components/CodeInput.vue'
@@ -12,12 +13,16 @@ const code = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const agreed = ref(false) const agreed = ref(false)
const submitting = ref(false)
function toggleAgreed() { function toggleAgreed() {
agreed.value = !agreed.value agreed.value = !agreed.value
} }
function handleRegister() { async function handleRegister() {
if (submitting.value)
return
if (!agreed.value) { if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' }) uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return return
@@ -34,7 +39,26 @@ function handleRegister() {
uni.showToast({ title: '密码等级过低,请重新输入', icon: 'none' }) uni.showToast({ title: '密码等级过低,请重新输入', icon: 'none' })
return return
} }
navigateToResultPage('register')
submitting.value = true
uni.showLoading({ title: '注册中...', mask: true })
try {
await register({
phoneNumber: phone.value.trim(),
smsCode: code.value.trim(),
password: password.value,
confirmPassword: confirmPassword.value,
})
navigateToResultPage('register')
}
catch {
// 错误提示由 request 统一处理
}
finally {
submitting.value = false
uni.hideLoading()
}
} }
function handleLogin() { function handleLogin() {
@@ -70,7 +94,11 @@ function handleUserAgreement() {
</view> </view>
<view class="register-page__field"> <view class="register-page__field">
<CodeInput v-model="code" /> <CodeInput
v-model="code"
:phone="phone"
scene="register"
/>
</view> </view>
<view class="register-page__field"> <view class="register-page__field">
@@ -130,9 +158,10 @@ function handleUserAgreement() {
<button <button
class="register-page__submit" class="register-page__submit"
:disabled="submitting"
@tap="handleRegister" @tap="handleRegister"
> >
注册 {{ submitting ? '注册中...' : '注册' }}
</button> </button>
</view> </view>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onUnmounted, ref } from 'vue'
import type { SmsScene } from '@/types/auth'
import { requestSmsCode } from '@/utils/sms-code'
defineOptions({ defineOptions({
options: { options: {
@@ -7,8 +9,10 @@ defineOptions({
}, },
}) })
defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
phone: string
scene: SmsScene
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,17 +20,14 @@ const emit = defineEmits<{
}>() }>()
const countdown = ref(0) const countdown = ref(0)
const sending = ref(false)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) { function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value) emit('update:modelValue', e.detail.value)
} }
function handleGetCode() { function startCountdown() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60 countdown.value = 60
timer = setInterval(() => { timer = setInterval(() => {
countdown.value -= 1 countdown.value -= 1
@@ -36,6 +37,31 @@ function handleGetCode() {
} }
}, 1000) }, 1000)
} }
async function handleGetCode() {
if (countdown.value > 0 || sending.value)
return
sending.value = true
try {
const success = await requestSmsCode(props.phone, props.scene)
if (success)
startCountdown()
}
catch {
// 错误提示由 request 统一处理
}
finally {
sending.value = false
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script> </script>
<template> <template>
@@ -55,11 +81,11 @@ function handleGetCode() {
> >
<view <view
class="code-input__btn" class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }" :class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
@tap="handleGetCode" @tap="handleGetCode"
> >
<text class="code-input__btn-text"> <text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }} {{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text> </text>
</view> </view>
</view> </view>

View File

@@ -62,7 +62,11 @@ function handleUserAgreement() {
/> />
<view class="sms-login-page__code-group"> <view class="sms-login-page__code-group">
<CodeInput v-model="code" /> <CodeInput
v-model="code"
:phone="phone"
scene="login"
/>
</view> </view>
<view <view

View File

@@ -73,3 +73,45 @@
color: $color-text-primary; color: $color-text-primary;
} }
} }
.chat-message__image-wrap {
max-width: 360rpx;
overflow: hidden;
border-radius: 16rpx;
}
.chat-message__image {
display: block;
width: 360rpx;
height: 360rpx;
background: #f2f3f5;
}
.chat-message__bubble--file {
display: flex;
align-items: center;
gap: 16rpx;
min-width: 280rpx;
}
.chat-message__file-icon {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
}
.chat-message__file-name {
flex: 1;
min-width: 0;
font-size: 26rpx;
line-height: 1.4;
word-break: break-all;
.chat-message__bubble--user & {
color: #ffffff;
}
.chat-message__bubble--service & {
color: $color-text-primary;
}
}

View File

@@ -1,9 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/customer-service' import type { ChatMessage } from '@/types/customer-service'
import fileIcon from '@/static/icons/PDF.png'
defineProps<{ const props = defineProps<{
message: ChatMessage message: ChatMessage
}>() }>()
const messageType = computed(() => props.message.type || 'text')
function handlePreviewImage() {
if (!props.message.imageUrl)
return
uni.previewImage({
urls: [props.message.imageUrl],
current: props.message.imageUrl,
})
}
function handleOpenFile() {
if (!props.message.filePath) {
uni.showToast({ title: props.message.fileName || '文件', icon: 'none' })
return
}
uni.openDocument({
filePath: props.message.filePath,
showMenu: true,
fail: () => {
uni.showToast({ title: `已发送${props.message.fileName || '文件'}`, icon: 'none' })
},
})
}
</script> </script>
<template> <template>
@@ -21,7 +50,37 @@ defineProps<{
<text class="chat-message__nickname"> <text class="chat-message__nickname">
{{ message.nickname }} {{ message.nickname }}
</text> </text>
<view <view
v-if="messageType === 'image' && message.imageUrl"
class="chat-message__image-wrap"
@tap="handlePreviewImage"
>
<image
class="chat-message__image"
:src="message.imageUrl"
mode="aspectFill"
/>
</view>
<view
v-else-if="messageType === 'file'"
class="chat-message__bubble chat-message__bubble--file"
:class="`chat-message__bubble--${message.role}`"
@tap="handleOpenFile"
>
<image
class="chat-message__file-icon"
:src="fileIcon"
mode="aspectFit"
/>
<text class="chat-message__file-name">
{{ message.fileName || message.content }}
</text>
</view>
<view
v-else
class="chat-message__bubble" class="chat-message__bubble"
:class="`chat-message__bubble--${message.role}`" :class="`chat-message__bubble--${message.role}`"
> >

View File

@@ -5,6 +5,9 @@ import type { ChatMessage } from '@/types/customer-service'
import { navigateBackWithFallback } from '@/utils/navigate-back' import { navigateBackWithFallback } from '@/utils/navigate-back'
import ChatMessageItem from './components/ChatMessage.vue' import ChatMessageItem from './components/ChatMessage.vue'
const USER_AVATAR = '/static/icons/hi.png'
const USER_NICKNAME = '昵称'
const messages = ref<ChatMessage[]>(CUSTOMER_SERVICE_MESSAGES.map(item => ({ ...item }))) const messages = ref<ChatMessage[]>(CUSTOMER_SERVICE_MESSAGES.map(item => ({ ...item })))
const inputValue = ref('') const inputValue = ref('')
const panelVisible = ref(false) const panelVisible = ref(false)
@@ -22,24 +25,137 @@ function handleSend() {
if (!content) if (!content)
return return
messages.value.push({ appendTextMessage(content)
id: String(Date.now()),
role: 'user',
nickname: '昵称',
content,
avatar: '/static/icons/hi.png',
})
inputValue.value = '' inputValue.value = ''
panelVisible.value = false panelVisible.value = false
} }
function handleAttachment(type: 'camera' | 'photo' | 'file') { function appendTextMessage(content: string) {
const labelMap = { messages.value.push({
camera: '拍摄', id: `${Date.now()}-${messages.value.length}`,
photo: '照片', role: 'user',
file: '文件', nickname: USER_NICKNAME,
content,
type: 'text',
avatar: USER_AVATAR,
})
}
function appendImageMessage(imageUrl: string) {
messages.value.push({
id: `${Date.now()}-${messages.value.length}`,
role: 'user',
nickname: USER_NICKNAME,
content: '[图片]',
type: 'image',
imageUrl,
avatar: USER_AVATAR,
})
panelVisible.value = false
}
function appendFileMessage(fileName: string, filePath: string) {
messages.value.push({
id: `${Date.now()}-${messages.value.length}`,
role: 'user',
nickname: USER_NICKNAME,
content: fileName,
type: 'file',
fileName,
filePath,
avatar: USER_AVATAR,
})
panelVisible.value = false
}
function isChooseCanceled(errMsg?: string) {
return Boolean(errMsg?.includes('cancel') || errMsg?.includes('取消'))
}
function handleChooseImageSuccess(tempFilePaths: string[]) {
if (!tempFilePaths.length) {
uni.showToast({ title: '未选择图片', icon: 'none' })
return
} }
uni.showToast({ title: `${labelMap[type]}功能开发中`, icon: 'none' })
tempFilePaths.forEach((path) => {
appendImageMessage(path)
})
}
function handleChooseImages(sourceType: Array<'album' | 'camera'>) {
uni.chooseImage({
count: 9,
sourceType,
success: (res) => {
handleChooseImageSuccess(res.tempFilePaths)
},
fail: (error) => {
if (isChooseCanceled(error.errMsg))
return
uni.showToast({ title: '选择图片失败', icon: 'none' })
},
})
}
function handleChooseFilesSuccess(files: Array<{ name: string, path: string }>) {
if (!files.length) {
uni.showToast({ title: '未选择文件', icon: 'none' })
return
}
files.forEach((file) => {
appendFileMessage(file.name, file.path)
})
}
function handleChooseFiles() {
uni.chooseFile({
count: 5,
extension: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar', '.png', '.jpg', '.jpeg'],
success: (res) => {
handleChooseFilesSuccess(res.tempFiles.map(file => ({
name: file.name,
path: file.path,
})))
},
fail: () => {
if (typeof uni.chooseMessageFile === 'function') {
uni.chooseMessageFile({
count: 5,
type: 'all',
success: (res) => {
handleChooseFilesSuccess(res.tempFiles.map(file => ({
name: file.name,
path: file.path,
})))
},
fail: (error) => {
if (isChooseCanceled(error.errMsg))
return
uni.showToast({ title: '选择文件失败', icon: 'none' })
},
})
return
}
uni.showToast({ title: '选择文件失败', icon: 'none' })
},
})
}
function handleAttachment(type: 'camera' | 'photo' | 'file') {
if (type === 'camera') {
handleChooseImages(['camera'])
return
}
if (type === 'photo') {
handleChooseImages(['album'])
return
}
handleChooseFiles()
} }
</script> </script>

29
src/types/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
export type SmsScene = 'register' | 'login' | 'resetPwd'
export interface SendSmsCodeParams {
phoneNumber: string
scene: SmsScene
}
export interface RegisterParams {
phoneNumber: string
smsCode: string
password: string
confirmPassword: string
}
export interface RegisterResult {
token?: string
userId?: string | number
}
export interface PasswordLoginParams {
phoneNumber: string
password: string
}
export interface LoginResult {
access_token: string
expire_in?: number
client_id?: string
}

View File

@@ -1,9 +1,15 @@
export type ChatRole = 'user' | 'service' export type ChatRole = 'user' | 'service'
export type ChatMessageType = 'text' | 'image' | 'file'
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: ChatRole role: ChatRole
nickname: string nickname: string
content: string content: string
avatar: string avatar: string
type?: ChatMessageType
imageUrl?: string
fileName?: string
filePath?: string
} }

13
src/utils/auth-storage.ts Normal file
View File

@@ -0,0 +1,13 @@
const ACCESS_TOKEN_KEY = 'access_token'
export function setAccessToken(token: string) {
uni.setStorageSync(ACCESS_TOKEN_KEY, token)
}
export function getAccessToken() {
return uni.getStorageSync(ACCESS_TOKEN_KEY) as string
}
export function clearAccessToken() {
uni.removeStorageSync(ACCESS_TOKEN_KEY)
}

98
src/utils/request.ts Normal file
View File

@@ -0,0 +1,98 @@
import { API_BASE_URL, API_TIMEOUT } from '@/constants/config'
import { getAccessToken } from '@/utils/auth-storage'
export interface RequestOptions {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: object | string
header?: Record<string, string>
timeout?: number
showErrorToast?: boolean
}
export interface ApiResponse<T = unknown> {
code?: number
message?: string
msg?: string
data?: T
}
function resolveUrl(url: string) {
if (/^https?:\/\//.test(url))
return url
const path = url.startsWith('/') ? url : `/${url}`
return `${API_BASE_URL}${path}`
}
function getErrorMessage(response: ApiResponse, fallback: string) {
return response.message || response.msg || fallback
}
export function request<T = unknown>(options: RequestOptions): Promise<T> {
const {
url,
method = 'GET',
data,
header,
timeout = API_TIMEOUT,
showErrorToast = true,
} = options
return new Promise((resolve, reject) => {
const token = getAccessToken()
uni.request({
url: resolveUrl(url),
method,
data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...header,
},
timeout,
success: (res) => {
const statusCode = res.statusCode
const body = res.data as ApiResponse<T> | T
if (statusCode >= 200 && statusCode < 300) {
if (body && typeof body === 'object' && 'code' in body) {
const apiBody = body as ApiResponse<T>
if (apiBody.code === 0 || apiBody.code === 200) {
resolve((apiBody.data ?? apiBody) as T)
return
}
const message = getErrorMessage(apiBody, '请求失败')
if (showErrorToast)
uni.showToast({ title: message, icon: 'none' })
reject(new Error(message))
return
}
resolve(body as T)
return
}
const message = `请求失败(${statusCode})`
if (showErrorToast)
uni.showToast({ title: message, icon: 'none' })
reject(new Error(message))
},
fail: (error) => {
const message = error.errMsg || '网络异常,请稍后重试'
if (showErrorToast)
uni.showToast({ title: message, icon: 'none' })
reject(new Error(message))
},
})
})
}
export function get<T = unknown>(url: string, data?: object, options?: Omit<RequestOptions, 'url' | 'method' | 'data'>) {
return request<T>({ url, method: 'GET', data, ...options })
}
export function post<T = unknown>(url: string, data?: object, options?: Omit<RequestOptions, 'url' | 'method' | 'data'>) {
return request<T>({ url, method: 'POST', data, ...options })
}

29
src/utils/sms-code.ts Normal file
View File

@@ -0,0 +1,29 @@
import { sendSmsCode } from '@/api/auth'
import type { SmsScene } from '@/types/auth'
const PHONE_REG = /^1\d{10}$/
export function validatePhone(phone: string) {
const value = phone.trim()
if (!value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return false
}
if (!PHONE_REG.test(value)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return false
}
return true
}
export async function requestSmsCode(phone: string, scene: SmsScene) {
if (!validatePhone(phone))
return false
await sendSmsCode({
phoneNumber: phone.trim(),
scene,
})
uni.showToast({ title: '验证码已发送', icon: 'none' })
return true
}

View File

@@ -13,7 +13,14 @@ export default defineConfig({
// https://uni-helper.js.org/plugin-uni // https://uni-helper.js.org/plugin-uni
Uni(), Uni(),
], ],
server: {
proxy: {
'/api': {
target: 'http://192.168.1.187:8080',
changeOrigin: true,
},
},
},
}) })