hookehuyr

feat(recall): 实现用户召回功能相关接口和逻辑

- 添加用户信息获取接口和登录接口
- 完善个人信息页面增加身份证验证和提交逻辑
- 活动历史页面对接真实数据接口
- 时间轴页面显示用户真实数据和活动统计
- 海报页面支持上传和编辑背景图片
- 积分页面显示用户实际积分数据
/*
* @Date: 2025-12-19 10:43:09
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-24 16:01:49
* @LastEditTime: 2025-12-24 17:19:47
* @FilePath: /mlaj/src/api/recall_users.js
* @Description: 引入外部接口, 召回旧用户相关接口
*/
......@@ -9,6 +9,7 @@ import { fn, fetch } from './fn';
const Api = {
USER_LOGIN: '/srv/?a=user_login',
USER_INFO: '/srv/?a=desk_calendar&t=user_info',
USER_SEARCH_OLD_ACTIVITY: '/srv/?a=desk_calendar&t=search_old_activity',
USER_GET_POSTER: '/srv/?a=desk_calendar&t=get_poster',
USER_EDIT_POSTER: '/srv/?a=desk_calendar&t=edit_poster',
......@@ -18,11 +19,16 @@ const Api = {
* @description: 用户登录
* @param: mobile 手机号
* @param: password 用户密码
* @return: data: { user_info 用户信息 }
*/
export const loginAPI = (params) => fn(fetch.post(Api.USER_LOGIN, params));
/**
* @description: 获取用户信息
* @return: data.user: { id 用户ID, name 姓名, mobile 手机号, has_idcard 是否填写了身份证, has_activity_registration 是否收集了星球壁币 }
*/
export const userInfoAPI = () => fn(fetch.get(Api.USER_INFO));
/**
* @description: 搜索历史活动
* @param: name 姓名
* @param: mobile 手机号
......
/*
* @Date: 2025-03-23 23:45:53
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-26 16:09:46
* @LastEditTime: 2025-12-24 17:10:13
* @FilePath: /mlaj/src/api/users.js
* @Description: 用户相关接口
*/
......@@ -48,6 +48,9 @@ export const getUserInfoAPI = () => fn(fetch.get(Api.USER_INFO));
* @description: 更新用户信息
* @param: name 用户名称
* @param: avatar 头像
* @param: mobile 手机号
* @param: sms_code 短信验证码
* @param: idcard 身份证号
*/
export const updateUserInfoAPI = (params) => fn(fetch.post(Api.USER_UPDATE, params));
......
......@@ -85,6 +85,11 @@ const handleUsernameChange = async () => {
};
// 更新localStorage中的用户信息
localStorage.setItem('currentUser', JSON.stringify(currentUser.value));
// 更新司总缓存user_info键里面的user_name的值改成新的用户名
localStorage.setItem('user_info', JSON.stringify({
...JSON.parse(localStorage.getItem('user_info') || '{}'),
user_name: username.value
}));
showToast('用户名修改成功');
}
} catch (error) {
......
......@@ -100,61 +100,29 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { showToast } from 'vant'
import { userInfoAPI, searchOldActivityAPI } from '@/api/recall_users'
const historyBg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/history_bg@2x.png'
const router = useRouter()
useTitle('活动历史')
// Mock Data
const activities = ref([
{
id: 1,
title: '2025.11月3日-10日江苏东台养生营,邀您一起进入童话世界!',
price: 3200.00,
count: 2,
date: '2025-11-03',
total: 6400
},
{
id: 2,
title: '【自然的恩典】青少年成长营-贵阳百花湖3(小学初中专场)',
price: 3999.00,
count: 2,
date: '2025-10-03',
total: 7998
},
{
id: 3,
title: '2024年4月22-25日浙江义乌【中华智慧商业应用论坛】',
price: 3200.00,
count: 1,
date: '2024-04-03',
total: 3200
},
{
id: 4,
title: '2023.7.6-7.11【自然的恩典】“爱我中华”优秀传统文化夏令营-天津场',
price: 3990.00,
count: 1,
date: '2023-07-01',
total: 3990
}
])
const activities = ref([])
// State
const showMissingPopup = ref(false)
const missingInfo = ref('')
// Actions
// 处理生成海报
const handleGeneratePoster = (item) => {
// 跳转到/recall/poster?id=item.id
router.push({ path: '/recall/poster', query: { id: item.id } })
showToast('生成海报: ' + item.title)
router.push({ path: '/recall/poster', query: { id: item.id, stu_uid: item.stu_uid, campaign_id: item.campaign_id } })
// showToast('生成海报: ' + item.title)
}
const handleCollectCoins = () => {
......@@ -179,6 +147,33 @@ const handleSubmitMissing = () => {
missingInfo.value = ''
}
onMounted(async () => {
// 获取用户信息
const userInfoRes = await userInfoAPI()
if (userInfoRes.code) {
// 通过用户信息获取活动历史信息
const activityRes = await searchOldActivityAPI({
name: userInfoRes.data?.user_name || '',
mobile: userInfoRes.data?.mobile || '',
idcard: userInfoRes.data?.idcard || ''
})
if (activityRes.code) {
// 获取历史列表数据
const campaign_info = activityRes.data?.campaign_info || []
if (campaign_info.length) {
activities.value = campaign_info.map(item => ({
id: item.campaign_id || 0,
stu_uid: item.stu_uid || '',
title: item.campaign_name || '',
price: item.fee_stu || 0,
count: item.stu_cnt || 0,
date: item.create_time?.substring(0, 10) || '',
total: item.fee_stu * item.stu_cnt || 0
}))
}
}
}
})
</script>
<style lang="less" scoped>
......
......@@ -75,6 +75,8 @@ import { ref, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { showToast } from 'vant'
import { updateUserInfoAPI } from '@/api/users'
import { searchOldActivityAPI } from '@/api/recall_users'
const bgImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png'
const starImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/xing@2x.png'
......@@ -92,7 +94,7 @@ const form = reactive({
})
// Actions
const handleConfirm = () => {
const handleConfirm = async () => {
if (!form.name) {
showToast('请输入姓名')
return
......@@ -113,16 +115,36 @@ const handleConfirm = () => {
return
}
// Validation passed
// showToast('提交成功')
// TODO: 完善信息之后, 如果能查看历史数据, 则需要跳转到timeline
const flag = false;
if (flag) {
const res = await updateUserInfoAPI({
name: form.name,
mobile: form.phone,
idcard: form.idCard
})
if (res.code) {
const activityRes = await searchOldActivityAPI({
name: form.name,
mobile: form.phone,
idcard: form.idCard
})
if (activityRes.code) {
// 更新司总缓存user_info键里面的user_name的值改成新的用户名
localStorage.setItem('user_info', JSON.stringify({
...JSON.parse(localStorage.getItem('user_info') || '{}'),
user_name: form.name
}));
// 获取历史列表数据
const campaign_info = activityRes.data?.campaign_info || []
if (campaign_info.length) {
// 有历史数据, 跳转到timeline
router.push('/recall/timeline')
return
}
// 如果不能查看历史数据, 则需要跳转到id-query, 继续查数据
} else {
// 没有历史数据, 跳转到id-query, 继续查数据
router.push('/recall/id-query')
return
}
}
}
}
</script>
......
......@@ -102,6 +102,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { showToast } from 'vant'
import { searchOldActivityAPI } from '@/api/recall_users'
// Assets
const bgImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png'
......@@ -123,7 +124,7 @@ const validateIdCard = (id) => {
return reg.test(id)
}
const handleConfirm = () => {
const handleConfirm = async () => {
if (!name.value) {
showToast('请输入姓名')
return
......@@ -149,15 +150,21 @@ const handleConfirm = () => {
return
}
// TODO: Submit logic
// showToast('验证通过')
// TODO: 如果能查到数据, 则跳转到timeline, 否则弹出提示
const flag = true;
const res = await searchOldActivityAPI({
name: name.value,
mobile: phone.value,
idcard: idCard.value
})
if (res.code) {
const campaign_info = res.data?.campaign_info || []
// 如果能查到数据, 则跳转到timeline, 否则弹出提示
const flag = campaign_info.length > 0;
if (flag) {
router.push('/recall/timeline')
return
}
showToast('时光机没有找到您的历史活动信息')
}
}
</script>
......
......@@ -81,7 +81,7 @@
<div class="mb-2 bg-white/10 backdrop-blur-md rounded-2xl p-4 text-center border border-white/20 shadow-lg flex-shrink-0">
<p class="text-white text-sm mb-1 opacity-90">您成功收集到</p>
<div class="flex items-baseline justify-center mb-1">
<span class="text-yellow-400 text-3xl font-bold mr-2 tracking-wider">15,800</span>
<span class="text-yellow-400 text-3xl font-bold mr-2 tracking-wider">{{ totalPoints || 0 }}</span>
<span class="text-white text-sm opacity-90">星球币</span>
</div>
<p class="text-white text-[12px] scale-90">基于您的历史活动自动计算所得 1元积1分</p>
......@@ -117,6 +117,9 @@
<script setup>
import { useTitle } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { getPointsListAPI } from '@/api/points'
const bgImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png'
const ppImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/pp@2x.png'
......@@ -141,6 +144,16 @@ const handleExplore = () => {
// 路由跳转逻辑
router.push('/')
}
const totalPoints = ref(0);
onMounted(async () => {
const res = await getPointsListAPI()
if (res.code) {
// 需要格式化一下, 变成千分号显示
totalPoints.value = Number(res.data?.balance || 0).toLocaleString();
}
})
</script>
<style lang="less" scoped>
......
<!--
* @Date: 2025-12-23 15:50:59
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-23 17:07:47
* @LastEditTime: 2025-12-24 17:47:23
* @FilePath: /mlaj/src/views/recall/PosterPage.vue
* @Description: 分享海报页面
-->
......@@ -33,7 +33,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import RecallPoster from '@/components/ui/RecallPoster.vue'
......@@ -42,6 +42,8 @@ import { showToast, showLoadingToast } from 'vant'
import { qiniuFileHash } from '@/utils/qiniuFileHash'
import { useAuth } from '@/contexts/auth'
import { getPosterAPI, editPosterAPI } from '@/api/recall'
const $route = useRoute();
const $router = useRouter();
const { currentUser } = useAuth();
......@@ -103,6 +105,10 @@ const afterRead = async (file) => {
// 文件已存在,直接使用
if (tokenResult.data) {
posterBg.value = tokenResult.data.src;
// 编辑海报配置
await editPosterAPI({
background_image: tokenResult.data.src
})
showToast('图片上传成功');
return;
}
......@@ -132,6 +138,10 @@ const afterRead = async (file) => {
if (data) {
posterBg.value = data.src;
// 编辑海报配置
await editPosterAPI({
background_image: data.src
})
showToast('图片上传成功');
}
}
......@@ -143,6 +153,24 @@ const afterRead = async (file) => {
toast.close();
}
}
onMounted(async () => {
const stu_uid = $route.query.stu_uid
const campaign_id = $route.query.campaign_id
if (stu_uid && campaign_id) {
const { data } = await getPosterAPI({
stu_uid,
campaign_id
})
if (data) {
title.value = $route.query.title || '活动海报'
posterBg.value = data.background_image || defaultBg
qrCodeUrl.value = data.qrcode
}
}
})
</script>
<style lang="less" scoped>
......
......@@ -89,6 +89,7 @@ import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { useTitle } from '@vueuse/core'
import { smsAPI } from '@/api/common'
import { loginAPI, userInfoAPI } from '@/api/recall_users'
import { useTracking } from '@/composables/useTracking'
const titleImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/title01@2x.png'
......@@ -161,7 +162,7 @@ const handleSendCode = async () => {
/**
* @description 登录/下一步
*/
const handleLogin = () => {
const handleLogin = async () => {
// TAG: 埋点, 登录/下一步按钮点击
trackClick('login_next_btn', { phone: phone.value })
......@@ -174,14 +175,29 @@ const handleLogin = () => {
return
}
// showToast('登录成功')
// TODO: 登录之后需要判断是否有完善个人信息, 如果没有进入完善流程, 如果完成直接跳到timeline
const info = false;
if (info) {
try {
const res = await loginAPI({ mobile: phone.value, sms_code: code.value })
if (res.code) {
const userInfo = await userInfoAPI()
// 登录之后需要判断是否有完善个人信息
if (userInfo.code) {
const info = userInfo.data.user || '';
if (!info.has_idcard) { // 如果【查询我的信息】没有填写过身份证(has_idcard 为 false),则进入完善个人信息页
$router.push('/recall/boot')
} else { // 如果【查询我的信息】填写过身份证(has_idcard 为 true)
if (!info.has_activity_registration) { // 如果【查询我的信息】中没有兑换过星球币(has_activity_registration 为 false),则进入二次查询历史活动页
$router.push('/recall/id-query')
} else { // 如果【查询我的信息】中兑换过星球币(has_activity_registration 为 true),则进入时光机页面
$router.push('/recall/timeline')
return
}
$router.push('/recall/boot')
}
}
}
}
catch (error) {
console.error('登录失败:', error)
showToast('登录失败,请稍后重试')
}
}
// Agreement Content Structure
......
......@@ -12,7 +12,7 @@
<!-- Title Section -->
<div class="flex flex-col items-center mb-8 animate-fade-in-down">
<h2 class="text-[#FFDD01] text-2xl font-bold mb-4 tracking-wider">@{{ userName }}</h2>
<h2 class="text-[#FFDD01] text-2xl font-bold mb-4 tracking-wider">@{{ userInfo.value.name }}</h2>
<img :src="title03" class="w-64 object-contain" alt="Welcome Back" />
</div>
......@@ -46,7 +46,7 @@
<!-- Title Section -->
<div class="flex flex-col items-center mb-10 animate-fade-in-down">
<img :src="title04" class="w-64 object-contain mb-4" alt="My Footprints" />
<h2 class="text-[#FFDD01] text-2xl font-bold tracking-wider">@{{ userName }}</h2>
<h2 class="text-[#FFDD01] text-2xl font-bold tracking-wider">@{{ userInfo.value.name }}</h2>
</div>
<!-- Card Section -->
......@@ -86,7 +86,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import dayjs from 'dayjs'
......@@ -94,6 +94,8 @@ import { Swiper, SwiperSlide } from 'swiper/vue'
import { Mousewheel } from 'swiper/modules'
import 'swiper/css'
import { userInfoAPI, searchOldActivityAPI } from '@/api/recall_users'
const bgImg = 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png'
const title03 = 'https://cdn.ipadbiz.cn/mlaj/recall/img/title03@2x.png'
......@@ -122,9 +124,13 @@ const onSlideChange = () => {
}
// Data Logic
const userName = computed(() => currentUser.value?.name || '张开心') // Fallback to design mock name
const recordDate = ref('')
const lastActivityDate = ref('')
const activityCount = ref(0)
const volunteerCount = ref(0)
const joinDate = computed(() => {
return currentUser.value?.created_at || currentUser.value?.reg_time || '2020-09-12' // Fallback to design mock date
return recordDate.value || currentUser.value?.created_at || currentUser.value?.reg_time || '2020-09-12' // Fallback to design mock date
})
const joinYear = computed(() => dayjs(joinDate.value).year())
......@@ -135,12 +141,12 @@ const joinDateFormatted = computed(() => {
const durationString = computed(() => {
const start = dayjs(joinDate.value)
const now = dayjs()
const years = now.diff(start, 'year')
const months = now.diff(start, 'month') % 12
const end = lastActivityDate.value ? dayjs(lastActivityDate.value) : dayjs()
const years = end.diff(start, 'year')
const months = end.diff(start, 'month') % 12
if (years === 0 && months === 0) {
const days = now.diff(start, 'day');
const days = end.diff(start, 'day');
return `${days}天`;
}
......@@ -151,14 +157,34 @@ const durationString = computed(() => {
}
})
// Mock Stats
const activityCount = ref(20)
const volunteerCount = ref(12)
const handleViewHistory = () => {
router.push('/recall/activity-history')
}
const userInfo = ref({})
onMounted(async () => {
// 获取用户信息
const res = await userInfoAPI()
if (res.code) {
userInfo.value = res.data || {};
if (userInfo.value.name && userInfo.value.mobile && userInfo.value.idcard) {
// 检查是否有历史数据
const res = await searchOldActivityAPI({
name: userInfo.value.name,
mobile: userInfo.value.mobile,
idcard: userInfo.value.idcard
})
if (res.code) {
activityCount.value = res.data?.payment_qty || 0
volunteerCount.value = res.data?.volunteer_qty || 0
recordDate.value = res.data?.record_date || ''
lastActivityDate.value = res.data?.last_activity_date || ''
}
}
}
})
</script>
<style lang="less" scoped>
......