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 = https://ai.xuexiaole.com
VITE_BASE_URL = http://43.136.52.196:9053
# VITE_BASE_URL = https://ai.lingxixue.com
# 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/

View File

@ -23,32 +23,61 @@ export const psdLogin = (data: AccountLoginParams) =>
});
export interface ParentSmsLoginParams {
mobile: string;
smsCode: string;
phone: string;
verifyCode: string;
openid: string;
}
/** 家长端 - 发送登录验证码 */
export const sendParentLoginCode = (mobile: string) =>
request({
url: '/parent/sendLoginCode',
export interface ParentMpLoginResp {
accessToken?: string | null;
account?: string | null;
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',
headers: { Authorization: '' },
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) =>
request<string>({
url: '/parent/loginBySms',
url: '/smsLogin',
method: 'POST',
headers: { Authorization: '' },
headers: {
Authorization: '',
'X-Tenant-Id': '1966391196339204098',
},
data: {
mobile: data.mobile,
smsCode: data.smsCode,
clientType: 'PARENT',
simSerialNumber: 'miniProgram',
model: 'wechat',
bindMaOpenid: true,
adminType: 16,
openid: data.openid,
phone: data.phone,
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" : {},
/* */
"mp-weixin" : {
"appid" : "wxa5522671f30c5891",
"appid" : "wxab166c9b5b6401ea",
"setting" : {
"urlCheck" : false,
"minified" : true,

View File

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

View File

@ -5,8 +5,8 @@
<view class="account-row">
<view class="account-avatar"></view>
<view class="account-main">
<text class="phone">138****4546</text>
<text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className }}</text>
<text class="phone">{{ displayPhone }}</text>
<text class="current-child">当前孩子{{ activeChild.name }} · {{ activeChild.className || '暂无年级' }}</text>
</view>
</view>
</ParentCard>
@ -14,10 +14,12 @@
<view class="children-card">
<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
v-for="(child, index) in parentChildren"
:key="child.id"
v-for="(child, index) in childrenList"
:key="child.bindId || child.id"
class="child-card"
:class="{ active: index === activeChildIndex }"
@click="selectChild(index)"
@ -47,19 +49,77 @@
<script setup lang="ts">
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 ParentCard from '../components/ParentCard.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 loading = ref(false);
const parentPhone = ref('');
const childrenList = ref<ChildItem[]>([]);
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) => {
activeChildIndex.value = index;
uni.showToast({
title: `已切换到${parentChildren[index].name}`,
title: `已切换到${childrenList.value[index]?.name || '孩子'}`,
icon: 'none',
});
};
@ -146,6 +206,13 @@ const handleLogout = () => {
gap: 16rpx;
}
.state-text {
padding: 24rpx 0;
text-align: center;
color: #94a3b8;
font-size: 26rpx;
}
.child-card {
padding: 20rpx;
border-radius: 24rpx;

View File

@ -90,13 +90,27 @@ export const useUserStore = defineStore('user', () => {
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) => {
await clear();
const res = await parentSmsLoginApi(data);
token.value = res.data;
setCache('token', res.data);
setCache('role', 'parent');
saveParentSession(res.data);
};
/** 退出登录 */
@ -122,6 +136,7 @@ export const useUserStore = defineStore('user', () => {
isLogin,
gradeId,
accountLogin,
parentMpLoginSuccess,
parentLogin,
fetchUserInfo,
logout,

View File

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