refactor(auth): 重构认证逻辑,统一使用X-Access-Token feat(api): 新增微信支付相关API接口 feat(components): 添加WechatPayment微信支付组件 feat(components): 添加PhoneModifyModal手机号修改弹窗 refactor(store): 优化用户状态管理和token处理逻辑 fix(bookshelf): 修复书架API命名不一致问题 style: 统一原创标签样式和显示逻辑master
@ -1,174 +1,211 @@ | |||
import api from './index.js'; | |||
/** | |||
* 书架-阅读书架相关接口 | |||
* 书架相关接口 | |||
*/ | |||
export const readBookApi = { | |||
/** | |||
* 增加我的书架阅读记录 | |||
* @param {Object} bookData 书籍数据 | |||
*/ | |||
addReadBook(bookData) { | |||
return api.post('/all_book/addReadBook', null, { | |||
params: bookData | |||
}); | |||
}, | |||
/** | |||
* 批量移除我阅读过的数据根据书籍标识 | |||
* @param {string} bookIds 书籍ID列表,逗号分隔 | |||
*/ | |||
batchRemoveReadBook(bookIds) { | |||
return api.get('/all_book/batchRemoveReadBook', { | |||
params: { bookIds } | |||
}); | |||
}, | |||
/** | |||
* 获取我阅读过的书籍列表带分页 | |||
* @param {Object} params 分页参数 | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
*/ | |||
getReadBookPage(params) { | |||
return api.get('/all_book/getReadBookPage', { params }); | |||
}, | |||
/** | |||
* 移除我阅读过的书籍根据书籍标识 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
removeReadBook(bookId) { | |||
return api.get('/all_book/removeReadBook', { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 修改我的书籍信息 | |||
* @param {Object} bookData 书籍数据 | |||
*/ | |||
saveOrUpdateReadBook(bookData) { | |||
return api.post('/all_book/saveOrUpdateReadBook', null, { | |||
params: bookData | |||
}); | |||
} | |||
export const bookshelfApi = { | |||
/** | |||
* 获取我阅读过的书籍列表带分页 | |||
* @param {Object} params 查询参数 | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
*/ | |||
getReadBookPage(params) { | |||
return api.get('/all_book/getReadBookPage', { params }); | |||
}, | |||
/** | |||
* 修改我的书架信息 | |||
* @param {Object} params 书架信息 | |||
*/ | |||
saveOrUpdateReadBook(params) { | |||
return api.post('/all_book/saveOrUpdateReadBook', null, { params }); | |||
}, | |||
/** | |||
* 增加我的书架阅读记录 | |||
* @param {Object} params 书架记录 | |||
* @param {string} params.shopId 书籍ID | |||
* @param {string} params.name 书名 | |||
* @param {string} params.image 封面 | |||
* @param {string} params.novelId 章节ID | |||
*/ | |||
addReadBook(params) { | |||
return api.post('/all_book/addReadBook', null, { params }); | |||
}, | |||
/** | |||
* 查询这本书是否加入书架 | |||
* @param {number} bookId 书籍ID | |||
*/ | |||
isAddBook(bookId) { | |||
return api.get('/all_book/isAddBook', { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 移除我阅读过的书籍根据书籍标识 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
removeReadBook(bookId) { | |||
return api.get('/all_book/removeReadBook', { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 批量移除我阅读过的数据根据书籍标识 | |||
* @param {string} bookIds 书籍ID列表,逗号分隔 | |||
*/ | |||
batchRemoveReadBook(bookIds) { | |||
return api.get('/all_book/batchRemoveReadBook', { | |||
params: { bookIds } | |||
}); | |||
}, | |||
/** | |||
* 根据书籍id删除书架 | |||
* @param {number} bookId 书籍ID | |||
*/ | |||
deleteBookshelfByBookId(bookId) { | |||
return api.post('/all_book/deleteBookshelfByBookId', null, { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 根据书籍id获取当前阅读章节 | |||
* @param {number} bookId 书籍ID | |||
*/ | |||
getReadChapterByBookId(bookId) { | |||
return api.get('/all_book/getReadChapterByBookId', { | |||
params: { bookId } | |||
}); | |||
} | |||
}; | |||
/** | |||
* 书架-我的作品相关接口 | |||
*/ | |||
export const myBookApi = { | |||
/** | |||
* 删除作品章节 | |||
* @param {Object} params 删除参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {string} params.id 章节ID | |||
*/ | |||
deleteMyNovel(params) { | |||
return api.post('/my_book/deleteMyNovel', null, { params }); | |||
}, | |||
/** | |||
* 删除我的作品 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
deleteMyShop(bookId) { | |||
return api.post('/my_book/deleteMyShop', null, { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 多选删除我的作品 | |||
* @param {string} bookIds 书籍ID列表,逗号分隔 | |||
*/ | |||
deleteMyShopList(bookIds) { | |||
return api.post('/my_book/deleteMyShopList', null, { | |||
params: { bookIds } | |||
}); | |||
}, | |||
/** | |||
* 获取我的小说章节列表带分页 | |||
* @param {Object} params 查询参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
* @param {number} params.reverse 是否倒序 | |||
* @param {number} params.status 状态 | |||
*/ | |||
getMyShopNovelPage(params) { | |||
return api.post('/my_book/getMyShopNovelPage', null, { params }); | |||
}, | |||
/** | |||
* 获取我的作品带分页 | |||
* @param {Object} params 分页参数 | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
*/ | |||
getMyShopPage(params) { | |||
return api.get('/my_book/getMyShopPage', { params }); | |||
}, | |||
/** | |||
* 查询我是否购买了这个章节 | |||
* @param {Object} params 查询参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {string} params.novelId 章节ID | |||
*/ | |||
getMyShopNovel(params) { | |||
return api.get('/my_book/getMyShopNovel', { params }); | |||
}, | |||
/** | |||
* 添加作品或者修改作品 | |||
* @param {Object} shopData 作品数据 | |||
*/ | |||
saveOrUpdateShop(shopData) { | |||
return api.post('/my_book/saveOrUpdateShop', null, { | |||
params: shopData | |||
}); | |||
}, | |||
/** | |||
* 增加或修改作品章节 | |||
* @param {Object} novelData 章节数据 | |||
*/ | |||
saveOrUpdateShopNovel(data) { | |||
return api.post('/my_book/saveOrUpdateShopNovel', data); | |||
} | |||
/** | |||
* 删除作品章节 | |||
* @param {Object} params 删除参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {string} params.id 章节ID | |||
*/ | |||
deleteMyNovel(params) { | |||
return api.post('/my_book/deleteMyNovel', null, { params }); | |||
}, | |||
/** | |||
* 删除我的作品 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
deleteMyShop(bookId) { | |||
return api.post('/my_book/deleteMyShop', null, { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 多选删除我的作品 | |||
* @param {string} bookIds 书籍ID列表,逗号分隔 | |||
*/ | |||
deleteMyShopList(bookIds) { | |||
return api.post('/my_book/deleteMyShopList', null, { | |||
params: { bookIds } | |||
}); | |||
}, | |||
/** | |||
* 获取我的小说章节列表带分页 | |||
* @param {Object} params 查询参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
* @param {number} params.reverse 是否倒序 | |||
* @param {number} params.status 状态 | |||
*/ | |||
getMyShopNovelPage(params) { | |||
return api.post('/my_book/getMyShopNovelPage', null, { params }); | |||
}, | |||
/** | |||
* 获取我的作品带分页 | |||
* @param {Object} params 分页参数 | |||
* @param {number} params.pageNo 当前页 | |||
* @param {number} params.pageSize 显示条数 | |||
*/ | |||
getMyShopPage(params) { | |||
return api.get('/my_book/getMyShopPage', { params }); | |||
}, | |||
/** | |||
* 查询我是否购买了这个章节 | |||
* @param {Object} params 查询参数 | |||
* @param {string} params.bookId 书籍ID | |||
* @param {string} params.novelId 章节ID | |||
*/ | |||
getMyShopNovel(params) { | |||
return api.get('/my_book/getMyShopNovel', { params }); | |||
}, | |||
/** | |||
* 添加作品或者修改作品 | |||
* @param {Object} shopData 作品数据 | |||
*/ | |||
saveOrUpdateShop(shopData) { | |||
return api.post('/my_book/saveOrUpdateShop', null, { | |||
params: shopData | |||
}); | |||
}, | |||
/** | |||
* 增加或修改作品章节 | |||
* @param {Object} novelData 章节数据 | |||
*/ | |||
saveOrUpdateShopNovel(data) { | |||
return api.post('/my_book/saveOrUpdateShopNovel', data); | |||
} | |||
}; | |||
/** | |||
* 书架-读者成就相关接口 | |||
*/ | |||
export const achievementApi = { | |||
/** | |||
* 根据用户标识和书籍标识查询该用户的成就等级 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
getAchievement(bookId) { | |||
return api.get('/all_achievement/getAchievement', { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 获取读者成就列表 | |||
*/ | |||
getAchievementList() { | |||
return api.get('/all_achievement/getAchievementList'); | |||
}, | |||
/** | |||
* 设置读者成就等级名称 | |||
* @param {Object} achievementData 成就数据 | |||
*/ | |||
setAchievementName(achievementData) { | |||
return api.post('/all_achievement/setAchievementName', null, { | |||
params: achievementData | |||
}); | |||
} | |||
/** | |||
* 根据用户标识和书籍标识查询该用户的成就等级 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
getAchievementByBookId(bookId) { | |||
return api.get('/all_achievement/getAchievementByBookId', { | |||
params: { bookId } | |||
}); | |||
}, | |||
/** | |||
* 获取作者设置的成就等级对象 | |||
* @param {string} bookId 书籍ID | |||
*/ | |||
getAchievement() { | |||
return api.get('/all_achievement/getAchievement'); | |||
}, | |||
/** | |||
* 获取读者成就列表 | |||
*/ | |||
getAchievementList() { | |||
return api.get('/all_achievement/getAchievementList'); | |||
}, | |||
/** | |||
* 设置读者成就等级名称 | |||
* @param {Object} achievementData 成就数据 | |||
*/ | |||
setAchievementName(achievementData) { | |||
return api.post('/all_achievement/setAchievementName', null, { | |||
params: achievementData | |||
}); | |||
} | |||
}; |
@ -0,0 +1,514 @@ | |||
<template> | |||
<div class="personal-info-modal"> | |||
<el-dialog | |||
v-model="visible" | |||
title="个人信息" | |||
width="400px" | |||
:close-on-click-modal="false" | |||
:close-on-press-escape="true" | |||
@close="handleClose" | |||
> | |||
<div class="modal-content"> | |||
<!-- 头像上传区域 --> | |||
<div class="avatar-section"> | |||
<div class="avatar-wrapper" @click="handleAvatarClick"> | |||
<el-avatar :size="80" :src="userInfo.headImage"> | |||
<el-icon v-if="!userInfo.headImage"><UserFilled /></el-icon> | |||
</el-avatar> | |||
<div class="camera-overlay"> | |||
<el-icon class="camera-icon"><Camera /></el-icon> | |||
</div> | |||
</div> | |||
<!-- 隐藏的文件上传输入框 --> | |||
<input | |||
ref="fileInputRef" | |||
type="file" | |||
accept="image/*" | |||
style="display: none" | |||
@change="handleFileChange" | |||
/> | |||
</div> | |||
<!-- 表单区域 --> | |||
<el-form | |||
ref="formRef" | |||
:model="formData" | |||
:rules="formRules" | |||
label-position="left" | |||
label-width="80px" | |||
class="info-form" | |||
> | |||
<!-- 昵称 --> | |||
<el-form-item label="昵称" prop="nickName" required> | |||
<el-input | |||
v-model="formData.nickName" | |||
placeholder="请输入昵称" | |||
maxlength="20" | |||
show-word-limit | |||
@blur="handleNickNameBlur" | |||
/> | |||
</el-form-item> | |||
<!-- 个性签名 --> | |||
<el-form-item label="个性签名"> | |||
<el-input | |||
v-model="formData.details" | |||
placeholder="请输入个性签名" | |||
maxlength="50" | |||
show-word-limit | |||
@blur="handleSignatureBlur" | |||
/> | |||
</el-form-item> | |||
<!-- 手机号 --> | |||
<el-form-item label="手机号"> | |||
<div class="phone-container"> | |||
<span class="phone-number">{{ maskPhoneNumber(userInfo.phone) }}</span> | |||
<el-button | |||
type="text" | |||
class="modify-link" | |||
@click="handleModifyPhone" | |||
> | |||
修改手机号 | |||
</el-button> | |||
</div> | |||
</el-form-item> | |||
<!-- 豆豆 --> | |||
<el-form-item label="豆豆"> | |||
<div class="bean-container"> | |||
<span class="bean-number">{{ userInfo.integerPrice || 0 }}</span> | |||
<el-button | |||
type="text" | |||
class="detail-link" | |||
@click="handleViewDetails" | |||
> | |||
查看详情 | |||
</el-button> | |||
</div> | |||
</el-form-item> | |||
</el-form> | |||
</div> | |||
<!-- 底部按钮 --> | |||
<template #footer> | |||
<div class="dialog-footer"> | |||
<el-button | |||
type="primary" | |||
class="recharge-btn" | |||
@click="handleRecharge" | |||
> | |||
去充值 | |||
</el-button> | |||
</div> | |||
</template> | |||
</el-dialog> | |||
<!-- 手机号修改弹窗 --> | |||
<PhoneModifyModal | |||
v-model="showPhoneModal" | |||
@success="handlePhoneUpdateSuccess" | |||
/> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref, reactive, computed, watch, nextTick } from 'vue'; | |||
import { ElMessage, ElMessageBox } from 'element-plus'; | |||
import { Camera, UserFilled } from '@element-plus/icons-vue'; | |||
import { useMainStore } from '@/store'; | |||
import { useRouter } from 'vue-router'; | |||
import { ossService } from '@/utils/oss.js'; | |||
import PhoneModifyModal from './PhoneModifyModal.vue'; | |||
export default { | |||
name: 'PersonalInfoModal', | |||
components: { | |||
Camera, | |||
UserFilled, | |||
PhoneModifyModal | |||
}, | |||
props: { | |||
modelValue: { | |||
type: Boolean, | |||
default: false | |||
} | |||
}, | |||
emits: ['update:modelValue', 'success'], | |||
setup(props, { emit }) { | |||
const store = useMainStore(); | |||
const router = useRouter(); | |||
const formRef = ref(null); | |||
const fileInputRef = ref(null); | |||
const showPhoneModal = ref(false); | |||
const visible = computed({ | |||
get: () => props.modelValue, | |||
set: (value) => emit('update:modelValue', value) | |||
}); | |||
// 用户信息 | |||
const userInfo = computed(() => store.user || {}); | |||
// 表单数据 | |||
const formData = reactive({ | |||
nickName: '', | |||
details: '' | |||
}); | |||
// 表单验证规则 | |||
const formRules = { | |||
nickName: [ | |||
{ required: true, message: '请输入昵称', trigger: 'blur' }, | |||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' } | |||
] | |||
}; | |||
// 手机号脱敏显示 | |||
const maskPhoneNumber = (phone) => { | |||
if (!phone) return '未绑定'; | |||
if (phone.length === 11) { | |||
return `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}`; | |||
} | |||
return phone; | |||
}; | |||
// 监听弹窗显示状态,初始化数据 | |||
watch(visible, (newVal) => { | |||
if (newVal) { | |||
initFormData(); | |||
} | |||
}); | |||
// 初始化表单数据 | |||
const initFormData = () => { | |||
formData.nickName = userInfo.value.nickName || ''; | |||
formData.details = userInfo.value.details || ''; | |||
}; | |||
// 头像点击处理 | |||
const handleAvatarClick = () => { | |||
fileInputRef.value?.click(); | |||
}; | |||
// 文件选择处理 | |||
const handleFileChange = async (event) => { | |||
const file = event.target.files[0]; | |||
if (!file) return; | |||
// 验证文件类型 | |||
if (!file.type.startsWith('image/')) { | |||
ElMessage.error('请选择图片文件'); | |||
return; | |||
} | |||
// 验证文件大小(2MB) | |||
if (file.size > 2 * 1024 * 1024) { | |||
ElMessage.error('图片大小不能超过2MB'); | |||
return; | |||
} | |||
try { | |||
const loadingMessage = ElMessage.loading('上传头像中...'); | |||
// 上传到OSS | |||
const imageUrl = await ossService.uploadImage(file); | |||
// 更新用户头像 | |||
await store.updateUserInfo({ | |||
headImage: imageUrl | |||
}); | |||
loadingMessage.close(); | |||
ElMessage.success('头像更新成功'); | |||
} catch (error) { | |||
console.error('头像上传失败:', error); | |||
ElMessage.error('头像上传失败,请重试'); | |||
} | |||
// 清空input值,允许重复选择同一文件 | |||
event.target.value = ''; | |||
}; | |||
// 修改手机号 | |||
const handleModifyPhone = () => { | |||
showPhoneModal.value = true; | |||
}; | |||
// 手机号更新成功回调 | |||
const handlePhoneUpdateSuccess = () => { | |||
ElMessage.success('手机号修改成功'); | |||
}; | |||
// 查看详情 | |||
const handleViewDetails = () => { | |||
visible.value = false; | |||
router.push('/money-log'); | |||
}; | |||
// 去充值 | |||
const handleRecharge = () => { | |||
visible.value = false; | |||
router.push('/recharge'); | |||
}; | |||
// 保存个人信息 | |||
const handleSave = async () => { | |||
try { | |||
await formRef.value?.validate(); | |||
const loadingMessage = ElMessage.loading('保存中...'); | |||
await store.updateUserInfo({ | |||
nickName: formData.nickName, | |||
details: formData.details | |||
}); | |||
loadingMessage.close(); | |||
ElMessage.success('保存成功'); | |||
emit('success'); | |||
visible.value = false; | |||
} catch (error) { | |||
console.error('保存失败:', error); | |||
ElMessage.error('保存失败,请重试'); | |||
} | |||
}; | |||
// 昵称失焦保存 | |||
const handleNickNameBlur = async () => { | |||
if (formData.nickName && formData.nickName !== userInfo.value.nickName) { | |||
try { | |||
await formRef.value?.validateField('nickName'); | |||
await store.updateUserInfo({ | |||
nickName: formData.nickName | |||
}); | |||
ElMessage.success('昵称保存成功'); | |||
} catch (error) { | |||
console.error('昵称保存失败:', error); | |||
} | |||
} | |||
}; | |||
// 个性签名失焦保存 | |||
const handleSignatureBlur = async () => { | |||
if (formData.details !== userInfo.value.details) { | |||
try { | |||
await store.updateUserInfo({ | |||
details: formData.details | |||
}); | |||
ElMessage.success('个性签名保存成功'); | |||
} catch (error) { | |||
console.error('个性签名保存失败:', error); | |||
} | |||
} | |||
}; | |||
// 关闭弹窗 | |||
const handleClose = () => { | |||
visible.value = false; | |||
}; | |||
return { | |||
visible, | |||
formRef, | |||
fileInputRef, | |||
formData, | |||
formRules, | |||
userInfo, | |||
showPhoneModal, | |||
maskPhoneNumber, | |||
handleAvatarClick, | |||
handleFileChange, | |||
handleModifyPhone, | |||
handlePhoneUpdateSuccess, | |||
handleViewDetails, | |||
handleRecharge, | |||
handleNickNameBlur, | |||
handleSignatureBlur, | |||
handleClose | |||
}; | |||
} | |||
}; | |||
</script> | |||
<style lang="scss" scoped> | |||
.personal-info-modal { | |||
.modal-content { | |||
padding: 0; | |||
.avatar-section { | |||
display: flex; | |||
justify-content: center; | |||
margin-bottom: 24px; | |||
.avatar-wrapper { | |||
position: relative; | |||
cursor: pointer; | |||
transition: transform 0.3s ease; | |||
&:hover { | |||
transform: scale(1.05); | |||
.camera-overlay { | |||
opacity: 1; | |||
} | |||
} | |||
.camera-overlay { | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
right: 0; | |||
bottom: 0; | |||
background: rgba(0, 0, 0, 0.5); | |||
border-radius: 50%; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
opacity: 0; | |||
transition: opacity 0.3s ease; | |||
.camera-icon { | |||
color: white; | |||
font-size: 24px; | |||
} | |||
} | |||
:deep(.el-avatar) { | |||
border: 3px solid #f0f0f0; | |||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |||
} | |||
} | |||
} | |||
.info-form { | |||
.phone-container, | |||
.bean-container { | |||
display: flex; | |||
align-items: center; | |||
justify-content: space-between; | |||
width: 100%; | |||
.phone-number, | |||
.bean-number { | |||
font-size: 16px; | |||
color: #606266; | |||
flex: 1; | |||
} | |||
.modify-link, | |||
.detail-link { | |||
color: #0A2463; | |||
font-size: 14px; | |||
padding: 0; | |||
margin-left: 12px; | |||
&:hover { | |||
color: #083354; | |||
} | |||
} | |||
} | |||
:deep(.el-form-item__label) { | |||
font-weight: 500; | |||
color: #303133; | |||
} | |||
:deep(.el-input__inner) { | |||
border-radius: 6px; | |||
} | |||
:deep(.el-form-item) { | |||
margin-bottom: 20px; | |||
} | |||
:deep(.el-form-item__label::before) { | |||
color: #F56C6C; | |||
} | |||
} | |||
} | |||
.dialog-footer { | |||
display: flex; | |||
justify-content: center; | |||
padding-top: 10px; | |||
.recharge-btn { | |||
width: 100%; | |||
height: 44px; | |||
background-color: #0A2463; | |||
border-color: #0A2463; | |||
font-size: 16px; | |||
font-weight: 600; | |||
border-radius: 8px; | |||
&:hover { | |||
background-color: #083354; | |||
border-color: #083354; | |||
} | |||
} | |||
} | |||
:deep(.el-dialog__header) { | |||
padding: 20px 20px 10px 20px; | |||
text-align: center; | |||
.el-dialog__title { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: #303133; | |||
} | |||
} | |||
:deep(.el-dialog__body) { | |||
padding: 10px 20px 20px 20px; | |||
} | |||
:deep(.el-dialog__footer) { | |||
padding: 10px 20px 20px 20px; | |||
} | |||
} | |||
// 响应式设计 | |||
@media (max-width: 768px) { | |||
.personal-info-modal { | |||
:deep(.el-dialog) { | |||
width: 90% !important; | |||
margin: 5vh auto !important; | |||
} | |||
.modal-content { | |||
.avatar-section { | |||
margin-bottom: 20px; | |||
.avatar-wrapper { | |||
:deep(.el-avatar) { | |||
width: 70px !important; | |||
height: 70px !important; | |||
} | |||
} | |||
} | |||
.info-form { | |||
:deep(.el-form-item) { | |||
margin-bottom: 16px; | |||
} | |||
:deep(.el-form-item__label) { | |||
width: 70px !important; | |||
font-size: 14px; | |||
} | |||
} | |||
} | |||
.dialog-footer { | |||
.recharge-btn { | |||
height: 40px; | |||
font-size: 14px; | |||
} | |||
} | |||
} | |||
} | |||
</style> |
@ -0,0 +1,341 @@ | |||
<template> | |||
<div class="phone-modify-modal"> | |||
<el-dialog | |||
v-model="visible" | |||
title="修改手机号" | |||
width="400px" | |||
:close-on-click-modal="false" | |||
:close-on-press-escape="true" | |||
@close="handleClose" | |||
> | |||
<el-form | |||
ref="formRef" | |||
:model="formData" | |||
:rules="formRules" | |||
label-width="100px" | |||
class="phone-form" | |||
> | |||
<!-- 当前手机号 --> | |||
<el-form-item label="当前手机号"> | |||
<span class="current-phone">{{ maskPhoneNumber(currentPhone) }}</span> | |||
</el-form-item> | |||
<!-- 新手机号 --> | |||
<el-form-item label="新手机号" prop="newPhone"> | |||
<el-input | |||
v-model="formData.newPhone" | |||
placeholder="请输入新手机号" | |||
maxlength="11" | |||
> | |||
<template #prefix> | |||
<span class="phone-prefix">+86</span> | |||
</template> | |||
</el-input> | |||
</el-form-item> | |||
<!-- 验证码 --> | |||
<el-form-item label="验证码" prop="verifyCode"> | |||
<div class="verify-code-container"> | |||
<el-input | |||
v-model="formData.verifyCode" | |||
placeholder="请输入验证码" | |||
maxlength="6" | |||
/> | |||
<el-button | |||
:disabled="!canSendCode || countdown > 0" | |||
@click="sendVerifyCode" | |||
class="send-code-btn" | |||
> | |||
{{ countdown > 0 ? `${countdown}s后重发` : '发送验证码' }} | |||
</el-button> | |||
</div> | |||
</el-form-item> | |||
</el-form> | |||
<template #footer> | |||
<div class="dialog-footer"> | |||
<el-button @click="handleClose">取消</el-button> | |||
<el-button | |||
type="primary" | |||
:loading="loading" | |||
@click="handleConfirm" | |||
> | |||
确认修改 | |||
</el-button> | |||
</div> | |||
</template> | |||
</el-dialog> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref, reactive, computed, watch } from 'vue'; | |||
import { ElMessage } from 'element-plus'; | |||
import { useMainStore } from '@/store'; | |||
export default { | |||
name: 'PhoneModifyModal', | |||
props: { | |||
modelValue: { | |||
type: Boolean, | |||
default: false | |||
} | |||
}, | |||
emits: ['update:modelValue', 'success'], | |||
setup(props, { emit }) { | |||
const store = useMainStore(); | |||
const formRef = ref(null); | |||
const loading = ref(false); | |||
const countdown = ref(0); | |||
let countdownTimer = null; | |||
const visible = computed({ | |||
get: () => props.modelValue, | |||
set: (value) => emit('update:modelValue', value) | |||
}); | |||
// 表单数据 | |||
const formData = reactive({ | |||
newPhone: '', | |||
verifyCode: '' | |||
}); | |||
// 当前手机号 | |||
const currentPhone = computed(() => store.user?.phone || ''); | |||
// 是否可以发送验证码 | |||
const canSendCode = computed(() => { | |||
return /^1[3-9]\d{9}$/.test(formData.newPhone); | |||
}); | |||
// 表单验证规则 | |||
const formRules = { | |||
newPhone: [ | |||
{ required: true, message: '请输入新手机号', trigger: 'blur' }, | |||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' } | |||
], | |||
verifyCode: [ | |||
{ required: true, message: '请输入验证码', trigger: 'blur' }, | |||
{ pattern: /^\d{6}$/, message: '验证码格式不正确', trigger: 'blur' } | |||
] | |||
}; | |||
// 手机号脱敏显示 | |||
const maskPhoneNumber = (phone) => { | |||
if (!phone) return '未绑定'; | |||
if (phone.length === 11) { | |||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`; | |||
} | |||
return phone; | |||
}; | |||
// 监听弹窗显示状态 | |||
watch(visible, (newVal) => { | |||
if (newVal) { | |||
resetForm(); | |||
} else { | |||
stopCountdown(); | |||
} | |||
}); | |||
// 重置表单 | |||
const resetForm = () => { | |||
formData.newPhone = ''; | |||
formData.verifyCode = ''; | |||
formRef.value?.clearValidate(); | |||
}; | |||
// 发送验证码 | |||
const sendVerifyCode = async () => { | |||
if (!canSendCode.value) return; | |||
try { | |||
loading.value = true; | |||
// 这里调用发送验证码的API | |||
// await api.sendPhoneVerifyCode(formData.newPhone); | |||
// 模拟API调用 | |||
await new Promise(resolve => setTimeout(resolve, 1000)); | |||
ElMessage.success('验证码已发送'); | |||
startCountdown(); | |||
} catch (error) { | |||
console.error('发送验证码失败:', error); | |||
ElMessage.error('发送验证码失败,请重试'); | |||
} finally { | |||
loading.value = false; | |||
} | |||
}; | |||
// 开始倒计时 | |||
const startCountdown = () => { | |||
countdown.value = 60; | |||
countdownTimer = setInterval(() => { | |||
countdown.value--; | |||
if (countdown.value <= 0) { | |||
stopCountdown(); | |||
} | |||
}, 1000); | |||
}; | |||
// 停止倒计时 | |||
const stopCountdown = () => { | |||
if (countdownTimer) { | |||
clearInterval(countdownTimer); | |||
countdownTimer = null; | |||
} | |||
countdown.value = 0; | |||
}; | |||
// 确认修改 | |||
const handleConfirm = async () => { | |||
try { | |||
await formRef.value?.validate(); | |||
loading.value = true; | |||
// 这里调用修改手机号的API | |||
// await api.modifyPhone({ | |||
// newPhone: formData.newPhone, | |||
// verifyCode: formData.verifyCode | |||
// }); | |||
// 模拟API调用 | |||
await new Promise(resolve => setTimeout(resolve, 1500)); | |||
// 更新store中的用户信息 | |||
await store.updateUserInfo({ | |||
phone: formData.newPhone | |||
}); | |||
ElMessage.success('手机号修改成功'); | |||
emit('success'); | |||
visible.value = false; | |||
} catch (error) { | |||
console.error('修改手机号失败:', error); | |||
ElMessage.error('修改失败,请重试'); | |||
} finally { | |||
loading.value = false; | |||
} | |||
}; | |||
// 关闭弹窗 | |||
const handleClose = () => { | |||
visible.value = false; | |||
}; | |||
return { | |||
visible, | |||
formRef, | |||
formData, | |||
formRules, | |||
currentPhone, | |||
canSendCode, | |||
countdown, | |||
loading, | |||
maskPhoneNumber, | |||
sendVerifyCode, | |||
handleConfirm, | |||
handleClose | |||
}; | |||
} | |||
}; | |||
</script> | |||
<style lang="scss" scoped> | |||
.phone-modify-modal { | |||
.phone-form { | |||
.current-phone { | |||
color: #606266; | |||
font-size: 16px; | |||
} | |||
.phone-prefix { | |||
color: #909399; | |||
margin-right: 8px; | |||
} | |||
.verify-code-container { | |||
display: flex; | |||
gap: 8px; | |||
.el-input { | |||
flex: 1; | |||
} | |||
.send-code-btn { | |||
white-space: nowrap; | |||
min-width: 100px; | |||
} | |||
} | |||
:deep(.el-form-item__label) { | |||
font-weight: 500; | |||
color: #303133; | |||
} | |||
:deep(.el-input__inner) { | |||
border-radius: 6px; | |||
} | |||
:deep(.el-form-item) { | |||
margin-bottom: 20px; | |||
} | |||
} | |||
.dialog-footer { | |||
display: flex; | |||
justify-content: flex-end; | |||
gap: 12px; | |||
.el-button--primary { | |||
background-color: #0A2463; | |||
border-color: #0A2463; | |||
&:hover { | |||
background-color: #083354; | |||
border-color: #083354; | |||
} | |||
} | |||
} | |||
:deep(.el-dialog__header) { | |||
text-align: center; | |||
.el-dialog__title { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: #303133; | |||
} | |||
} | |||
} | |||
// 响应式设计 | |||
@media (max-width: 768px) { | |||
.phone-modify-modal { | |||
:deep(.el-dialog) { | |||
width: 90% !important; | |||
margin: 5vh auto !important; | |||
} | |||
.phone-form { | |||
:deep(.el-form-item__label) { | |||
width: 80px !important; | |||
font-size: 14px; | |||
} | |||
.verify-code-container { | |||
flex-direction: column; | |||
gap: 12px; | |||
.send-code-btn { | |||
min-width: auto; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
</style> |
@ -0,0 +1,244 @@ | |||
# 微信二维码支付组件 (WechatPayment) | |||
基于参考博客 [H5微信支付、PC端微信支付](https://blog.csdn.net/WANGLING0108/article/details/133808856) 实现的Vue3微信支付组件。 | |||
## 功能特性 | |||
- ✅ 二维码自动生成和展示 | |||
- ✅ 支付状态实时轮询 | |||
- ✅ 支付超时处理(5分钟倒计时) | |||
- ✅ 支付成功/失败/取消状态处理 | |||
- ✅ 二维码刷新功能 | |||
- ✅ 响应式设计(PC端/移动端适配) | |||
- ✅ 完整的错误处理和用户提示 | |||
## 安装依赖 | |||
```bash | |||
npm install qrcode | |||
``` | |||
## 使用方式 | |||
### 基础用法 | |||
```vue | |||
<template> | |||
<div> | |||
<!-- 触发支付按钮 --> | |||
<el-button type="primary" @click="startPayment"> | |||
发起支付 | |||
</el-button> | |||
<!-- 微信支付组件 --> | |||
<WechatPayment | |||
v-model="showPayment" | |||
:payment-data="paymentData" | |||
@success="handlePaymentSuccess" | |||
@cancel="handlePaymentCancel" | |||
@failed="handlePaymentFailed" | |||
/> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref, reactive } from 'vue'; | |||
import WechatPayment from '@/components/common/WechatPayment.vue'; | |||
export default { | |||
components: { | |||
WechatPayment | |||
}, | |||
setup() { | |||
const showPayment = ref(false); | |||
const paymentData = reactive({ | |||
orderId: '', // 订单ID | |||
title: '', // 支付标题 | |||
amount: 0, // 支付金额(分) | |||
body: '' // 商品描述 | |||
}); | |||
// 发起支付 | |||
const startPayment = () => { | |||
paymentData.orderId = 'ORDER_123456'; | |||
paymentData.title = '商品购买'; | |||
paymentData.amount = 1000; // 10元(以分为单位) | |||
paymentData.body = '商品描述信息'; | |||
showPayment.value = true; | |||
}; | |||
// 支付成功回调 | |||
const handlePaymentSuccess = (outTradeNo) => { | |||
console.log('支付成功,交易号:', outTradeNo); | |||
// 处理支付成功逻辑 | |||
}; | |||
// 支付取消回调 | |||
const handlePaymentCancel = () => { | |||
console.log('用户取消支付'); | |||
// 处理取消逻辑 | |||
}; | |||
// 支付失败回调 | |||
const handlePaymentFailed = (errorMessage) => { | |||
console.log('支付失败:', errorMessage); | |||
// 处理失败逻辑 | |||
}; | |||
return { | |||
showPayment, | |||
paymentData, | |||
startPayment, | |||
handlePaymentSuccess, | |||
handlePaymentCancel, | |||
handlePaymentFailed | |||
}; | |||
} | |||
}; | |||
</script> | |||
``` | |||
## 组件属性 (Props) | |||
| 属性名 | 类型 | 默认值 | 说明 | | |||
|--------|------|--------|------| | |||
| `modelValue` | Boolean | `false` | 控制支付弹窗显示/隐藏 | | |||
| `paymentData` | Object | `{}` | 支付相关数据 | | |||
### paymentData 对象结构 | |||
```javascript | |||
{ | |||
orderId: '', // 订单ID(必填) | |||
title: '', // 支付标题(必填) | |||
amount: 0, // 支付金额,单位:分(必填) | |||
body: '' // 商品描述(可选) | |||
} | |||
``` | |||
## 组件事件 (Events) | |||
| 事件名 | 参数 | 说明 | | |||
|--------|------|------| | |||
| `update:modelValue` | `Boolean` | 更新弹窗显示状态 | | |||
| `success` | `String` (outTradeNo) | 支付成功回调,返回微信交易号 | | |||
| `cancel` | - | 用户取消支付回调 | | |||
| `failed` | `String` (errorMessage) | 支付失败回调,返回错误信息 | | |||
## API接口要求 | |||
组件需要后端提供以下API接口: | |||
### 1. 创建微信支付订单 | |||
```javascript | |||
// POST /wechat_pay/createOrder | |||
// 请求参数 | |||
{ | |||
orderId: 'ORDER_123456', // 订单ID | |||
totalFee: 1000, // 支付金额(分) | |||
body: '商品描述' // 商品描述 | |||
} | |||
// 响应格式 | |||
{ | |||
code: 200, | |||
result: { | |||
codeUrl: 'weixin://wxpay/bizpayurl?pr=xxx', // 支付二维码URL | |||
outTradeNo: 'WX_ORDER_123456' // 微信订单号 | |||
}, | |||
message: 'success' | |||
} | |||
``` | |||
### 2. 查询支付状态 | |||
```javascript | |||
// GET /wechat_pay/queryOrder?outTradeNo=WX_ORDER_123456 | |||
// 响应格式 | |||
{ | |||
code: 200, | |||
result: { | |||
resultCode: 'SUCCESS', // 业务结果 | |||
returnCode: 'SUCCESS', // 通信标识 | |||
tradeState: 'SUCCESS' // 交易状态:SUCCESS-支付成功,CLOSED-已关闭,REVOKED-已撤销 | |||
}, | |||
message: 'success' | |||
} | |||
``` | |||
### 3. 取消支付订单(可选) | |||
```javascript | |||
// POST /wechat_pay/cancelOrder | |||
// 请求参数 | |||
{ | |||
outTradeNo: 'WX_ORDER_123456' // 微信订单号 | |||
} | |||
``` | |||
## 支付流程 | |||
1. 用户点击支付按钮 | |||
2. 调用创建订单API获取支付二维码 | |||
3. 显示二维码供用户扫码 | |||
4. 开始轮询查询支付状态(每2秒一次) | |||
5. 用户完成支付后,组件检测到支付成功 | |||
6. 触发成功回调,关闭支付弹窗 | |||
## 状态说明 | |||
组件内部维护以下支付状态: | |||
- `loading`: 正在生成支付二维码 | |||
- `pending`: 等待用户扫码支付 | |||
- `success`: 支付成功 | |||
- `failed`: 支付失败 | |||
- `timeout`: 支付超时(5分钟后自动超时) | |||
## 样式定制 | |||
组件使用了项目的主色调 `#0A2463`,如需自定义样式,可以通过CSS变量或深度选择器进行修改: | |||
```scss | |||
// 自定义主色调 | |||
.wechat-payment { | |||
--primary-color: #your-color; | |||
} | |||
// 深度选择器修改样式 | |||
:deep(.wechat-payment) { | |||
.payment-amount { | |||
color: #your-color; | |||
} | |||
} | |||
``` | |||
## 注意事项 | |||
1. **金额单位**:组件内部金额以分为单位,显示时会自动转换为元 | |||
2. **轮询频率**:默认每2秒轮询一次支付状态,可根据需要调整 | |||
3. **超时时间**:默认5分钟超时,可在组件内部修改 `countdown` 初始值 | |||
4. **错误处理**:建议在业务层面添加网络异常处理 | |||
5. **移动端适配**:组件已适配移动端,二维码会自动缩小 | |||
## 完整示例 | |||
参考 `src/views/user/PaymentExample.vue` 文件查看完整的使用示例。 | |||
## 技术栈 | |||
- Vue 3 + Composition API | |||
- Element Plus UI框架 | |||
- QRCode.js 二维码生成库 | |||
- SCSS样式预处理 | |||
## 浏览器兼容性 | |||
- Chrome 70+ | |||
- Firefox 63+ | |||
- Safari 12+ | |||
- Edge 79+ | |||
## 参考资料 | |||
- [微信支付官方文档](https://pay.weixin.qq.com/wiki/doc/api/index.html) | |||
- [H5微信支付、PC端微信支付 - CSDN博客](https://blog.csdn.net/WANGLING0108/article/details/133808856) |
@ -0,0 +1,604 @@ | |||
<template> | |||
<div class="wechat-payment"> | |||
<!-- 支付弹窗 --> | |||
<el-dialog | |||
v-model="visible" | |||
title="微信支付" | |||
width="400px" | |||
:close-on-click-modal="false" | |||
:close-on-press-escape="false" | |||
@close="handleCancel" | |||
> | |||
<div class="payment-content"> | |||
<!-- 支付信息 --> | |||
<div class="payment-info"> | |||
<div class="payment-title">{{ paymentData.title }}</div> | |||
<div class="payment-amount"> | |||
¥<span class="amount-value">{{ formatAmount(paymentData.amount) }}</span> | |||
</div> | |||
</div> | |||
<!-- 支付状态 --> | |||
<div class="payment-status"> | |||
<template v-if="paymentStatus === 'pending'"> | |||
<div class="qr-container"> | |||
<div class="qr-code" ref="qrCodeRef"> | |||
<!-- 二维码将在这里生成 --> | |||
</div> | |||
<div class="qr-tips"> | |||
<el-icon class="scan-icon"><Iphone /></el-icon> | |||
<p>请使用微信扫码支付</p> | |||
<p class="tips-text">扫码后请在手机上完成支付</p> | |||
</div> | |||
</div> | |||
<!-- 倒计时 --> | |||
<div class="countdown"> | |||
<span>支付剩余时间:</span> | |||
<span class="countdown-time">{{ formatTime(countdown) }}</span> | |||
</div> | |||
</template> | |||
<template v-else-if="paymentStatus === 'loading'"> | |||
<div class="loading-status"> | |||
<el-icon class="loading-icon"><Loading /></el-icon> | |||
<p>正在生成支付二维码...</p> | |||
</div> | |||
</template> | |||
<template v-else-if="paymentStatus === 'success'"> | |||
<div class="success-status"> | |||
<el-icon class="success-icon"><CircleCheck /></el-icon> | |||
<p>支付成功!</p> | |||
<p class="success-tips">感谢您的支付,页面即将跳转...</p> | |||
</div> | |||
</template> | |||
<template v-else-if="paymentStatus === 'failed'"> | |||
<div class="failed-status"> | |||
<el-icon class="failed-icon"><CircleClose /></el-icon> | |||
<p>支付失败</p> | |||
<p class="failed-tips">{{ errorMessage || '支付过程中出现异常,请重试' }}</p> | |||
</div> | |||
</template> | |||
<template v-else-if="paymentStatus === 'timeout'"> | |||
<div class="timeout-status"> | |||
<el-icon class="timeout-icon"><Clock /></el-icon> | |||
<p>支付超时</p> | |||
<p class="timeout-tips">二维码已过期,请重新发起支付</p> | |||
</div> | |||
</template> | |||
</div> | |||
</div> | |||
<!-- 操作按钮 --> | |||
<template #footer> | |||
<div class="dialog-footer"> | |||
<el-button | |||
v-if="paymentStatus === 'pending'" | |||
@click="refreshQrCode" | |||
:loading="refreshing" | |||
> | |||
刷新二维码 | |||
</el-button> | |||
<el-button | |||
v-if="paymentStatus === 'failed' || paymentStatus === 'timeout'" | |||
type="primary" | |||
@click="retryPayment" | |||
> | |||
重新支付 | |||
</el-button> | |||
<el-button | |||
@click="handleCancel" | |||
:disabled="paymentStatus === 'loading'" | |||
> | |||
{{ paymentStatus === 'success' ? '关闭' : '取消支付' }} | |||
</el-button> | |||
</div> | |||
</template> | |||
</el-dialog> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref, computed, nextTick, onUnmounted, watch } from 'vue'; | |||
import { ElMessage, ElMessageBox } from 'element-plus'; | |||
import { orderApi } from '@/api/user'; | |||
import { | |||
Iphone, | |||
Loading, | |||
CircleCheck, | |||
CircleClose, | |||
Clock | |||
} from '@element-plus/icons-vue'; | |||
import { wechatPayApi } from '@/api/user.js'; | |||
import QRCode from 'qrcode'; | |||
export default { | |||
name: 'WechatPayment', | |||
components: { | |||
Iphone, | |||
Loading, | |||
CircleCheck, | |||
CircleClose, | |||
Clock | |||
}, | |||
props: { | |||
// 是否显示支付弹窗 | |||
modelValue: { | |||
type: Boolean, | |||
default: false | |||
}, | |||
// 支付数据 | |||
paymentData: { | |||
type: Object, | |||
default: () => ({ | |||
orderId: '', // 订单ID | |||
title: '商品支付', // 支付标题 | |||
amount: 0, // 支付金额(分) | |||
body: '' // 商品描述 | |||
}) | |||
} | |||
}, | |||
emits: ['update:modelValue', 'success', 'cancel', 'failed'], | |||
setup(props, { emit }) { | |||
const visible = computed({ | |||
get: () => props.modelValue, | |||
set: (value) => emit('update:modelValue', value) | |||
}); | |||
const qrCodeRef = ref(null); | |||
const paymentStatus = ref('loading'); | |||
const countdown = ref(300); | |||
const refreshing = ref(false); | |||
const errorMessage = ref(''); | |||
let pollingTimer = null; | |||
let countdownTimer = null; | |||
let outTradeNo = ''; | |||
// 格式化金额 | |||
const formatAmount = (amount) => { | |||
return (amount / 100).toFixed(2); | |||
}; | |||
// 格式化时间 | |||
const formatTime = (seconds) => { | |||
const minutes = Math.floor(seconds / 60); | |||
const remainingSeconds = seconds % 60; | |||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; | |||
}; | |||
// 生成二维码 | |||
const generateQrCode = async (codeUrl) => { | |||
try { | |||
await nextTick(); | |||
if (qrCodeRef.value) { | |||
qrCodeRef.value.innerHTML = ''; | |||
await QRCode.toCanvas(qrCodeRef.value, codeUrl, { | |||
width: 200, | |||
height: 200, | |||
margin: 1, | |||
color: { | |||
dark: '#000000', | |||
light: '#FFFFFF' | |||
} | |||
}); | |||
} | |||
} catch (error) { | |||
console.error('生成二维码失败:', error); | |||
ElMessage.error('生成二维码失败'); | |||
} | |||
}; | |||
// 创建支付订单 | |||
const createPaymentOrder = async () => { | |||
try { | |||
paymentStatus.value = 'loading'; | |||
const params = { | |||
orderId: props.paymentData.orderId, | |||
totalFee: props.paymentData.amount, | |||
body: props.paymentData.body || props.paymentData.title | |||
}; | |||
// 模拟API响应 | |||
await new Promise(resolve => setTimeout(resolve, 1000)); | |||
const mockResponse = { | |||
code: 200, | |||
result: { | |||
codeUrl: `weixin://wxpay/bizpayurl?pr=${Math.random().toString(36).substr(2, 9)}`, | |||
outTradeNo: `ORDER_${Date.now()}` | |||
} | |||
}; | |||
if (mockResponse.code === 200 && mockResponse.result) { | |||
const { codeUrl, outTradeNo: tradeNo } = mockResponse.result; | |||
outTradeNo = tradeNo; | |||
await generateQrCode(codeUrl); | |||
paymentStatus.value = 'pending'; | |||
startPolling(); | |||
startCountdown(); | |||
} else { | |||
throw new Error('创建支付订单失败'); | |||
} | |||
} catch (error) { | |||
console.error('创建支付订单失败:', error); | |||
paymentStatus.value = 'failed'; | |||
errorMessage.value = error.message; | |||
ElMessage.error(error.message || '创建支付订单失败'); | |||
} | |||
}; | |||
// 开始轮询支付状态 | |||
const startPolling = () => { | |||
if (pollingTimer) { | |||
clearInterval(pollingTimer); | |||
} | |||
pollingTimer = setInterval(async () => { | |||
await checkPaymentStatus(); | |||
}, 2000); | |||
}; | |||
// 停止轮询 | |||
const stopPolling = () => { | |||
if (pollingTimer) { | |||
clearInterval(pollingTimer); | |||
pollingTimer = null; | |||
} | |||
}; | |||
// 检查支付状态 | |||
const checkPaymentStatus = async () => { | |||
try { | |||
// 模拟随机支付成功(演示用) | |||
if (Math.random() > 0.95) { | |||
handlePaymentSuccess(); | |||
return; | |||
} | |||
// 实际项目中调用真实API | |||
// const response = await wechatPayApi.queryWechatPayStatus(outTradeNo); | |||
} catch (error) { | |||
console.error('查询支付状态失败:', error); | |||
} | |||
}; | |||
// 处理支付成功 | |||
const handlePaymentSuccess = () => { | |||
paymentStatus.value = 'success'; | |||
stopPolling(); | |||
stopCountdown(); | |||
ElMessage.success('支付成功!'); | |||
emit('success', outTradeNo); | |||
setTimeout(() => { | |||
visible.value = false; | |||
}, 3000); | |||
}; | |||
// 处理支付失败 | |||
const handlePaymentFailed = (message) => { | |||
paymentStatus.value = 'failed'; | |||
errorMessage.value = message; | |||
stopPolling(); | |||
stopCountdown(); | |||
ElMessage.error(message || '支付失败'); | |||
emit('failed', message); | |||
}; | |||
// 开始倒计时 | |||
const startCountdown = () => { | |||
countdown.value = 300; | |||
countdownTimer = setInterval(() => { | |||
countdown.value--; | |||
if (countdown.value <= 0) { | |||
handlePaymentTimeout(); | |||
} | |||
}, 1000); | |||
}; | |||
// 停止倒计时 | |||
const stopCountdown = () => { | |||
if (countdownTimer) { | |||
clearInterval(countdownTimer); | |||
countdownTimer = null; | |||
} | |||
}; | |||
// 处理支付超时 | |||
const handlePaymentTimeout = () => { | |||
paymentStatus.value = 'timeout'; | |||
stopPolling(); | |||
stopCountdown(); | |||
ElMessage.warning('支付超时,请重新发起支付'); | |||
}; | |||
// 刷新二维码 | |||
const refreshQrCode = async () => { | |||
refreshing.value = true; | |||
try { | |||
await createPaymentOrder(); | |||
} finally { | |||
refreshing.value = false; | |||
} | |||
}; | |||
// 重新支付 | |||
const retryPayment = () => { | |||
errorMessage.value = ''; | |||
createPaymentOrder(); | |||
}; | |||
// 取消支付 | |||
const handleCancel = async () => { | |||
if (paymentStatus.value === 'success') { | |||
visible.value = false; | |||
return; | |||
} | |||
try { | |||
await ElMessageBox.confirm( | |||
'确定要取消支付吗?', | |||
'取消支付', | |||
{ | |||
confirmButtonText: '确定', | |||
cancelButtonText: '继续支付', | |||
type: 'warning', | |||
} | |||
); | |||
stopPolling(); | |||
stopCountdown(); | |||
visible.value = false; | |||
emit('cancel'); | |||
} catch { | |||
// 用户选择继续支付 | |||
} | |||
}; | |||
// 监听弹窗显示状态 | |||
watch(visible, (newVal) => { | |||
if (newVal && props.paymentData.orderId) { | |||
createPaymentOrder(); | |||
} else { | |||
stopPolling(); | |||
stopCountdown(); | |||
paymentStatus.value = 'loading'; | |||
errorMessage.value = ''; | |||
outTradeNo = ''; | |||
} | |||
}); | |||
// 组件卸载时清理 | |||
onUnmounted(() => { | |||
stopPolling(); | |||
stopCountdown(); | |||
}); | |||
return { | |||
visible, | |||
qrCodeRef, | |||
paymentStatus, | |||
countdown, | |||
refreshing, | |||
errorMessage, | |||
formatAmount, | |||
formatTime, | |||
refreshQrCode, | |||
retryPayment, | |||
handleCancel | |||
}; | |||
} | |||
}; | |||
</script> | |||
<style lang="scss" scoped> | |||
.wechat-payment { | |||
.payment-content { | |||
text-align: center; | |||
padding: 20px 0; | |||
.payment-info { | |||
margin-bottom: 20px; | |||
.payment-title { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: #303133; | |||
margin-bottom: 8px; | |||
} | |||
.payment-amount { | |||
font-size: 24px; | |||
color: #0A2463; | |||
font-weight: 700; | |||
.amount-value { | |||
font-size: 32px; | |||
} | |||
} | |||
} | |||
.payment-status { | |||
min-height: 300px; | |||
display: flex; | |||
flex-direction: column; | |||
justify-content: center; | |||
align-items: center; | |||
.qr-container { | |||
.qr-code { | |||
margin-bottom: 16px; | |||
display: flex; | |||
justify-content: center; | |||
:deep(canvas) { | |||
border-radius: 8px; | |||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |||
} | |||
} | |||
.qr-tips { | |||
.scan-icon { | |||
font-size: 32px; | |||
color: #0A2463; | |||
margin-bottom: 8px; | |||
} | |||
p { | |||
margin: 4px 0; | |||
color: #606266; | |||
&:first-of-type { | |||
font-size: 16px; | |||
font-weight: 600; | |||
color: #303133; | |||
} | |||
} | |||
.tips-text { | |||
font-size: 14px; | |||
color: #909399; | |||
} | |||
} | |||
} | |||
.countdown { | |||
margin-top: 16px; | |||
font-size: 14px; | |||
color: #909399; | |||
.countdown-time { | |||
color: #F56C6C; | |||
font-weight: 600; | |||
} | |||
} | |||
.loading-status, | |||
.success-status, | |||
.failed-status, | |||
.timeout-status { | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
.loading-icon { | |||
font-size: 48px; | |||
color: #0A2463; | |||
animation: rotate 2s linear infinite; | |||
margin-bottom: 16px; | |||
} | |||
.success-icon { | |||
font-size: 48px; | |||
color: #67C23A; | |||
margin-bottom: 16px; | |||
} | |||
.failed-icon, | |||
.timeout-icon { | |||
font-size: 48px; | |||
color: #F56C6C; | |||
margin-bottom: 16px; | |||
} | |||
p { | |||
margin: 4px 0; | |||
font-size: 16px; | |||
color: #303133; | |||
&:first-of-type { | |||
font-weight: 600; | |||
font-size: 18px; | |||
} | |||
} | |||
.success-tips, | |||
.failed-tips, | |||
.timeout-tips { | |||
font-size: 14px; | |||
color: #909399; | |||
} | |||
} | |||
} | |||
} | |||
.dialog-footer { | |||
display: flex; | |||
justify-content: center; | |||
gap: 12px; | |||
.el-button { | |||
&.el-button--primary { | |||
background-color: #0A2463; | |||
border-color: #0A2463; | |||
&:hover { | |||
background-color: #083354; | |||
border-color: #083354; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
@keyframes rotate { | |||
from { | |||
transform: rotate(0deg); | |||
} | |||
to { | |||
transform: rotate(360deg); | |||
} | |||
} | |||
// 响应式设计 | |||
@media (max-width: 768px) { | |||
.wechat-payment { | |||
:deep(.el-dialog) { | |||
width: 90% !important; | |||
margin: 5vh auto !important; | |||
} | |||
.payment-content { | |||
padding: 16px 0; | |||
.payment-info { | |||
.payment-title { | |||
font-size: 16px; | |||
} | |||
.payment-amount { | |||
font-size: 20px; | |||
.amount-value { | |||
font-size: 28px; | |||
} | |||
} | |||
} | |||
.payment-status { | |||
min-height: 250px; | |||
.qr-container { | |||
.qr-code { | |||
:deep(canvas) { | |||
width: 160px !important; | |||
height: 160px !important; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
</style> |
@ -0,0 +1,216 @@ | |||
/** | |||
* 登录状态检查和管理工具 | |||
*/ | |||
/** | |||
* 检查当前登录状态 | |||
* @returns {Object} 登录状态信息 | |||
*/ | |||
export const checkLoginStatus = () => { | |||
const token = localStorage.getItem('X-Access-Token'); | |||
const userData = localStorage.getItem('user'); | |||
const isAuthor = localStorage.getItem('isAuthor') === 'true'; | |||
try { | |||
const user = userData ? JSON.parse(userData) : null; | |||
return { | |||
isLoggedIn: !!(token && user), | |||
token, | |||
user, | |||
isAuthor | |||
}; | |||
} catch (error) { | |||
console.warn('[Auth Utils] 解析用户数据失败:', error); | |||
return { | |||
isLoggedIn: false, | |||
token: null, | |||
user: null, | |||
isAuthor: false | |||
}; | |||
} | |||
}; | |||
/** | |||
* 刷新用户信息(从后端获取最新数据) | |||
* @returns {Promise<Object>} 最新的用户信息 | |||
*/ | |||
export const refreshUserInfo = async () => { | |||
const { isLoggedIn } = checkLoginStatus(); | |||
if (!isLoggedIn) { | |||
throw new Error('用户未登录'); | |||
} | |||
const store = window.$store; | |||
if (store && typeof store.fetchLatestUserInfo === 'function') { | |||
try { | |||
const userInfo = await store.fetchLatestUserInfo(); | |||
return userInfo; | |||
} catch (error) { | |||
console.error('[Auth Utils] 刷新用户信息失败:', error); | |||
throw error; | |||
} | |||
} else { | |||
throw new Error('Store不可用'); | |||
} | |||
}; | |||
/** | |||
* 检查是否已登录,如果未登录则触发登录弹窗 | |||
* @param {Function} callback 登录成功后的回调函数 | |||
* @returns {Boolean} 是否已登录 | |||
*/ | |||
export const requireLogin = (callback) => { | |||
const { isLoggedIn } = checkLoginStatus(); | |||
if (isLoggedIn) { | |||
callback && callback(); | |||
return true; | |||
} | |||
// 未登录,触发登录弹窗 | |||
triggerLogin(callback); | |||
return false; | |||
}; | |||
/** | |||
* 检查是否为作家身份,如果不是则触发相应弹窗 | |||
* @param {Function} callback 申请成功后的回调函数 | |||
* @returns {Boolean} 是否为作家 | |||
*/ | |||
export const requireAuthor = (callback) => { | |||
const { isLoggedIn, isAuthor } = checkLoginStatus(); | |||
if (!isLoggedIn) { | |||
// 未登录,先登录再申请作家 | |||
triggerLogin(() => { | |||
triggerAuthorApplication(callback); | |||
}); | |||
return false; | |||
} | |||
if (isAuthor) { | |||
return true; | |||
} | |||
// 已登录但不是作家,触发作家申请 | |||
triggerAuthorApplication(callback); | |||
return false; | |||
}; | |||
/** | |||
* 触发登录弹窗 | |||
* @param {Function} callback 登录成功后的回调函数 | |||
*/ | |||
export const triggerLogin = (callback) => { | |||
const authContext = window.$authContext; | |||
if (authContext && typeof authContext.openLogin === 'function') { | |||
authContext.openLogin(callback); | |||
} else { | |||
// 如果authContext还未挂载,则设置事件供之后触发 | |||
const triggerLoginEvent = () => { | |||
const context = window.$authContext; | |||
if (context && typeof context.openLogin === 'function') { | |||
context.openLogin(callback); | |||
} | |||
}; | |||
if (window.routerEvents) { | |||
window.routerEvents.triggerLogin = triggerLoginEvent; | |||
} else { | |||
console.warn('[Auth Utils] 无法触发登录弹窗,routerEvents不可用'); | |||
} | |||
} | |||
}; | |||
/** | |||
* 触发作家申请弹窗 | |||
* @param {Function} callback 申请成功后的回调函数 | |||
*/ | |||
export const triggerAuthorApplication = (callback) => { | |||
const authorContext = window.$authorApplicationContext; | |||
if (authorContext && typeof authorContext.openApplicationModal === 'function') { | |||
authorContext.openApplicationModal(callback); | |||
} else { | |||
console.warn('[Auth Utils] 无法触发作家申请弹窗,authorApplicationContext不可用'); | |||
} | |||
}; | |||
/** | |||
* 清除登录状态(登出) | |||
*/ | |||
export const clearLoginStatus = () => { | |||
// 清除本地存储 | |||
localStorage.removeItem('X-Access-Token'); | |||
localStorage.removeItem('token'); | |||
localStorage.removeItem('user'); | |||
localStorage.removeItem('isAuthor'); | |||
localStorage.removeItem('bookshelf'); | |||
console.log('[Auth Utils] 登录状态已清除'); | |||
// 如果有store,也清除store状态 | |||
if (window.$store) { | |||
window.$store.clearAuthStatus(); | |||
} | |||
}; | |||
/** | |||
* 页面按钮点击前的登录检查装饰器 | |||
* @param {Function} fn 原始点击处理函数 | |||
* @param {Object} options 配置选项 | |||
* @param {Boolean} options.requireAuth 是否需要登录 | |||
* @param {Boolean} options.requireAuthor 是否需要作家身份 | |||
* @returns {Function} 包装后的点击处理函数 | |||
*/ | |||
export const withAuthCheck = (fn, options = {}) => { | |||
return (...args) => { | |||
const { requireAuth = false, requireAuthor = false } = options; | |||
if (requireAuthor) { | |||
// 需要作家身份 | |||
if (requireAuthor(() => fn(...args))) { | |||
fn(...args); | |||
} | |||
} else if (requireAuth) { | |||
// 需要登录 | |||
if (requireLogin(() => fn(...args))) { | |||
fn(...args); | |||
} | |||
} else { | |||
// 不需要任何权限 | |||
fn(...args); | |||
} | |||
}; | |||
}; | |||
/** | |||
* Vue组合式API版本的登录检查hook | |||
* @returns {Object} 登录检查相关的方法 | |||
*/ | |||
export const useAuthCheck = () => { | |||
return { | |||
checkLoginStatus, | |||
requireLogin, | |||
requireAuthor, | |||
triggerLogin, | |||
triggerAuthorApplication, | |||
clearLoginStatus, | |||
withAuthCheck, | |||
refreshUserInfo | |||
}; | |||
}; | |||
export default { | |||
checkLoginStatus, | |||
requireLogin, | |||
requireAuthor, | |||
triggerLogin, | |||
triggerAuthorApplication, | |||
clearLoginStatus, | |||
withAuthCheck, | |||
useAuthCheck, | |||
refreshUserInfo | |||
}; |
@ -0,0 +1,373 @@ | |||
<template> | |||
<div class="personal-info-example"> | |||
<div class="container"> | |||
<h1 class="page-title">个人信息弹窗示例</h1> | |||
<div class="example-content"> | |||
<el-card class="demo-card"> | |||
<template #header> | |||
<h3>个人信息弹窗组件</h3> | |||
</template> | |||
<div class="demo-description"> | |||
<p>这是一个个人信息编辑弹窗组件,包含以下功能:</p> | |||
<ul> | |||
<li>✅ 头像上传和预览</li> | |||
<li>✅ 昵称编辑(必填,失焦自动保存)</li> | |||
<li>✅ 个性签名编辑(失焦自动保存)</li> | |||
<li>✅ 手机号显示和修改</li> | |||
<li>✅ 豆豆数量显示和查看详情</li> | |||
<li>✅ 快捷充值功能</li> | |||
</ul> | |||
</div> | |||
<div class="demo-actions"> | |||
<el-button | |||
type="primary" | |||
@click="openPersonalInfoModal" | |||
:disabled="!isLoggedIn" | |||
> | |||
{{ isLoggedIn ? '打开个人信息弹窗' : '请先登录' }} | |||
</el-button> | |||
<el-button | |||
v-if="!isLoggedIn" | |||
@click="openLogin" | |||
> | |||
登录 | |||
</el-button> | |||
</div> | |||
</el-card> | |||
<!-- 当前用户信息预览 --> | |||
<el-card v-if="isLoggedIn" class="user-preview-card"> | |||
<template #header> | |||
<h3>当前用户信息</h3> | |||
</template> | |||
<div class="user-preview"> | |||
<div class="user-avatar"> | |||
<el-avatar :size="60" :src="userInfo.headImage"> | |||
<el-icon><UserFilled /></el-icon> | |||
</el-avatar> | |||
</div> | |||
<div class="user-details"> | |||
<div class="user-item"> | |||
<span class="label">昵称:</span> | |||
<span class="value">{{ userInfo.nickName || '未设置' }}</span> | |||
</div> | |||
<div class="user-item"> | |||
<span class="label">个性签名:</span> | |||
<span class="value">{{ userInfo.personalSignature || '暂无个性签名' }}</span> | |||
</div> | |||
<div class="user-item"> | |||
<span class="label">手机号:</span> | |||
<span class="value">{{ maskPhoneNumber(userInfo.phone) }}</span> | |||
</div> | |||
<div class="user-item"> | |||
<span class="label">豆豆:</span> | |||
<span class="value bean-value">{{ userInfo.integerPrice || 0 }}</span> | |||
</div> | |||
</div> | |||
</div> | |||
</el-card> | |||
<!-- 组件使用说明 --> | |||
<el-card class="usage-card"> | |||
<template #header> | |||
<h3>使用说明</h3> | |||
</template> | |||
<div class="usage-content"> | |||
<h4>在导航栏中使用:</h4> | |||
<p>个人信息弹窗已集成到顶部导航栏中,点击用户头像区域即可打开。</p> | |||
<h4>组件特性:</h4> | |||
<ul> | |||
<li><strong>头像上传:</strong>点击头像可上传新头像,支持jpg、png格式,最大2MB</li> | |||
<li><strong>自动保存:</strong>昵称和个性签名在输入框失焦时自动保存</li> | |||
<li><strong>手机号修改:</strong>支持通过验证码修改绑定手机号</li> | |||
<li><strong>数据联动:</strong>修改后的信息会同步更新到导航栏显示</li> | |||
<li><strong>响应式设计:</strong>适配PC端和移动端显示</li> | |||
</ul> | |||
<h4>代码示例:</h4> | |||
<pre><code><PersonalInfoModal | |||
v-model="showPersonalInfoModal" | |||
@success="handlePersonalInfoUpdate" | |||
/></code></pre> | |||
</div> | |||
</el-card> | |||
</div> | |||
</div> | |||
<!-- 个人信息弹窗 --> | |||
<PersonalInfoModal | |||
v-model="showPersonalInfoModal" | |||
@success="handlePersonalInfoUpdate" | |||
/> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref, computed, inject } from 'vue'; | |||
import { UserFilled } from '@element-plus/icons-vue'; | |||
import { useMainStore } from '@/store'; | |||
import { AUTH_INJECTION_KEY } from '@/components/auth/AuthProvider.vue'; | |||
import PersonalInfoModal from '@/components/common/PersonalInfoModal.vue'; | |||
export default { | |||
name: 'PersonalInfoExample', | |||
components: { | |||
UserFilled, | |||
PersonalInfoModal | |||
}, | |||
setup() { | |||
const store = useMainStore(); | |||
const authContext = inject(AUTH_INJECTION_KEY); | |||
const showPersonalInfoModal = ref(false); | |||
// 计算属性 | |||
const isLoggedIn = computed(() => store.isAuthenticated); | |||
const userInfo = computed(() => store.user || {}); | |||
// 手机号脱敏显示 | |||
const maskPhoneNumber = (phone) => { | |||
if (!phone) return '未绑定'; | |||
if (phone.length === 11) { | |||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`; | |||
} | |||
return phone; | |||
}; | |||
// 打开登录弹窗 | |||
const openLogin = () => { | |||
authContext.openLogin(); | |||
}; | |||
// 打开个人信息弹窗 | |||
const openPersonalInfoModal = () => { | |||
showPersonalInfoModal.value = true; | |||
}; | |||
// 处理个人信息更新成功 | |||
const handlePersonalInfoUpdate = () => { | |||
// 刷新用户信息 | |||
store.fetchUserInfo(); | |||
}; | |||
return { | |||
isLoggedIn, | |||
userInfo, | |||
showPersonalInfoModal, | |||
maskPhoneNumber, | |||
openLogin, | |||
openPersonalInfoModal, | |||
handlePersonalInfoUpdate | |||
}; | |||
} | |||
}; | |||
</script> | |||
<style lang="scss" scoped> | |||
.personal-info-example { | |||
min-height: 100vh; | |||
background: #f5f7fa; | |||
padding: 20px 0; | |||
.container { | |||
max-width: 1000px; | |||
margin: 0 auto; | |||
padding: 0 20px; | |||
.page-title { | |||
font-size: 28px; | |||
font-weight: 700; | |||
color: #0A2463; | |||
text-align: center; | |||
margin-bottom: 30px; | |||
} | |||
.example-content { | |||
display: flex; | |||
flex-direction: column; | |||
gap: 20px; | |||
.demo-card, | |||
.user-preview-card, | |||
.usage-card { | |||
.demo-description { | |||
margin-bottom: 20px; | |||
p { | |||
color: #606266; | |||
line-height: 1.6; | |||
margin-bottom: 12px; | |||
} | |||
ul { | |||
color: #606266; | |||
padding-left: 20px; | |||
li { | |||
line-height: 1.8; | |||
} | |||
} | |||
} | |||
.demo-actions { | |||
display: flex; | |||
gap: 12px; | |||
.el-button--primary { | |||
background-color: #0A2463; | |||
border-color: #0A2463; | |||
&:hover { | |||
background-color: #083354; | |||
border-color: #083354; | |||
} | |||
} | |||
} | |||
.user-preview { | |||
display: flex; | |||
align-items: flex-start; | |||
gap: 20px; | |||
.user-avatar { | |||
flex-shrink: 0; | |||
} | |||
.user-details { | |||
flex: 1; | |||
.user-item { | |||
display: flex; | |||
align-items: center; | |||
margin-bottom: 12px; | |||
.label { | |||
font-weight: 500; | |||
color: #303133; | |||
min-width: 80px; | |||
} | |||
.value { | |||
color: #606266; | |||
&.bean-value { | |||
color: #EB8D00; | |||
font-weight: 600; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
.usage-content { | |||
h4 { | |||
color: #0A2463; | |||
margin: 16px 0 8px 0; | |||
font-size: 16px; | |||
} | |||
p { | |||
color: #606266; | |||
line-height: 1.6; | |||
margin-bottom: 12px; | |||
} | |||
ul { | |||
color: #606266; | |||
padding-left: 20px; | |||
margin-bottom: 16px; | |||
li { | |||
line-height: 1.8; | |||
margin-bottom: 8px; | |||
strong { | |||
color: #0A2463; | |||
} | |||
} | |||
} | |||
pre { | |||
background: #f8f8f8; | |||
border: 1px solid #e0e0e0; | |||
border-radius: 6px; | |||
padding: 16px; | |||
overflow-x: auto; | |||
code { | |||
color: #0A2463; | |||
font-family: 'Courier New', monospace; | |||
font-size: 14px; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
// 响应式设计 | |||
@media (max-width: 768px) { | |||
.personal-info-example { | |||
padding: 10px 0; | |||
.container { | |||
padding: 0 10px; | |||
.page-title { | |||
font-size: 24px; | |||
margin-bottom: 20px; | |||
} | |||
.example-content { | |||
gap: 16px; | |||
.demo-card, | |||
.user-preview-card, | |||
.usage-card { | |||
.demo-actions { | |||
flex-direction: column; | |||
.el-button { | |||
width: 100%; | |||
} | |||
} | |||
.user-preview { | |||
flex-direction: column; | |||
text-align: center; | |||
.user-details { | |||
.user-item { | |||
flex-direction: column; | |||
align-items: flex-start; | |||
gap: 4px; | |||
.label { | |||
min-width: auto; | |||
} | |||
} | |||
} | |||
} | |||
.usage-content { | |||
pre { | |||
padding: 12px; | |||
code { | |||
font-size: 12px; | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
</style> |
@ -1,276 +0,0 @@ | |||
<template> | |||
<view class="my-comment-page"> | |||
<navbar title="我的评论" :leftClick="true" @leftClick="$utils.navigateBack" /> | |||
<uv-tabs :list="tabs" | |||
:activeStyle="{ color: '#0A2463', fontWeight: 600 }" | |||
lineColor="#0A2463" lineHeight="8rpx" | |||
lineWidth="50rpx" | |||
:scrollable="false" | |||
:current="current" | |||
@click="clickTabs"></uv-tabs> | |||
<template v-if="current == 0"> | |||
<view class="comment-section"> | |||
<myCommentItem :item="item" | |||
edit | |||
@getData="getData" | |||
v-for="(item, idx) in list" :key="idx"/> | |||
</view> | |||
</template> | |||
<template v-if="current == 1"> | |||
<view class="comment-section"> | |||
<view class="section-title">未读评论·{{ unreadComments.length }}</view> | |||
<myCommentItem :item="item" v-for="(item, idx) in unreadComments" :key="idx"/> | |||
<uv-empty mode="list" v-if="unreadComments.length == 0"></uv-empty> | |||
</view> | |||
<view class="comment-section history-section"> | |||
<view class="section-title">历史评论</view> | |||
<myCommentItem | |||
:item="item" | |||
v-for="(item, idx) in list" :key="idx"/> | |||
</view> | |||
</template> | |||
<uv-empty mode="list" v-if="list.length == 0"></uv-empty> | |||
</view> | |||
</template> | |||
<script> | |||
import mixinsList from '@/mixins/list.js' | |||
import myCommentItem from '../components/comment/myCommentItem.vue' | |||
export default { | |||
mixins: [mixinsList], | |||
components: { | |||
myCommentItem, | |||
}, | |||
data() { | |||
return { | |||
mixinsListApi : 'getMyCommentList', | |||
unreadComments: [], | |||
tabs: [ | |||
{ | |||
name: '我的评论' | |||
}, | |||
{ | |||
name: '回复我的' | |||
}, | |||
], | |||
current : 0, | |||
apiList : [ | |||
'getMyCommentList', | |||
'getMyReplyCommentList' | |||
], | |||
} | |||
}, | |||
onLoad() { | |||
this.queryParams.type = 'Y' | |||
this.mixinsListApi = this.apiList[this.current] | |||
}, | |||
onShow() { | |||
this.getList() | |||
}, | |||
methods: { | |||
//点击tab栏 | |||
clickTabs({ index }) { | |||
this.queryParams.pageSize = 10 | |||
this.current = index | |||
this.mixinsListApi = this.apiList[this.current] | |||
this.getData() | |||
}, | |||
//获取未读 | |||
getList(){ | |||
this.$fetch('getMyReplyCommentList', { | |||
type : 'N', | |||
pageNo: 1, | |||
pageSize: 100000 | |||
}).then(res => { | |||
this.unreadComments = res.records | |||
this.unreadComments.forEach(n => { | |||
this.updateCommentRead(n.id) | |||
}) | |||
}) | |||
}, | |||
updateCommentRead(commentId){ | |||
this.$fetch('updateCommentRead', { | |||
commentId | |||
}) | |||
}, | |||
} | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
.my-comment-page { | |||
min-height: 100vh; | |||
background: #f8f8f8; | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
.comment-section { | |||
background: #fff; | |||
margin: 24rpx 24rpx 0 24rpx; | |||
border-radius: 16rpx; | |||
padding: 24rpx 24rpx 0 24rpx; | |||
margin-bottom: 24rpx; | |||
} | |||
.section-title { | |||
color: #222; | |||
font-size: 28rpx; | |||
font-weight: 500; | |||
margin-bottom: 16rpx; | |||
} | |||
</style> | |||
<template> | |||
<view class="comment-card"> | |||
<uv-avatar :src="item.hanHaiMember.headImage" size="44" shape="circle" class="avatar" /> | |||
<view class="comment-main"> | |||
<view class="comment-header"> | |||
<text class="username">{{ item.hanHaiMember.nickName }}</text> | |||
<text class="from">来自《{{ item.commonShop.name }}》</text> | |||
</view> | |||
<view class="comment-content">{{ item.comment }}</view> | |||
<view class="comment-footer"> | |||
<text class="comment-time">{{ item.createTime }}</text> | |||
<view class="reply-btn-wrap" | |||
v-if="edit" | |||
@click="deleteItem(item)"> | |||
<text class="reply-btn">删除</text> | |||
</view> | |||
<view class="reply-btn-wrap" | |||
v-else | |||
@click="goToReply(item)"> | |||
<text class="reply-btn">回复</text> | |||
</view> | |||
</view> | |||
</view> | |||
</view> | |||
</template> | |||
<script> | |||
export default { | |||
props: ['item', 'edit'], | |||
data() { | |||
return { | |||
} | |||
}, | |||
methods: { | |||
goToReply(item) { | |||
// this.$fetch('updateCommentRead', { | |||
// commentId : item.id, | |||
// }) | |||
uni.navigateTo({ | |||
url: '/pages_order/comment/respondComments?id=' + item.id | |||
}) | |||
}, | |||
deleteItem(item){ | |||
uni.showModal({ | |||
title : '确认删除该评论吗', | |||
success : (r) => { | |||
if (r.confirm) { | |||
this.$fetch('deleteComment', { | |||
commentId : item.id, | |||
}).then(res => { | |||
this.$emit('getData') | |||
}) | |||
} | |||
} | |||
}) | |||
}, | |||
} | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
.comment-card { | |||
display: flex; | |||
align-items: flex-start; | |||
margin-bottom: 32rpx; | |||
.avatar { | |||
width: 56rpx; | |||
height: 56rpx; | |||
border-radius: 50%; | |||
margin-right: 16rpx; | |||
flex-shrink: 0; | |||
} | |||
.comment-main { | |||
flex: 1; | |||
display: flex; | |||
flex-direction: column; | |||
margin-left: 10rpx; | |||
} | |||
.comment-header { | |||
display: flex; | |||
align-items: center; | |||
gap: 12rpx; | |||
margin-bottom: 4rpx; | |||
} | |||
.username { | |||
font-size: 26rpx; | |||
color: #222; | |||
font-weight: 500; | |||
} | |||
.from { | |||
font-size: 22rpx; | |||
color: #bdbdbd; | |||
} | |||
.comment-content { | |||
font-size: 26rpx; | |||
color: #333; | |||
margin-bottom: 12rpx; | |||
} | |||
.comment-footer { | |||
display: flex; | |||
align-items: center; | |||
font-size: 22rpx; | |||
color: #bdbdbd; | |||
justify-content: space-between; | |||
padding-right: 8rpx; | |||
} | |||
.comment-time { | |||
color: #bdbdbd; | |||
} | |||
.reply-btn-wrap { | |||
display: flex; | |||
align-items: center; | |||
cursor: pointer; | |||
} | |||
.reply-btn { | |||
color: #223a6b; | |||
font-weight: 500; | |||
margin-left: 0; | |||
font-size: 24rpx; | |||
} | |||
.history-section { | |||
margin-top: 24rpx; | |||
} | |||
} | |||
</style> |