feat: 适配5.x后端,重构前端架构
主要变更: 1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范 2. 优化布局组件,提升用户界面体验 3. 增强国际化支持,完善多语言配置 4. 新增权限控制功能,支持前端路由权限管理 5. 优化认证流程,支持JWT认证机制 6. 新增中间件和插件,提升系统扩展性 7. 优化状态管理,采用更清晰的模块化设计 8. 新增搜索框、广告等组件,丰富页面功能 9. 更改包管理工具从npm到pnpm,提升依赖管理效率 10. 完善配置系统,支持多环境配置 适配5.x后端特性: - 采用新的API接口规范 - 支持JWT认证机制 - 适配后端权限控制体系 - 支持后端国际化接口 - 优化数据交互方式
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="member-balance">
|
||||
<h2 class="page-title">{{ t('member.balance.page_title') }}</h2>
|
||||
<el-card class="balance-card">
|
||||
<div class="balance-summary">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<div class="balance-item">
|
||||
<div class="balance-label">{{ t('member.balance.current_balance') }}</div>
|
||||
<div class="balance-value">{{ currentBalance }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="balance-item">
|
||||
<div class="balance-label">{{ t('member.balance.total_transaction') }}</div>
|
||||
<div class="balance-value">{{ totalTransaction }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="balance-list">
|
||||
<el-table :data="balanceRecords" style="width: 100%">
|
||||
<el-table-column prop="id" :label="t('member.balance.record_id')" width="100" />
|
||||
<el-table-column prop="type" :label="t('member.balance.type')" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.type === 'income' ? 'success' : 'danger'">
|
||||
{{ scope.row.type === 'income' ? t('member.balance.type_income') : t('member.balance.type_expense') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="t('member.balance.amount')" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="scope.row.type === 'income' ? 'text-success' : 'text-danger'">
|
||||
{{ scope.row.type === 'income' ? '+' : '-' }}{{ scope.row.amount.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" :label="t('member.balance.description')" />
|
||||
<el-table-column prop="created_at" :label="t('member.balance.time')" width="180" />
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
// 余额概览数据
|
||||
const currentBalance = ref(8888.88)
|
||||
const totalTransaction = ref(128888.88)
|
||||
|
||||
// 分页数据
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(80)
|
||||
|
||||
// 余额记录数据
|
||||
const balanceRecords = ref([
|
||||
{
|
||||
id: 1,
|
||||
type: 'income',
|
||||
amount: 1000.00,
|
||||
description: '充值',
|
||||
created_at: '2026-01-30 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'expense',
|
||||
amount: 199.99,
|
||||
description: '购买商品',
|
||||
created_at: '2026-01-29 15:30:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'income',
|
||||
amount: 500.00,
|
||||
description: '提现',
|
||||
created_at: '2026-01-28 09:15:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'expense',
|
||||
amount: 99.00,
|
||||
description: '服务费用',
|
||||
created_at: '2026-01-27 14:20:00'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'income',
|
||||
amount: 200.00,
|
||||
description: '退款',
|
||||
created_at: '2026-01-26 11:45:00'
|
||||
}
|
||||
])
|
||||
|
||||
// 分页方法
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
// 这里应该重新获取数据
|
||||
}
|
||||
|
||||
const handleCurrentChange = (current: number) => {
|
||||
currentPage.value = current
|
||||
// 这里应该重新获取数据
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-balance {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
margin-bottom: 20px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.balance-summary {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.balance-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="member-password">
|
||||
<h2 class="page-title">{{ t('member.password.page_title') }}</h2>
|
||||
<el-card class="password-card">
|
||||
<el-form :model="passwordForm" :rules="rules" ref="passwordFormRef" label-width="120px">
|
||||
<el-form-item :label="t('member.password.current_password')" prop="oldPassword">
|
||||
<el-input v-model="passwordForm.oldPassword" type="password" :placeholder="t('member.password.current_password_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.password.new_password')" prop="newPassword">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" :placeholder="t('member.password.new_password_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.password.confirm_password')" prop="confirmPassword">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" :placeholder="t('member.password.confirm_password_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" @click="submitForm" class="submit-btn" :loading="loading">{{ t('member.password.submit_button') }}</el-button>
|
||||
<el-button @click="resetForm" class="reset-btn">{{ t('member.password.reset_button') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { changePassword } from '~/api/member'
|
||||
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
oldPassword: [
|
||||
{ required: true, message: t('member.password.validation.current_password_required'), trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: t('member.password.validation.new_password_required'), trigger: 'blur' },
|
||||
{ min: 6, message: t('member.password.validation.new_password_min_length'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('member.password.validation.confirm_password_required'), trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.newPassword) {
|
||||
callback(new Error(t('member.password.validation.confirm_password_mismatch')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
await passwordFormRef.value.validate(async (valid, fields) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
old_password: passwordForm.oldPassword,
|
||||
new_password: passwordForm.newPassword
|
||||
})
|
||||
ElMessage.success(t('member.password.success'))
|
||||
resetForm()
|
||||
}finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
console.log('验证失败', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
passwordFormRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-password {
|
||||
padding: 20px;
|
||||
/* min-height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column; */
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
max-width: 600px;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
/* background-color: #ffffff; */
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* 优化表单标签样式 */
|
||||
:deep(.el-form-item__label) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 优化输入框样式 - 移除边框 */
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 优化按钮样式 */
|
||||
:deep(.el-button) {
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
background-color: #66b1ff;
|
||||
border-color: #66b1ff;
|
||||
}
|
||||
|
||||
:deep(.el-button--default) {
|
||||
border-color: #dcdfe6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
:deep(.el-button--default:hover) {
|
||||
border-color: #c0c4cc;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="member-points">
|
||||
<h2 class="page-title">{{ t('member.points.page_title') }}</h2>
|
||||
<el-card class="points-card">
|
||||
<div class="points-summary">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="points-item">
|
||||
<div class="points-label">{{ t('member.points.current_points') }}</div>
|
||||
<div class="points-value">{{ pointsTotal.current_points }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="points-item">
|
||||
<div class="points-label">{{ t('member.points.monthly_points') }}</div>
|
||||
<div class="points-value">{{ pointsTotal.monthly_points }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="points-item">
|
||||
<div class="points-label">{{ t('member.points.total_points') }}</div>
|
||||
<div class="points-value">{{ pointsTotal.total_points }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="points-list">
|
||||
<el-table :data="pointsRecords" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" :label="t('member.points.record_id')" width="200" />
|
||||
<el-table-column prop="type" :label="t('member.points.type')" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.type === 1 ? 'success' : 'danger'">
|
||||
{{ scope.row.type === 1 ? t('member.points.type_earn') : t('member.points.type_deduct') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points" :label="t('member.points.points')" width="100">
|
||||
<template #default="scope">
|
||||
<span :class="scope.row.type === 1 ? 'text-success' : 'text-danger'">
|
||||
{{ scope.row.type === 1 ? '+' : '-' }}{{ scope.row.points }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="t('member.points.reason')" />
|
||||
<el-table-column prop="created_at" :label="t('member.points.time')" width="180">
|
||||
<template #default="scope">
|
||||
{{ timeFormat(scope.row.created_at, 'yyyy-mm-dd hh:MM:ss') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getPointTransactions, getMemberPointsTotal } from '~/api/member'
|
||||
import { timeFormat } from '~/utils/common'
|
||||
|
||||
// 积分概览数据
|
||||
const pointsTotal = reactive({
|
||||
current_points: 0,
|
||||
monthly_points: 0,
|
||||
total_points: 0
|
||||
})
|
||||
|
||||
// 分页数据
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 积分记录数据
|
||||
const pointsRecords = ref([])
|
||||
|
||||
// 获取积分总额
|
||||
const fetchPointsTotal = async () => {
|
||||
try {
|
||||
const res: any = await getMemberPointsTotal()
|
||||
if (res) {
|
||||
pointsTotal.current_points = res.current_points || 0
|
||||
pointsTotal.monthly_points = res.monthly_points || 0
|
||||
pointsTotal.total_points = res.total_points || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取积分总额失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取积分记录
|
||||
const fetchPointsRecords = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getPointTransactions({
|
||||
page: currentPage.value,
|
||||
limit: pageSize.value
|
||||
})
|
||||
if (res) {
|
||||
pointsRecords.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取积分记录失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页方法
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
fetchPointsRecords()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (current: number) => {
|
||||
currentPage.value = current
|
||||
fetchPointsRecords()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchPointsTotal()
|
||||
fetchPointsRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-points {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.points-card {
|
||||
margin-bottom: 20px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
/* background-color: #ffffff; */
|
||||
}
|
||||
|
||||
.points-summary {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.points-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.points-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="member-profile">
|
||||
<h2 class="page-title">{{ t('member.profile.page_title') }}</h2>
|
||||
<el-form
|
||||
:model="profileForm"
|
||||
:rules="rules"
|
||||
ref="profileFormRef"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item :label="t('member.profile.username')">
|
||||
<el-input v-model="profileForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.email')" prop="email">
|
||||
<el-input
|
||||
v-model="profileForm.email"
|
||||
type="email"
|
||||
:placeholder="t('member.profile.email_placeholder')"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.phone')">
|
||||
<el-input v-model="profileForm.phone" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.nickname')" prop="nickname">
|
||||
<el-input v-model="profileForm.nickname" :placeholder="t('member.profile.nickname_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.gender')">
|
||||
<el-radio-group v-model="profileForm.gender">
|
||||
<el-radio label="male">{{ t('member.profile.gender_male') }}</el-radio>
|
||||
<el-radio label="female">{{ t('member.profile.gender_female') }}</el-radio>
|
||||
<el-radio label="other">{{ t('member.profile.gender_other') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.birthday')">
|
||||
<el-date-picker
|
||||
v-model="profileForm.birthday"
|
||||
type="date"
|
||||
:placeholder="t('member.profile.birthday_placeholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.profile.bio')">
|
||||
<el-input
|
||||
v-model="profileForm.bio"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="t('member.profile.bio_placeholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" @click="submitProfileForm" class="submit-btn"
|
||||
>{{ t('member.profile.save_button') }}</el-button
|
||||
>
|
||||
<el-button @click="resetProfileForm" class="reset-btn">{{ t('member.profile.reset_button') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { updateMemberInfo } from "~/api/member";
|
||||
import { useMemberStore } from "~/stores/member";
|
||||
|
||||
const memberStore = useMemberStore();
|
||||
const profileFormRef = ref<FormInstance>();
|
||||
|
||||
const profileForm = reactive({
|
||||
username: "",
|
||||
nickname: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
gender: "male",
|
||||
birthday: "",
|
||||
bio: "",
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
nickname: [{ required: true, message: t('member.profile.validation.nickname_required'), trigger: "blur" }],
|
||||
email: [{ type: "email", message: t('member.profile.validation.email_format'), trigger: "blur" }],
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await memberStore.getMemberInfo();
|
||||
initProfileForm();
|
||||
});
|
||||
|
||||
const initProfileForm = () => {
|
||||
if (memberStore.info) {
|
||||
profileForm.username = memberStore.info.username || "";
|
||||
profileForm.nickname = memberStore.info.nickname || "";
|
||||
profileForm.email = memberStore.info.email || "";
|
||||
profileForm.phone = memberStore.info.phone || "";
|
||||
profileForm.gender = memberStore.info.gender === 1? "male": memberStore.info.gender === 2 ? "female": "other";
|
||||
profileForm.birthday = memberStore.info.birthday || "";
|
||||
profileForm.bio = memberStore.info.bio || "";
|
||||
}
|
||||
};
|
||||
|
||||
const submitProfileForm = async () => {
|
||||
if (!profileFormRef.value) return;
|
||||
await profileFormRef.value.validate(async (valid, fields) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const data = {
|
||||
nickname: profileForm.nickname,
|
||||
gender:
|
||||
profileForm.gender === "male" ? 1 : profileForm.gender === "female" ? 2 : 0,
|
||||
birthday: profileForm.birthday,
|
||||
bio: profileForm.bio,
|
||||
};
|
||||
await updateMemberInfo(data);
|
||||
ElMessage.success(t('member.profile.update_success'));
|
||||
await memberStore.getMemberInfo();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.profile.update_failed'));
|
||||
}
|
||||
} else {
|
||||
console.log("验证失败", fields);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetProfileForm = () => {
|
||||
initProfileForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-profile {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,784 @@
|
||||
<template>
|
||||
<div class="member-settings">
|
||||
<h2 class="page-title">{{ t('member.settings.page_title') }}</h2>
|
||||
|
||||
<div class="card-header">{{ t('member.settings.account_info') }}</div>
|
||||
<el-form :model="accountForm" label-width="120px" class="account-form">
|
||||
<!-- 手机号码修改 -->
|
||||
<el-form-item :label="t('member.settings.login_phone')">
|
||||
<div class="account-item">
|
||||
<el-input v-model="accountForm.phone" disabled />
|
||||
<el-button type="primary" @click="showPhoneDialog = true">{{ t('member.settings.change_phone') }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 邮箱修改 -->
|
||||
<el-form-item :label="t('member.settings.email')">
|
||||
<div class="account-item">
|
||||
<el-input v-model="accountForm.email" disabled />
|
||||
<el-button type="primary" @click="showEmailDialog = true">{{ t('member.settings.change_email') }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 登录密码 -->
|
||||
<el-form-item :label="t('member.settings.login_password')">
|
||||
<div class="account-item">
|
||||
<el-input
|
||||
type="password"
|
||||
v-model="passwordPlaceholder"
|
||||
disabled
|
||||
placeholder="******"
|
||||
/>
|
||||
<el-button type="primary" @click="navigateToPassword">{{ t('member.settings.change_password') }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="card-header">{{ t('member.settings.third_party_bind') }}</div>
|
||||
|
||||
<!-- 绑定列表 -->
|
||||
<el-table :data="thirdPartyList" style="width: 100%">
|
||||
<el-table-column prop="id" label="序号" width="80" />
|
||||
<el-table-column :label="t('member.settings.bind')" width="150" align="left">
|
||||
<template #default="scope">
|
||||
<div class="platform-info">
|
||||
<!-- <Icon :icon="getPlatformIcon(scope.row.platform)" class="platform-icon" /> -->
|
||||
<span class="platform-name">{{ scope.row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('member.settings.bind')" min-width="150">
|
||||
<template #default="scope">
|
||||
<div class="platform-detail">
|
||||
<img
|
||||
v-if="scope.row.info?.avatar"
|
||||
:src="scope.row.info.avatar"
|
||||
class="avatar"
|
||||
alt=""
|
||||
/>
|
||||
<span>{{ scope.row.info?.account || "-" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bind_time" :label="t('member.settings.bind_time')" width="180" />
|
||||
<el-table-column prop="status" :label="t('member.settings.status')" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" type="success" v-if="scope.row.binded">{{ t('member.settings.using') }}</el-tag>
|
||||
<el-tag size="small" type="danger" v-else>{{ t('member.settings.not_bound') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('member.settings.options')" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnbind(scope.row)"
|
||||
v-if="scope.row.binded"
|
||||
>
|
||||
{{ t('member.settings.unbind') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="handleBind(scope.row)" v-else>
|
||||
{{ t('member.settings.bind') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 手机号修改弹窗 -->
|
||||
<el-dialog v-model="showPhoneDialog" :title="t('member.settings.phone_bind_dialog')" width="400px">
|
||||
<el-form
|
||||
:model="phoneForm"
|
||||
:rules="phoneRules"
|
||||
ref="phoneFormRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item :label="t('member.settings.old_phone')" prop="oldMobile">
|
||||
<el-input v-model="phoneForm.oldMobile" :placeholder="t('member.settings.old_phone_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.settings.new_phone')" prop="newMobile">
|
||||
<el-input v-model="phoneForm.newMobile" :placeholder="t('member.settings.new_phone_placeholder')">
|
||||
<template #append>
|
||||
<el-button @click="sendSmsCode('phone')" :disabled="smsCountdown > 0">
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('member.settings.get_code') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.settings.verification_code')" prop="code">
|
||||
<el-input v-model="phoneForm.code" :placeholder="t('member.settings.verification_code_placeholder')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showPhoneDialog = false">{{ t('member.settings.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="updatePhone">{{ t('member.settings.confirm') }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 邮箱修改弹窗 -->
|
||||
<el-dialog v-model="showEmailDialog" :title="t('member.settings.email_bind_dialog')" width="400px">
|
||||
<el-form
|
||||
:model="emailForm"
|
||||
:rules="emailRules"
|
||||
ref="emailFormRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item :label="t('member.settings.new_email')" prop="email">
|
||||
<el-input v-model="emailForm.email" type="email" :placeholder="t('member.settings.new_email_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('member.settings.verification_code')" prop="code">
|
||||
<el-input v-model="emailForm.code" :placeholder="t('member.settings.verification_code_placeholder')">
|
||||
<template #append>
|
||||
<el-button @click="sendSmsCode('email')" :disabled="emailCountdown > 0">
|
||||
{{ emailCountdown > 0 ? `${emailCountdown}s` : t('member.settings.get_code') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showEmailDialog = false">{{ t('member.settings.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="updateEmail">{{ t('member.settings.confirm') }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="qrDialogVisible" :title="qrDialogTitle" width="400px" center>
|
||||
<div class="qr-dialog-content">
|
||||
<div v-if="qrLoading" class="qr-loading">
|
||||
<el-icon class="is-loading"><loading /></el-icon>
|
||||
<p>{{ t('member.settings.qr_generating') }}</p>
|
||||
</div>
|
||||
<div v-else-if="qrExpired" class="qr-expired">
|
||||
<el-icon><warning /></el-icon>
|
||||
<p>{{ t('member.settings.qr_expired') }}</p>
|
||||
<el-button type="primary" @click="generateQrCode">{{ t('member.settings.qr_regenerate') }}</el-button>
|
||||
</div>
|
||||
<div v-else-if="qrSuccess" class="qr-success">
|
||||
<el-icon><success-filled /></el-icon>
|
||||
<p>{{ t('member.settings.qr_success') }}</p>
|
||||
<el-button type="primary" @click="closeQrDialog">{{ t('member.settings.confirm') }}</el-button>
|
||||
</div>
|
||||
<div v-else class="qr-code-container">
|
||||
<img v-if="qrCode" :src="qrCode" alt="绑定二维码" class="qr-code" />
|
||||
<p class="qr-tip">{{ t('member.settings.qr_tip', { name: currentBindItem?.name }) }}</p>
|
||||
<el-progress
|
||||
:percentage="qrProgress"
|
||||
:status="qrProgress === 100 ? 'success' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Loading, Warning, SuccessFilled } from "@element-plus/icons-vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
getThirdPartyList,
|
||||
unbindThirdParty,
|
||||
generateBindQrCode,
|
||||
checkBindQrStatus,
|
||||
} from "~/api/auth";
|
||||
import { getVerificationCode, updateMemberMobile, updateMemberEmail } from "~/api/member";
|
||||
import { useMemberStore } from "~/stores/member";
|
||||
|
||||
const router = useRouter();
|
||||
const memberStore = useMemberStore();
|
||||
|
||||
const getPlatformIcon = (platform: number): string => {
|
||||
const iconMap: Record<number, string> = {
|
||||
1: "ant-design:qq-circle-filled",
|
||||
2: "ant-design:wechat-filled",
|
||||
3: "ant-design:weibo-circle-filled",
|
||||
4: "ant-design:youtube-filled",
|
||||
};
|
||||
return iconMap[platform] || "ant-design:question-circle-filled";
|
||||
};
|
||||
|
||||
// 账户表单
|
||||
const accountForm = reactive({
|
||||
phone: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const passwordPlaceholder = ref("");
|
||||
|
||||
// 绑定列表
|
||||
const bindList = reactive([
|
||||
{
|
||||
type: "qq",
|
||||
name: t('member.settings.qq'),
|
||||
platform: 1,
|
||||
binded: false,
|
||||
scan: true,
|
||||
info: null,
|
||||
bind_time: "-",
|
||||
},
|
||||
{
|
||||
type: "wechat",
|
||||
name: t('member.settings.wechat'),
|
||||
platform: 2,
|
||||
binded: false,
|
||||
scan: true,
|
||||
info: null,
|
||||
bind_time: "-",
|
||||
},
|
||||
{
|
||||
type: "douyin",
|
||||
name: t('member.settings.douyin'),
|
||||
platform: 4,
|
||||
binded: false,
|
||||
scan: false,
|
||||
info: null,
|
||||
bind_time: "-",
|
||||
},
|
||||
{
|
||||
type: "weibo",
|
||||
name: t('member.settings.weibo'),
|
||||
platform: 3,
|
||||
binded: false,
|
||||
scan: false,
|
||||
info: null,
|
||||
bind_time: "-",
|
||||
},
|
||||
]);
|
||||
|
||||
// 第三方绑定列表
|
||||
const thirdPartyList = ref<any[]>([]);
|
||||
|
||||
// 手机号修改
|
||||
const showPhoneDialog = ref(false);
|
||||
const phoneForm = reactive({
|
||||
oldMobile: "",
|
||||
newMobile: "",
|
||||
code: "",
|
||||
});
|
||||
|
||||
const phoneRules = reactive({
|
||||
oldMobile: [{ required: true, message: t('member.settings.validation.old_phone_required'), trigger: "blur" }],
|
||||
newMobile: [{ required: true, message: t('member.settings.validation.new_phone_required'), trigger: "blur" }],
|
||||
code: [{ required: true, message: t('member.settings.validation.code_required'), trigger: "blur" }],
|
||||
});
|
||||
|
||||
const phoneFormRef = ref();
|
||||
|
||||
// 邮箱修改
|
||||
const showEmailDialog = ref(false);
|
||||
const emailForm = reactive({
|
||||
email: "",
|
||||
code: "",
|
||||
});
|
||||
|
||||
const emailRules = reactive({
|
||||
email: [{ required: true, message: t('member.settings.validation.email_required'), trigger: "blur" }],
|
||||
code: [{ required: true, message: t('member.settings.validation.new_email_code_required'), trigger: "blur" }],
|
||||
});
|
||||
|
||||
const emailFormRef = ref();
|
||||
|
||||
// 验证码倒计时
|
||||
const smsCountdown = ref(0);
|
||||
const emailCountdown = ref(0);
|
||||
|
||||
// 二维码绑定
|
||||
const qrDialogVisible = ref(false);
|
||||
const qrDialogTitle = ref("");
|
||||
const qrLoading = ref(false);
|
||||
const qrExpired = ref(false);
|
||||
const qrSuccess = ref(false);
|
||||
const qrCode = ref("");
|
||||
const qrProgress = ref(0);
|
||||
const currentBindItem = ref<any>(null);
|
||||
const currentSceneId = ref("");
|
||||
const qrTimer = ref<any>(null);
|
||||
|
||||
// 加载会员信息
|
||||
const loadMemberInfo = async () => {
|
||||
try {
|
||||
await memberStore.getMemberInfo();
|
||||
if (memberStore.info) {
|
||||
accountForm.phone = memberStore.info.phone || "";
|
||||
accountForm.email = memberStore.info.email || "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载会员信息失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载第三方绑定列表
|
||||
const loadThirdPartyList = async () => {
|
||||
try {
|
||||
const res = await getThirdPartyList();
|
||||
console.log("获取第三方绑定列表结果:", res);
|
||||
|
||||
if (res && Array.isArray(res)) {
|
||||
// 更新绑定状态
|
||||
res.forEach((item: any) => {
|
||||
const bindItem = bindList.find((b) => b.platform === item.platform);
|
||||
if (bindItem) {
|
||||
bindItem.binded = item.binded;
|
||||
bindItem.info = item.info;
|
||||
bindItem.bind_time = item.bind_time;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示所有平台(包括已绑定和未绑定)
|
||||
thirdPartyList.value = bindList.map((item: any, index: number) => ({
|
||||
id: index + 1,
|
||||
...item,
|
||||
bind_time: item.bind_time || "-",
|
||||
status: item.binded ? t('member.settings.using') : t('member.settings.not_bound'),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载第三方绑定列表失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMemberInfo();
|
||||
await loadThirdPartyList();
|
||||
});
|
||||
|
||||
// 发送验证码
|
||||
const sendSmsCode = async (type: string) => {
|
||||
try {
|
||||
let data: any = {};
|
||||
|
||||
if (type === "phone") {
|
||||
if (!phoneForm.newMobile) {
|
||||
ElMessage.warning(t('member.settings.please_enter_phone'));
|
||||
return;
|
||||
}
|
||||
data = {
|
||||
mobile: phoneForm.newMobile,
|
||||
type: "update_phone",
|
||||
};
|
||||
} else if (type === "email") {
|
||||
if (!emailForm.email) {
|
||||
ElMessage.warning(t('member.settings.please_enter_email'));
|
||||
return;
|
||||
}
|
||||
data = {
|
||||
email: emailForm.email,
|
||||
type: "update_email",
|
||||
};
|
||||
}
|
||||
|
||||
await getVerificationCode(data);
|
||||
ElMessage.success(t('member.settings.code_send_success'));
|
||||
|
||||
// 开始倒计时
|
||||
if (type === "phone") {
|
||||
smsCountdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} else if (type === "email") {
|
||||
emailCountdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
emailCountdown.value--;
|
||||
if (emailCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.settings.code_send_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 更新手机号
|
||||
const updatePhone = async () => {
|
||||
if (!phoneFormRef.value) return;
|
||||
|
||||
try {
|
||||
await updateMemberMobile(phoneForm);
|
||||
ElMessage.success(t('member.settings.phone_update_success'));
|
||||
showPhoneDialog.value = false;
|
||||
await loadMemberInfo();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.settings.phone_update_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 更新邮箱
|
||||
const updateEmail = async () => {
|
||||
if (!emailFormRef.value) return;
|
||||
|
||||
try {
|
||||
await updateMemberEmail(emailForm);
|
||||
ElMessage.success(t('member.settings.email_update_success'));
|
||||
showEmailDialog.value = false;
|
||||
await loadMemberInfo();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.settings.email_update_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到修改密码页面
|
||||
const navigateToPassword = () => {
|
||||
router.push("/member/password");
|
||||
};
|
||||
|
||||
// 处理绑定
|
||||
const handleBind = async (item: any) => {
|
||||
console.log("handleBind 被调用", item);
|
||||
|
||||
if (item.binded) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('member.settings.confirm_unbind', { name: item.name }), t('member.settings.confirm_unbind_title'), {
|
||||
confirmButtonText: t('member.settings.confirm'),
|
||||
cancelButtonText: t('member.settings.cancel'),
|
||||
type: "warning",
|
||||
});
|
||||
try {
|
||||
await unbindThirdParty(item.platform);
|
||||
ElMessage.success(t('member.settings.unbind_success'));
|
||||
await loadThirdPartyList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.settings.unbind_failed'));
|
||||
}
|
||||
} catch {
|
||||
ElMessage.info(t('member.settings.cancel_unbind'));
|
||||
}
|
||||
} else {
|
||||
console.log("开始绑定流程", item);
|
||||
|
||||
if (item.scan) {
|
||||
console.log("使用扫码绑定");
|
||||
currentBindItem.value = item;
|
||||
qrDialogTitle.value = t('member.settings.qr_bind_title', { name: item.name });
|
||||
generateQrCode();
|
||||
} else {
|
||||
console.log("使用应用跳转绑定");
|
||||
const schemes: Record<string, string> = {
|
||||
douyin: "snssdk1128://",
|
||||
weibo: "sinaweibo://",
|
||||
};
|
||||
|
||||
const scheme = schemes[item.type];
|
||||
if (scheme) {
|
||||
try {
|
||||
window.location.href = scheme;
|
||||
ElMessage.info(t('member.settings.opening_app', { name: item.name }));
|
||||
} catch (error) {
|
||||
ElMessage.error(t('member.settings.open_app_failed', { name: item.name }));
|
||||
}
|
||||
} else {
|
||||
ElMessage.info(t('member.settings.please_bind', { name: item.name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理解绑
|
||||
const handleUnbind = async (item: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('member.settings.confirm_unbind', { name: item.name }), t('member.settings.confirm_unbind_title'), {
|
||||
confirmButtonText: t('member.settings.confirm'),
|
||||
cancelButtonText: t('member.settings.cancel'),
|
||||
type: "warning",
|
||||
});
|
||||
try {
|
||||
await unbindThirdParty(item.platform);
|
||||
ElMessage.success(t('member.settings.unbind_success'));
|
||||
await loadThirdPartyList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('member.settings.unbind_failed'));
|
||||
}
|
||||
} catch {
|
||||
ElMessage.info(t('member.settings.cancel_unbind'));
|
||||
}
|
||||
};
|
||||
|
||||
// 生成二维码
|
||||
const generateQrCode = async () => {
|
||||
console.log("generateQrCode 被调用", currentBindItem.value);
|
||||
|
||||
if (!currentBindItem.value) {
|
||||
console.log("currentBindItem 为空,返回");
|
||||
return;
|
||||
}
|
||||
|
||||
qrDialogVisible.value = true;
|
||||
qrLoading.value = true;
|
||||
qrExpired.value = false;
|
||||
qrSuccess.value = false;
|
||||
qrCode.value = "";
|
||||
qrProgress.value = 0;
|
||||
|
||||
try {
|
||||
console.log("开始生成二维码,platform:", currentBindItem.value.platform);
|
||||
const res = await generateBindQrCode(currentBindItem.value.platform);
|
||||
console.log("二维码生成结果:", res);
|
||||
console.log("qr_code URL:", res.qr_code);
|
||||
|
||||
if (res) {
|
||||
currentSceneId.value = res.scene_id;
|
||||
qrCode.value = res.qr_code;
|
||||
console.log("qrCode URL:", qrCode.value);
|
||||
qrLoading.value = false;
|
||||
startPolling();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("生成二维码失败:", error);
|
||||
ElMessage.error(error.message || t('member.settings.qr_generate_failed'));
|
||||
qrDialogVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
if (!currentSceneId.value) return;
|
||||
|
||||
qrTimer.value = setInterval(async () => {
|
||||
try {
|
||||
const res = await checkBindQrStatus(currentSceneId.value);
|
||||
if (res) {
|
||||
if (res.status === "success") {
|
||||
qrSuccess.value = true;
|
||||
qrProgress.value = 100;
|
||||
stopPolling();
|
||||
await loadThirdPartyList();
|
||||
} else if (res.status === "pending") {
|
||||
qrProgress.value = Math.min(qrProgress.value + 10, 90);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("过期")) {
|
||||
qrExpired.value = true;
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (qrTimer.value) {
|
||||
clearInterval(qrTimer.value);
|
||||
qrTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭二维码弹窗
|
||||
const closeQrDialog = () => {
|
||||
stopPolling();
|
||||
qrDialogVisible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-settings {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.account-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-item .el-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.empty-bind-list {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.available-platforms {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.platforms-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.platform-item:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-icon.qq {
|
||||
background-color: #12b7f5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-icon.wechat {
|
||||
background-color: #07c160;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-icon.douyin {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-icon.weibo {
|
||||
background-color: #e6162d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-info .platform-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.platform-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-detail .avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.qr-dialog-content {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-loading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-expired {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-expired p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-success p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qr-tip {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<el-card class="sign-in">
|
||||
<!-- 标题和操作区 -->
|
||||
<div class="sign-in__header flex justify-between items-center mb-2">
|
||||
<h3 class="text-base font-semibold text-gray-800">{{ t('member.sign.page_title') }}</h3>
|
||||
<div class="sign-in__actions flex gap-2">
|
||||
<el-button type="text" @click="showRules" size="small">{{ t('member.sign.sign_rules') }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="isSignedToday || isLoading"
|
||||
@click="signInAction"
|
||||
size="small"
|
||||
>
|
||||
<el-icon v-if="isLoading"><Loading /></el-icon>
|
||||
{{ isSignedToday ? t('member.sign.signed') : t('member.sign.sign_button') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签到提示 -->
|
||||
<div v-if="showTip" class="sign-in__tip bg-green-50 border border-green-200 rounded-md p-2 flex justify-between items-center mb-3">
|
||||
<span class="text-green-700 text-sm">{{ t('member.sign.tomorrow_points', { points: tomorrowPoints }) }}</span>
|
||||
<el-button type="text" size="small" @click="closeTip">×</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="sign-in__divider border-t border-gray-200 my-3"></div>
|
||||
|
||||
<!-- 日历导航 -->
|
||||
<div class="sign-in__calendar-header flex justify-between items-center mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button type="text" @click="prevMonth" size="small">‹</el-button>
|
||||
<span class="text-gray-700 text-sm">{{ currentYear }}年{{ currentMonth }}月</span>
|
||||
<el-button type="text" @click="nextMonth" size="small">›</el-button>
|
||||
</div>
|
||||
<span class="sign-in__consecutive text-primary text-sm">{{ t('member.sign.consecutive_days') }} {{ consecutiveDays }} {{ t('member.sign.days') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 日历 -->
|
||||
<div class="sign-in__calendar">
|
||||
<!-- 星期标题和日期表格 -->
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="day in weekdays" :key="day" class="w-10 h-8 text-center text-gray-500 text-xs">
|
||||
{{ day }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- 日期行 -->
|
||||
<tr v-for="(week, index) in calendarWeeks" :key="index">
|
||||
<td v-for="(date, dayIndex) in week" :key="dayIndex" class="h-14 text-center">
|
||||
<div
|
||||
v-if="date"
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center text-sm rounded-full cursor-pointer transition-colors duration-200"
|
||||
:class="{
|
||||
'bg-[#67c23a] text-white font-medium': isSigned(date),
|
||||
'bg-blue-50 text-blue-600': isToday(date) && !isSigned(date),
|
||||
'text-gray-800': !isToday(date) && !isSigned(date)
|
||||
}"
|
||||
@mouseenter="!isSigned(date) && $event.target.classList.add('bg-blue-50', 'text-blue-600')"
|
||||
@mouseleave="!isSigned(date) && $event.target.classList.remove('bg-blue-50', 'text-blue-600')"
|
||||
>
|
||||
{{ date.getDate() }}
|
||||
</div>
|
||||
<div v-else class="w-10 h-10 mx-auto"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { signIn, getSignStatus, getSignCalendar } from '~/api/member'
|
||||
|
||||
const memberStore = useMemberStore()
|
||||
|
||||
// 响应式数据
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const currentMonth = ref(new Date().getMonth() + 1)
|
||||
const isSignedToday = ref(false)
|
||||
const consecutiveDays = ref(0)
|
||||
const showTip = ref(true)
|
||||
const isLoading = ref(false)
|
||||
const signedDates = ref<Set<string>>(new Set())
|
||||
|
||||
// 计算属性
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'] // 以星期一为起始
|
||||
|
||||
const tomorrowPoints = computed(() => {
|
||||
// 根据连续签到天数计算明天可获得的积分
|
||||
if (consecutiveDays.value >= 7) return 5
|
||||
if (consecutiveDays.value >= 3) return 3
|
||||
return 2
|
||||
})
|
||||
|
||||
// 生成日历数据(按周分组)
|
||||
const calendarWeeks = computed(() => {
|
||||
const weeks = []
|
||||
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1)
|
||||
const lastDay = new Date(currentYear.value, currentMonth.value, 0)
|
||||
// 计算当月第一天是星期几(1-7,1=星期一)
|
||||
let firstDayWeekday = firstDay.getDay()
|
||||
firstDayWeekday = firstDayWeekday === 0 ? 7 : firstDayWeekday
|
||||
|
||||
// 生成日期数组
|
||||
let week: (Date | null)[] = []
|
||||
|
||||
// 填充月初空白
|
||||
for (let i = 0; i < firstDayWeekday - 1; i++) {
|
||||
week.push(null)
|
||||
}
|
||||
|
||||
// 填充当月日期
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
// 确保创建日期时设置为当天的开始时间
|
||||
const currentDate = new Date(currentYear.value, currentMonth.value - 1, day)
|
||||
currentDate.setHours(0, 0, 0, 0)
|
||||
week.push(currentDate)
|
||||
|
||||
// 每周结束,开始新周
|
||||
if (week.length === 7) {
|
||||
weeks.push([...week])
|
||||
week = []
|
||||
}
|
||||
}
|
||||
|
||||
// 填充月末空白
|
||||
while (week.length > 0 && week.length < 7) {
|
||||
week.push(null)
|
||||
}
|
||||
|
||||
// 添加最后一周
|
||||
if (week.length === 7) {
|
||||
weeks.push(week)
|
||||
}
|
||||
|
||||
return weeks
|
||||
})
|
||||
|
||||
// 检查日期是否是今天
|
||||
const isToday = (date: Date) => {
|
||||
// 使用固定的日期格式化方法,确保不受时区影响
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
const todayStr = `${year}-${month}-${day}`
|
||||
const checkYear = date.getFullYear()
|
||||
const checkMonth = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const checkDay = String(date.getDate()).padStart(2, '0')
|
||||
const checkDateStr = `${checkYear}-${checkMonth}-${checkDay}`
|
||||
console.log('Checking if today:', {
|
||||
today: todayStr,
|
||||
checkDate: checkDateStr,
|
||||
isEqual: todayStr === checkDateStr
|
||||
})
|
||||
return todayStr === checkDateStr
|
||||
}
|
||||
|
||||
// 检查日期是否已签到
|
||||
const isSigned = (date: Date) => {
|
||||
// 使用固定的日期格式化方法,确保不受时区影响
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const dateStr = `${year}-${month}-${day}`
|
||||
console.log('Checking if signed:', {
|
||||
date: dateStr,
|
||||
isSigned: signedDates.value.has(dateStr),
|
||||
signedDates: Array.from(signedDates.value)
|
||||
})
|
||||
return signedDates.value.has(dateStr)
|
||||
}
|
||||
|
||||
// API 调用
|
||||
const fetchSignStatus = async () => {
|
||||
try {
|
||||
const data = await getSignStatus() as any
|
||||
isSignedToday.value = data.is_signed_today
|
||||
consecutiveDays.value = data.continuous_days
|
||||
} catch (error: any) {
|
||||
console.error('获取签到状态失败:', error)
|
||||
ElMessage.error(error.message || t('member.sign.get_sign_status_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSignCalendar = async () => {
|
||||
try {
|
||||
const result = await getSignCalendar(currentYear.value, currentMonth.value) as any
|
||||
signedDates.value.clear()
|
||||
result.calendar.forEach((date: string) => {
|
||||
signedDates.value.add(date)
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('获取签到日历失败:', error)
|
||||
ElMessage.error(error.message || t('member.sign.get_sign_calendar_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const showRules = () => {
|
||||
// 显示签到规则
|
||||
ElMessage.info(t('member.sign.rules_content'))
|
||||
}
|
||||
|
||||
const signInAction = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const data = await signIn()
|
||||
isSignedToday.value = true
|
||||
consecutiveDays.value = data.continuous_days
|
||||
ElMessage.success(t('member.sign.sign_success', { points: data.points }))
|
||||
|
||||
// 更新签到日历
|
||||
await fetchSignCalendar()
|
||||
} catch (error: any) {
|
||||
console.error('签到失败:', error)
|
||||
ElMessage.error(error.message || t('member.sign.sign_failed'))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeTip = () => {
|
||||
showTip.value = false
|
||||
}
|
||||
|
||||
const prevMonth = async () => {
|
||||
if (currentMonth.value === 1) {
|
||||
currentMonth.value = 12
|
||||
currentYear.value--
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
await fetchSignCalendar()
|
||||
}
|
||||
|
||||
const nextMonth = async () => {
|
||||
if (currentMonth.value === 12) {
|
||||
currentMonth.value = 1
|
||||
currentYear.value++
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
await fetchSignCalendar()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
// 初始化签到状态
|
||||
await fetchSignStatus()
|
||||
await fetchSignCalendar()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user