feat: 对接家长登录

This commit is contained in:
阿梦 2026-05-29 14:21:32 +08:00
parent d13f75ddc0
commit 5fd397b1ea
8 changed files with 224 additions and 42 deletions

4
.env
View File

@ -1,5 +1,5 @@
#VITE_HOST = http://hjcy.niuniuhotpot.fr/prod-api VITE_BASE_URL = http://43.136.52.196:9053
VITE_BASE_URL = https://ai.xuexiaole.com # VITE_BASE_URL = https://ai.lingxixue.com
# VITE_WS_URL = ws://8.138.151.141:8888/ # VITE_WS_URL = ws://8.138.151.141:8888/
VITE_WS_URL = ws://ec2-13-36-229-106.eu-west-3.compute.amazonaws.com:8888/ VITE_WS_URL = ws://ec2-13-36-229-106.eu-west-3.compute.amazonaws.com:8888/

View File

@ -23,32 +23,61 @@ export const psdLogin = (data: AccountLoginParams) =>
}); });
export interface ParentSmsLoginParams { export interface ParentSmsLoginParams {
mobile: string; phone: string;
smsCode: string; verifyCode: string;
openid: string;
} }
/** 家长端 - 发送登录验证码 */ export interface ParentMpLoginResp {
export const sendParentLoginCode = (mobile: string) => accessToken?: string | null;
request({ account?: string | null;
url: '/parent/sendLoginCode', phone?: string | null;
wxUser?: {
openid?: string;
unionId?: string;
nickname?: string | null;
[key: string]: any;
};
}
/** 家长端 - 小程序 code 登录(查 openId & 绑定关系) */
export const parentMpLogin = (code: string) =>
request<ParentMpLoginResp>({
url: '/mpLogin',
method: 'POST', method: 'POST',
headers: { Authorization: '' }, headers: { Authorization: '' },
silent: true, silent: true,
data: { mobile }, data: { code },
}); });
/** 家长端 - 手机号验证码登录 */ /** 家长端 - 发送登录验证码form-urlencoded 请求体,非 URL 查询参数) */
export const sendParentLoginCode = (phone: string) =>
request({
url: '/sms/sendMessage',
method: 'POST',
headers: {
Authorization: '',
'Content-Type': 'application/x-www-form-urlencoded',
},
silent: true,
data: `phoneNumbers=${encodeURIComponent(phone)}`,
});
/** 家长端 - 手机号验证码登录(绑定微信 openid成功时 data 为 JWT 字符串 */
export const parentSmsLogin = (data: ParentSmsLoginParams) => export const parentSmsLogin = (data: ParentSmsLoginParams) =>
request<string>({ request<string>({
url: '/parent/loginBySms', url: '/smsLogin',
method: 'POST', method: 'POST',
headers: { Authorization: '' }, headers: {
Authorization: '',
'X-Tenant-Id': '1966391196339204098',
},
data: { data: {
mobile: data.mobile, bindMaOpenid: true,
smsCode: data.smsCode, adminType: 16,
clientType: 'PARENT', openid: data.openid,
simSerialNumber: 'miniProgram', phone: data.phone,
model: 'wechat', verifyCode: data.verifyCode,
}, },
}); });

52
src/api/parent.ts Normal file
View File

@ -0,0 +1,52 @@
import { request } from '@/utils/request';
export interface ParentBindChildInfo {
account?: string;
avatar?: string;
id?: number;
name?: string;
nickname?: string;
phone?: string;
[key: string]: any;
}
export interface ParentBindChildStudent {
account?: string;
avatar?: string;
gradeId?: number;
gradeName?: string;
id?: number;
name?: string;
nickname?: string;
[key: string]: any;
}
export interface ParentBindChildItem {
id?: number;
bindFlag?: number;
bindTime?: string;
identityType?: number;
child?: ParentBindChildStudent;
parent?: ParentBindChildInfo;
[key: string]: any;
}
export interface PageResult<T> {
current?: number;
rows?: T[];
size?: number;
totalPage?: number;
totalRows?: number;
[key: string]: any;
}
/** 家长绑定孩子列表 */
export const getParentBindChildList = (params?: { current?: number; size?: number }) =>
request<PageResult<ParentBindChildItem>>({
url: '/parentBindChild/list',
method: 'GET',
params: {
current: params?.current ?? 1,
size: params?.size ?? 100,
},
});

View File

