feat:注册功能
This commit is contained in:
3
.env.development
Normal file
3
.env.development
Normal 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
1
api-docs.json
Normal file
File diff suppressed because one or more lines are too long
38
src/api/auth.ts
Normal file
38
src/api/auth.ts
Normal 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
12
src/constants/config.ts
Normal 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
62
src/hooks/use-sms-code.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"usesCleartextTraffic": true,
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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({
|
||||
options: {
|
||||
@@ -7,8 +9,10 @@ defineOptions({
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
phone: string
|
||||
scene: SmsScene
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -16,17 +20,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const countdown = ref(0)
|
||||
const sending = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function onInput(e: { detail: { value: string } }) {
|
||||
emit('update:modelValue', e.detail.value)
|
||||
}
|
||||
|
||||
function handleGetCode() {
|
||||
if (countdown.value > 0)
|
||||
return
|
||||
|
||||
uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
function startCountdown() {
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
@@ -36,6 +37,31 @@ function handleGetCode() {
|
||||
}
|
||||
}, 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>
|
||||
|
||||
<template>
|
||||
@@ -50,11 +76,11 @@ function handleGetCode() {
|
||||
>
|
||||
<view
|
||||
class="code-input__btn"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 }"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
|
||||
@tap="handleGetCode"
|
||||
>
|
||||
<text class="code-input__btn-text">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
{{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -63,7 +63,11 @@ function handleUserAgreement() {
|
||||
</view>
|
||||
|
||||
<view class="forgot-page__field">
|
||||
<CodeInput v-model="code" />
|
||||
<CodeInput
|
||||
v-model="code"
|
||||
:phone="phone"
|
||||
scene="resetPwd"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="forgot-page__field">
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const phone = ref('')
|
||||
const password = ref('')
|
||||
const agreed = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
function toggleAgreed() {
|
||||
agreed.value = !agreed.value
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
function shouldNavigateToRegister(msg: string) {
|
||||
return /未注册|不存在|请先注册|创建账号/.test(msg)
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (submitting.value)
|
||||
return
|
||||
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
||||
return
|
||||
@@ -19,9 +31,37 @@ function handleLogin() {
|
||||
uni.showToast({ title: '请输入手机号和密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!validatePhone(phone.value))
|
||||
return
|
||||
|
||||
// TODO: 接入登录接口,失败时调用 navigateToResultPage('loginFailed')
|
||||
uni.redirectTo({ url: '/pages/home-index/index' })
|
||||
submitting.value = true
|
||||
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() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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({
|
||||
options: {
|
||||
@@ -7,8 +9,10 @@ defineOptions({
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
phone: string
|
||||
scene: SmsScene
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -16,17 +20,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const countdown = ref(0)
|
||||
const sending = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function onInput(e: { detail: { value: string } }) {
|
||||
emit('update:modelValue', e.detail.value)
|
||||
}
|
||||
|
||||
function handleGetCode() {
|
||||
if (countdown.value > 0)
|
||||
return
|
||||
|
||||
uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
function startCountdown() {
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
@@ -36,6 +37,31 @@ function handleGetCode() {
|
||||
}
|
||||
}, 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>
|
||||
|
||||
<template>
|
||||
@@ -55,11 +81,11 @@ function handleGetCode() {
|
||||
>
|
||||
<view
|
||||
class="code-input__btn"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 }"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
|
||||
@tap="handleGetCode"
|
||||
>
|
||||
<text class="code-input__btn-text">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
{{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { register } from '@/api/auth'
|
||||
import { getPasswordStrength } from '@/utils/password-strength'
|
||||
import { navigateToResultPage } from '@/utils/navigate-result'
|
||||
import CodeInput from './components/CodeInput.vue'
|
||||
@@ -12,12 +13,16 @@ const code = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const agreed = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
function toggleAgreed() {
|
||||
agreed.value = !agreed.value
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
async function handleRegister() {
|
||||
if (submitting.value)
|
||||
return
|
||||
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
||||
return
|
||||
@@ -34,7 +39,26 @@ function handleRegister() {
|
||||
uni.showToast({ title: '密码等级过低,请重新输入', icon: 'none' })
|
||||
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() {
|
||||
@@ -70,7 +94,11 @@ function handleUserAgreement() {
|
||||
</view>
|
||||
|
||||
<view class="register-page__field">
|
||||
<CodeInput v-model="code" />
|
||||
<CodeInput
|
||||
v-model="code"
|
||||
:phone="phone"
|
||||
scene="register"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="register-page__field">
|
||||
@@ -130,9 +158,10 @@ function handleUserAgreement() {
|
||||
|
||||
<button
|
||||
class="register-page__submit"
|
||||
:disabled="submitting"
|
||||
@tap="handleRegister"
|
||||
>
|
||||
注册
|
||||
{{ submitting ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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({
|
||||
options: {
|
||||
@@ -7,8 +9,10 @@ defineOptions({
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
phone: string
|
||||
scene: SmsScene
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -16,17 +20,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const countdown = ref(0)
|
||||
const sending = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function onInput(e: { detail: { value: string } }) {
|
||||
emit('update:modelValue', e.detail.value)
|
||||
}
|
||||
|
||||
function handleGetCode() {
|
||||
if (countdown.value > 0)
|
||||
return
|
||||
|
||||
uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
function startCountdown() {
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
@@ -36,6 +37,31 @@ function handleGetCode() {
|
||||
}
|
||||
}, 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>
|
||||
|
||||
<template>
|
||||
@@ -55,11 +81,11 @@ function handleGetCode() {
|
||||
>
|
||||
<view
|
||||
class="code-input__btn"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 }"
|
||||
:class="{ 'code-input__btn--disabled': countdown > 0 || sending }"
|
||||
@tap="handleGetCode"
|
||||
>
|
||||
<text class="code-input__btn-text">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
{{ sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -62,7 +62,11 @@ function handleUserAgreement() {
|
||||
/>
|
||||
|
||||
<view class="sms-login-page__code-group">
|
||||
<CodeInput v-model="code" />
|
||||
<CodeInput
|
||||
v-model="code"
|
||||
:phone="phone"
|
||||
scene="login"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
|
||||
@@ -73,3 +73,45 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChatMessage } from '@/types/customer-service'
|
||||
import fileIcon from '@/static/icons/PDF.png'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -21,7 +50,37 @@ defineProps<{
|
||||
<text class="chat-message__nickname">
|
||||
{{ message.nickname }}
|
||||
</text>
|
||||
|
||||
<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--${message.role}`"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { ChatMessage } from '@/types/customer-service'
|
||||
import { navigateBackWithFallback } from '@/utils/navigate-back'
|
||||
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 inputValue = ref('')
|
||||
const panelVisible = ref(false)
|
||||
@@ -22,24 +25,137 @@ function handleSend() {
|
||||
if (!content)
|
||||
return
|
||||
|
||||
messages.value.push({
|
||||
id: String(Date.now()),
|
||||
role: 'user',
|
||||
nickname: '昵称',
|
||||
content,
|
||||
avatar: '/static/icons/hi.png',
|
||||
})
|
||||
appendTextMessage(content)
|
||||
inputValue.value = ''
|
||||
panelVisible.value = false
|
||||
}
|
||||
|
||||
function handleAttachment(type: 'camera' | 'photo' | 'file') {
|
||||
const labelMap = {
|
||||
camera: '拍摄',
|
||||
photo: '照片',
|
||||
file: '文件',
|
||||
function appendTextMessage(content: string) {
|
||||
messages.value.push({
|
||||
id: `${Date.now()}-${messages.value.length}`,
|
||||
role: 'user',
|
||||
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>
|
||||
|
||||
|
||||
29
src/types/auth.ts
Normal file
29
src/types/auth.ts
Normal 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
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
export type ChatRole = 'user' | 'service'
|
||||
|
||||
export type ChatMessageType = 'text' | 'image' | 'file'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: ChatRole
|
||||
nickname: string
|
||||
content: string
|
||||
avatar: string
|
||||
type?: ChatMessageType
|
||||
imageUrl?: string
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
13
src/utils/auth-storage.ts
Normal file
13
src/utils/auth-storage.ts
Normal 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
98
src/utils/request.ts
Normal 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
29
src/utils/sms-code.ts
Normal 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
|
||||
}
|
||||
@@ -13,7 +13,14 @@ export default defineConfig({
|
||||
// https://uni-helper.js.org/plugin-uni
|
||||
Uni(),
|
||||
],
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.187:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user