1. 更新切换账号逻辑

2. 诊所管理员功能上线
This commit is contained in:
李琦
2026-05-30 16:36:33 +08:00
parent 27202c0740
commit 31551437c2
16 changed files with 1820 additions and 792 deletions

View File

@@ -0,0 +1,84 @@
<template>
<view v-if="canSwitch">
<view
class="switch-row"
hover-class="switch-row-hover"
:hover-stay-time="100"
@tap.stop="openSwitch"
>
<text class="switch-label">切换账号</text>
<u-icon name="arrow-right" color="#C9CDD4" size="28" />
</view>
<account-switch-panel
ref="accountSwitchPanel"
mode="switch"
@switched="onSwitched"
/>
</view>
</template>
<script>
import AccountSwitchPanel from '@/components/account-switch/account-switch-panel.vue';
import { getDoctorWxSiblingAccounts } from '@/api/doctorAuth.js';
import {
saveLoginSession,
fetchProfileAfterLogin,
navigateAfterLogin,
} from '@/utils/loginSession.js';
export default {
name: 'AccountSwitchEntry',
components: { AccountSwitchPanel },
data() {
return {
canSwitch: false,
};
},
mounted() {
this.checkAccounts();
},
methods: {
checkAccounts() {
getDoctorWxSiblingAccounts()
.then((wrap) => {
if (wrap && wrap.ok) {
this.canSwitch = (wrap.data || []).length > 1;
}
})
.catch(() => {
this.canSwitch = false;
});
},
openSwitch() {
this.$refs.accountSwitchPanel.open();
},
async onSwitched(result) {
saveLoginSession(result);
await fetchProfileAfterLogin(result);
this.$toast('切换成功');
setTimeout(() => {
navigateAfterLogin(result);
}, 400);
},
},
};
</script>
<style lang="scss" scoped>
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 36rpx 0;
border-bottom: 1rpx solid #f2f3f5;
}
.switch-row-hover {
background: #f7f8fa;
}
.switch-label {
font-size: 30rpx;
color: #31353d;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<view>
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="mode === 'switch'"
:show="visible"
:overlay="false"
@leave="handlePageLeave"
/>
<!-- #endif -->
<u-popup
v-model="visible"
mode="bottom"
length="80%"
border-radius="24"
z-index="10078"
:closeable="true"
:mask-close-able="true"
:safe-area-inset-bottom="true"
@close="handleCancel"
>
<view class="sheet-inner white">
<view class="sheet-title">{{ mode === 'switch' ? '切换账号' : '选择登录账号' }}</view>
<scroll-view scroll-y class="account-scroll">
<view
v-for="(item, index) in accounts"
:key="item._rowKey"
class="account-item"
:class="{ active: isSelected(item) }"
hover-class="account-item-hover"
@tap.stop="selectAccount(item)"
>
<view class="name">{{ item.nick_name }}</view>
<view class="meta">
{{ item.role_name }}<text v-if="item.store_name"> · {{ item.store_name }}</text><text v-if="item.login_account"> · {{ item.login_account }}</text>
</view>
<view v-if="item.is_current" class="current-tag">当前</view>
</view>
</scroll-view>
<view class="actions">
<button class="btn cancel" @tap.stop="handleCancel">取消</button>
<button class="btn confirm" :loading="loading" @tap.stop="handleConfirm">确认</button>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import { switchDoctorWxAccount, getDoctorWxSiblingAccounts } from '@/api/doctorAuth.js';
export default {
name: 'AccountSwitchPanel',
props: {
mode: {
type: String,
default: 'login',
},
phone: {
type: String,
default: '',
},
},
data() {
return {
visible: false,
accounts: [],
selected: null,
loading: false,
defaultAccountId: null,
defaultAccountType: null,
};
},
methods: {
normalizeAccounts(accounts) {
return (accounts || []).map((item, index) => ({
...item,
_rowKey: `${item.account_type}_${item.id}_${index}`,
}));
},
resolveDefaultSelection(accounts, options = {}) {
const list = accounts || [];
if (!list.length) {
return null;
}
if (this.mode === 'switch') {
return list.find((item) => item.is_current) || list[0];
}
const defaultId = options.defaultAccountId ?? this.defaultAccountId;
const defaultType = options.defaultAccountType ?? this.defaultAccountType;
if (defaultId != null && defaultType) {
const matched = list.find(
(item) => item.account_type === defaultType && Number(item.id) === Number(defaultId)
);
if (matched) {
return matched;
}
}
return list.find((item) => item.password_matched) || list[0];
},
showPanel() {
this.$nextTick(() => {
this.visible = true;
});
},
open(accounts = null, options = {}) {
this.defaultAccountId = options.defaultAccountId ?? null;
this.defaultAccountType = options.defaultAccountType ?? null;
if (Array.isArray(accounts)) {
this.accounts = this.normalizeAccounts(accounts);
this.selected = this.resolveDefaultSelection(this.accounts, options);
this.showPanel();
return Promise.resolve();
}
return getDoctorWxSiblingAccounts().then((wrap) => {
if (!wrap || !wrap.ok) {
return Promise.reject(new Error('获取账号列表失败'));
}
this.accounts = this.normalizeAccounts(wrap.data || []);
this.selected = this.resolveDefaultSelection(this.accounts, options);
this.showPanel();
});
},
close() {
this.visible = false;
},
handlePageLeave() {
this.close();
},
isSelected(item) {
return this.selected && this.selected._rowKey === item._rowKey;
},
selectAccount(item) {
this.selected = item;
},
handleCancel() {
this.close();
this.$emit('cancel');
},
handleConfirm() {
if (!this.selected) {
this.$toast('请选择账号');
return;
}
if (this.mode === 'switch') {
if (this.selected.is_current) {
this.$toast('已是当前账号');
return;
}
this.submitSwitch();
return;
}
this.$emit('confirm', this.selected);
this.close();
},
resolveSwitchError(wrap, err) {
return wrap?.res?.msg
|| wrap?.res?.message
|| err?.msg
|| err?.message
|| '切换失败';
},
submitSwitch() {
this.loading = true;
switchDoctorWxAccount({
account_type: this.selected.account_type,
account_id: this.selected.id,
}).then((wrap) => {
if (!wrap || !wrap.ok || !wrap.data || !wrap.data.token) {
return Promise.reject({
message: this.resolveSwitchError(wrap),
wrap,
});
}
this.$emit('switched', wrap.data);
this.close();
}).catch((err) => {
const message = this.resolveSwitchError(err?.wrap, err);
uni.showToast({
title: message,
icon: 'none',
duration: 2500,
});
}).finally(() => {
this.loading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.sheet-inner {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.sheet-title {
font-size: 34rpx;
font-weight: 600;
margin-bottom: 24rpx;
flex-shrink: 0;
color: #222b2a;
}
.account-scroll {
flex: 1;
width: 100%;
min-height: 0;
}
.account-item {
position: relative;
padding: 24rpx;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.account-item.active {
border-color: #6acdbb;
background: #f0fbf8;
}
.account-item-hover {
opacity: 0.85;
}
.name {
font-size: 30rpx;
font-weight: 600;
padding-right: 80rpx;
color: #222b2a;
}
.meta {
margin-top: 8rpx;
font-size: 24rpx;
color: #6b7280;
}
.current-tag {
position: absolute;
right: 24rpx;
top: 24rpx;
font-size: 22rpx;
color: #6acdbb;
}
.actions {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
flex-shrink: 0;
}
.btn {
flex: 1;
}
.confirm {
background: #6acdbb;
color: #fff;
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<view class="container safe-area-inset-bottom">
<view class="title">欢迎登录</view>
<view class="box">
<view class="info">
<view class="pwd">
<d-input
:maxlength="11"
height="96"
v-model="form.mobile"
:prefixIcon="require('../../static/image/act.png')"
borderRadius="96rpx"
prefixIconSize="40rpx"
placeholder="请输入手机号"
:placeholder-style="{ fontSize: '32rpx' }"
:custom-style="{ fontSize: '32rpx', paddingLeft: '60rpx' }"
/>
</view>
<view class="pwd">
<d-input
height="96"
v-model="form.password"
type="password"
:prefixIcon="require('../../static/image/pwd.png')"
password
borderRadius="96rpx"
prefixIconSize="40rpx"
placeholder="请输入登录密码"
:placeholder-style="{ fontSize: '32rpx' }"
:custom-style="{ fontSize: '32rpx', paddingLeft: '60rpx' }"
/>
</view>
<view class="pwd">
<u-field v-model="smsCode"
placeholder="请填写验证码"
:prefixIcon="require('../../static/image/pwd.png')"
:placeholder-style="{ fontSize: '32rpx', paddingLeft: '60rpx' }"
:custom-style="{ fontSize: '32rpx', paddingLeft: '60rpx' }"
>
<template slot="icon">
<!-- <image src="../../../../static/image/pwd.png"></image>-->
<u-icon
size="40"
name="chat"
style="margin-left: 15rpx"
@click="form.check = !form.check"
></u-icon>
</template>
<u-button size="mini" slot="right" @click="getCode">{{ codeText }}</u-button>
</u-field>
<u-verification-code ref="uCode" @change="codeChange" class="code"></u-verification-code>
</view>
<view class="flex-row" style="margin-top: 88rpx;">
<u-icon
size="35"
:color="form.check ? '#6ACDBB' : '#e0fdff'"
name="checkmark-circle-fill"
@click="form.check = !form.check"
></u-icon>
<view class="agreement">
我已阅读并同意
<text @click="$go('/subPackages/sub_agreement/agreement?type=service_user_agreement')">用户服务协议</text>
<text @click="$go('/subPackages/sub_agreement/agreement?type=service_privacy_agreement')">隐私权政策</text>
</view>
</view>
<view class="btn">
<u-button :throttle-time="20" shape="circle" @click="submitLogin" :ripple="true" :loading="loading">
登录
</u-button>
</view>
</view>
</view>
</view>
</template>
<script>
import { sendDoctorWxCode, doctorWxLogin } from '@/api/doctorAuth.js';
import {
saveLoginSession,
fetchProfileAfterLogin,
navigateAfterLogin,
} from '@/utils/loginSession.js';
export default {
name: 'DoctorLogin',
data() {
return {
form: {
mobile: '',
password: '',
check: false,
},
smsCode: '',
codeText: '',
loading: false,
pendingLoginPayload: null,
};
},
methods: {
codeChange(text) {
this.codeText = text;
},
getCode() {
if (!this.$refs.uCode.canGetCode) {
this.$u.toast('倒计时结束后再发送');
return;
}
if (!this.form.mobile) {
this.$toast('请输入手机号');
return;
}
if (!this.form.password) {
this.$toast('请输入密码');
return;
}
uni.showLoading({ title: '正在获取验证码' });
sendDoctorWxCode(this.form.mobile, this.form.password)
.then((res) => {
if (res.code === 0) {
this.$refs.uCode.start();
if (res.result && res.result.code) {
this.smsCode = res.result.code;
}
}
})
.finally(() => {
uni.hideLoading();
});
},
submitLogin() {
if (!this.form.mobile) {
this.$toast('请输入手机号');
return;
}
if (!this.smsCode) {
this.$toast('请输入验证码');
return;
}
if (!this.form.check) {
this.$toast('请勾选协议');
return;
}
this.doLogin({});
},
doLogin(extra = {}) {
this.loading = true;
const payload = {
phone: this.form.mobile,
password: this.form.password,
code: this.smsCode,
...extra,
};
doctorWxLogin(payload)
.then((res) => {
if (res.code !== 0 || !res.result) {
return Promise.reject(new Error(res.message || res.msg || '登录失败'));
}
const result = res.result;
if (result.need_select && result.accounts && result.accounts.length) {
this.pendingLoginPayload = payload;
this.$emit('need-select', {
accounts: result.accounts,
defaultAccountId: result.default_account_id,
defaultAccountType: result.default_account_type,
});
return null;
}
return this.finishLogin(result);
})
.catch((err) => {
this.$toast(err.message || '登录失败');
})
.finally(() => {
this.loading = false;
});
},
onAccountPicked(account) {
this.doLogin({
account_type: account.account_type,
account_id: account.id,
});
},
async finishLogin(result) {
saveLoginSession(result);
await fetchProfileAfterLogin(result);
this.$toast('登录成功');
if (result.account_type !== 'admin') {
uni.$emit('user-login');
}
setTimeout(() => {
navigateAfterLogin(result);
}, 400);
},
},
};
</script>
<style lang="scss" scoped>
.container {
width: 100vw;
height: 100vh;
padding-bottom: env(safe-area-inset-bottom);
}
.title {
width: 750rpx;
padding-top: 200rpx;
text-align: center;
font-size: 48rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #0D111A;
background: linear-gradient(180deg, #EAF2FF 0%, #FFFFFF 100%);
margin-bottom: 60rpx;
}
.flex-row {
display: flex;
flex-direction: row;
margin-bottom: 34rpx;
.agreement {
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
margin-left: 16rpx;
text {
color: #2979FF;
size: 24rpx;
}
}
}
.box {
box-sizing: border-box;
padding: 0 52rpx;
}
.code {
font-size: 32rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #1777FF;
background: #F9FAFB;
}
.pwd {
width: 646rpx;
height: 96rpx;
background: #F9FAFB;
border-radius: 48rpx 48rpx 48rpx 48rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #CCCCCC;
margin: 0rpx 0 30rpx;
::v-deep button {
color: #1777FF;
border: 0;
}
}
.btn {
::v-deep button {
width: 648rpx;
height: 100rpx;
line-height: 100rpx;
background: #6ACDBB;
border: 0;
font-size: 32rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #FFFFFF;
}
}
</style>