@ -50,7 +50,7 @@
"quickapp" : {}, "quickapp" : {},
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wxa5522671f30c5891", "appid" : "wxab166c9b5b6401ea",
"setting" : { "setting" : {
"urlCheck" : false, "urlCheck" : false,
"minified" : true, "minified" : true,

View File

@ -180,7 +180,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'; import { computed, onUnmounted, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import { sendParentLoginCode } from '@/api/login'; import { parentMpLogin, sendParentLoginCode } from '@/api/login';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import { useTeacherStore } from '@/state/modules/teacher'; import { useTeacherStore } from '@/state/modules/teacher';
import { setCache, getCache } from '@/utils/cache'; import { setCache, getCache } from '@/utils/cache';
@ -216,8 +216,8 @@ const teacherStore = useTeacherStore();
const isParentLogin = computed(() => role.value === 'parent'); const isParentLogin = computed(() => role.value === 'parent');
type ParentStage = 'authorize' | 'bind'; type ParentStage = 'authorize' | 'bind';
const parentStage = ref<ParentStage>('authorize'); const parentStage = ref<ParentStage>('authorize');
/** mpLogin 返回的微信 openid绑定手机号时传给 smsLogin */
const MOCK_PARENT_WX_BOUND = false; const parentWxOpenid = ref('');
const smsBtnText = computed(() => { const smsBtnText = computed(() => {
if (smsCountdown.value > 0) return `${smsCountdown.value}s`; if (smsCountdown.value > 0) return `${smsCountdown.value}s`;
@ -231,6 +231,7 @@ const switchRole = (value: LoginRole) => {
if (value === 'parent') { if (value === 'parent') {
parentStage.value = 'authorize'; parentStage.value = 'authorize';
parentWxOpenid.value = '';
phone.value = ''; phone.value = '';
smsCode.value = ''; smsCode.value = '';
smsCountdown.value = 0; smsCountdown.value = 0;
@ -334,11 +335,6 @@ const onSendCode = async () => {
} }
}; };
const checkParentBoundByWxCode = async (_code: string): Promise<{ bound: boolean }> => {
await new Promise((resolve) => setTimeout(resolve, 350));
return { bound: MOCK_PARENT_WX_BOUND };
};
const onParentAuthorize = async () => { const onParentAuthorize = async () => {
if (submitting.value) return; if (submitting.value) return;
submitting.value = true; submitting.value = true;
@ -360,16 +356,29 @@ const onParentAuthorize = async () => {
return; return;
} }
const { bound } = await checkParentBoundByWxCode(code); const res = await parentMpLogin(code);
if (bound) { const data = res?.data || {};
setCache('role', 'parent'); const accessToken = data.accessToken;
if (accessToken) {
await userStore.parentMpLoginSuccess(accessToken);
uni.hideLoading(); uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' }); uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => navigateAfterLogin(), 800); setTimeout(() => navigateAfterLogin(), 800);
return; return;
} }
const openid = data.wxUser?.openid || '';
if (!openid) {
uni.hideLoading();
uni.showToast({ title: '获取微信 openid 失败', icon: 'none' });
return;
}
parentWxOpenid.value = openid;
parentStage.value = 'bind'; parentStage.value = 'bind';
if (data.phone) {
phone.value = data.phone;
}
uni.hideLoading(); uni.hideLoading();
uni.showToast({ title: '请先绑定手机号', icon: 'none' }); uni.showToast({ title: '请先绑定手机号', icon: 'none' });
} catch (e: any) { } catch (e: any) {
@ -412,11 +421,20 @@ const onLogin = async () => {
uni.showToast({ title: '请输入验证码', icon: 'none' }); uni.showToast({ title: '请输入验证码', icon: 'none' });
return; return;
} }
if (!parentWxOpenid.value) {
uni.showToast({ title: '请先完成微信授权', icon: 'none' });
parentStage.value = 'authorize';
return;
}
submitting.value = true; submitting.value = true;
uni.showLoading({ title: '登录中...', mask: true }); uni.showLoading({ title: '登录中...', mask: true });
try { try {
await userStore.parentLogin({ mobile, smsCode: code }); await userStore.parentLogin({
phone: mobile,
verifyCode: code,
openid: parentWxOpenid.value,
});
uni.hideLoading(); uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' }); uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => navigateAfterLogin(), 800); setTimeout(() => navigateAfterLogin(), 800);

View File

@ -5,8 +5,8 @@
<view class="account-row"> <view class="account-row">
<view class="account-avatar"></view> <view class="account-avatar"></view>
<view class="account-main"> <view class="account-main">
<text class="phone">138****4546</text> <text class="phone">{{ displayPhone }}</text>
<text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className }}</text> <text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className || '暂无年级' }}</text>
</view> </view>
</view> </view>
</ParentCard> </ParentCard>
@ -14,10 +14,12 @@
<view class="children-card"> <view class="children-card">
<ParentCard title="孩子管理" subtitle="切换后首页与报告将默认展示该孩子"> <ParentCard title="孩子管理" subtitle="切换后首页与报告将默认展示该孩子">
<view class="child-grid"> <view v-if="loading" class="state-text">加载中...</view>
<view v-else-if="!childrenList.length" class="state-text">暂无绑定孩子</view>
<view v-else class="child-grid">
<view <view
v-for="(child, index) in parentChildren" v-for="(child, index) in childrenList"
:key="child.id" :key="child.bindId || child.id"
class="child-card" class="child-card"
:class="{ active: index === activeChildIndex }" :class="{ active: index === activeChildIndex }"
@click="selectChild(index)" @click="selectChild(index)"
@ -47,19 +49,77 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getParentBindChildList, type ParentBindChildItem } from '@/api/parent';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import { parentChildren } from '../utils/data';
interface ChildItem {
bindId: string;
id: string;
name: string;
account: string;
className: string;
}
const userStore = useUserStore(); const userStore = useUserStore();
const loading = ref(false);
const parentPhone = ref('');
const childrenList = ref<ChildItem[]>([]);
const activeChildIndex = ref(0); const activeChildIndex = ref(0);
const activeChild = computed(() => parentChildren[activeChildIndex.value]);
const activeChild = computed(() => childrenList.value[activeChildIndex.value] || {
name: '--',
className: '--',
account: '',
id: '',
bindId: '',
});
const displayPhone = computed(() => maskPhone(parentPhone.value));
const maskPhone = (phone?: string) => {
const value = (phone || '').trim();
if (!value) return '--';
if (value.length < 7) return value;
return `${value.slice(0, 3)}****${value.slice(-4)}`;
};
const mapBindRow = (row: ParentBindChildItem): ChildItem => ({
bindId: String(row.id ?? row.child?.id ?? ''),
id: String(row.child?.id ?? row.id ?? ''),
name: row.child?.name || row.child?.nickname || '未命名',
account: row.child?.account || '',
className: row.child?.gradeName || '',
});
const fetchChildren = async () => {
loading.value = true;
try {
const res = await getParentBindChildList();
const rows = res.data?.rows || [];
childrenList.value = rows.map(mapBindRow);
parentPhone.value = rows.find((item) => item.parent?.phone)?.parent?.phone || '';
if (activeChildIndex.value >= childrenList.value.length) {
activeChildIndex.value = 0;
}
} catch (e: any) {
childrenList.value = [];
uni.showToast({ title: e?.message || '获取孩子列表失败', icon: 'none' });
} finally {
loading.value = false;
}
};
onShow(() => {
fetchChildren();
});
const selectChild = (index: number) => { const selectChild = (index: number) => {
activeChildIndex.value = index; activeChildIndex.value = index;
uni.showToast({ uni.showToast({
title: `已切换到${parentChildren[index].name}`, title: `已切换到${childrenList.value[index]?.name || '孩子'}`,
icon: 'none', icon: 'none',
}); });
}; };
@ -146,6 +206,13 @@ const handleLogout = () => {
gap: 16rpx; gap: 16rpx;
} }
.state-text {
padding: 24rpx 0;
text-align: center;
color: #94a3b8;
font-size: 26rpx;
}
.child-card { .child-card {
padding: 20rpx; padding: 20rpx;
border-radius: 24rpx; border-radius: 24rpx;

View File

@ -90,13 +90,27 @@ export const useUserStore = defineStore('user', () => {
await fetchUserInfo(); await fetchUserInfo();
}; };
/** 家长端 - 保存登录 token */
const saveParentSession = (accessToken: string) => {
if (!accessToken?.trim()) {
throw new Error('登录失败');
}
token.value = accessToken;
setCache('token', accessToken);
setCache('role', 'parent');
};
/** 家长端 - 微信授权已绑定,写入 token */
const parentMpLoginSuccess = async (accessToken: string) => {
await clear();
saveParentSession(accessToken);
};
/** 家长端 - 手机号验证码登录 */ /** 家长端 - 手机号验证码登录 */
const parentLogin = async (data: ParentSmsLoginParams) => { const parentLogin = async (data: ParentSmsLoginParams) => {
await clear(); await clear();
const res = await parentSmsLoginApi(data); const res = await parentSmsLoginApi(data);
token.value = res.data; saveParentSession(res.data);
setCache('token', res.data);
setCache('role', 'parent');
}; };
/** 退出登录 */ /** 退出登录 */
@ -122,6 +136,7 @@ export const useUserStore = defineStore('user', () => {
isLogin, isLogin,
gradeId, gradeId,
accountLogin, accountLogin,
parentMpLoginSuccess,
parentLogin, parentLogin,
fetchUserInfo, fetchUserInfo,
logout, logout,

View File

@ -84,7 +84,8 @@ http.interceptors.response.use(
return response; return response;
} }
if (data.code === 200) { // 兼容不同后端返回规范:部分接口成功码为 0并可能带 success=true
if (data.code === 200 || data.code === 0) {
return data; return data;
} }