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:
Mr.April
2026-04-07 11:01:11 +08:00
parent 1b83f1890b
commit d84d972e73
127 changed files with 103052 additions and 14661 deletions
@@ -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>
+264
View File
@@ -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>