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

53
api/doctorAuth.js Normal file
View File

@@ -0,0 +1,53 @@
import { req } from '@/common/js/index.js';
const AUTH_PREFIX = '/newApi/doctor-wx-auth';
function unwrapResult(res) {
if (!res) return null;
if (res.code === 0 && res.result !== undefined) return res.result;
return res.result != null ? res.result : res.data;
}
function doctorAuthRequest(options) {
return req.request({
...options,
url: `${AUTH_PREFIX}${options.url}`,
}).then((res) => {
const data = unwrapResult(res);
const ok = res && res.code === 0;
return { res, data, ok };
});
}
export function sendDoctorWxCode(phone, password) {
return req.request({
url: `${AUTH_PREFIX}/send-verification-code`,
method: 'POST',
data: { phone, password },
header: { isToken: false },
});
}
export function doctorWxLogin(payload) {
return req.request({
url: `${AUTH_PREFIX}/login`,
method: 'POST',
data: payload,
header: { isToken: false },
});
}
export function getDoctorWxSiblingAccounts() {
return doctorAuthRequest({
url: '/sibling-accounts',
method: 'GET',
});
}
export function switchDoctorWxAccount(payload) {
return doctorAuthRequest({
url: '/switch-account',
method: 'POST',
data: payload,
});
}

View File

@@ -21,12 +21,11 @@ export async function requestConfig(ins, options, successHandler = null, failHan
} }
options.url = options.url.replace('/oldApi/', ''); options.url = options.url.replace('/oldApi/', '');
options.url = options.url.replace('/newApi/', ''); options.url = options.url.replace('/newApi/', '');
// base const defaultHeader = { 'Content-Type': 'application/x-www-form-urlencoded' }
ins.header = options.header || ins.header // 每次请求独立合并 header避免登录 isToken:false 污染单例
// config base
let config = { let config = {
url:`${baseUrl}${options.url?options.url:''}`, url:`${baseUrl}${options.url?options.url:''}`,
header:ins.header header: { ...defaultHeader, ...(options.header || {}) }
} }
if(ins.requestInterceptor){ if(ins.requestInterceptor){
let _cg = null let _cg = null

View File

@@ -40,13 +40,26 @@ const reqInterceptor = async (options) => {
options.header = { options.header = {
...options.header ...options.header
} }
const isToken = (options.header || {}).isToken === false const skipToken = options.header.isToken === false
const isClinicAdminReq = (options.header || {})._clinicAdmin === '1' const isClinicAdminReq = options.header._clinicAdmin === '1'
|| (options.url && String(options.url).indexOf('clinic-admin') !== -1) || (options.url && String(options.url).indexOf('clinic-admin') !== -1)
const isDoctorWxAuthReq = options.url && String(options.url).indexOf('doctor-wx-auth') !== -1
// 内部控制位,不得作为 HTTP 头发出
delete options.header.isToken
delete options.header._clinicAdmin
const clinicToken = uni.getStorageSync('clinic_admin_token') const clinicToken = uni.getStorageSync('clinic_admin_token')
const doctorToken = uni.getStorageSync('token') const doctorToken = uni.getStorageSync('token')
if (!isToken) { const loginMode = uni.getStorageSync('loginMode')
if (isClinicAdminReq && clinicToken) { if (!skipToken) {
if (isDoctorWxAuthReq) {
if (loginMode === 'clinic_admin' && clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}`
} else if (doctorToken) {
options.header['authorization'] = `Bearer ${doctorToken}`
} else if (clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}`
}
} else if (isClinicAdminReq && clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}` options.header['authorization'] = `Bearer ${clinicToken}`
} else if (doctorToken) { } else if (doctorToken) {
options.header['authorization'] = `Bearer ${doctorToken}` options.header['authorization'] = `Bearer ${doctorToken}`

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>

View File

@@ -1,473 +1,50 @@
<template> <template>
<view class="container safe-area-inset-bottom"> <view>
<doctor-login ref="doctorLogin" @need-select="openAccountPicker" />
<view class="title"> <account-switch-panel
欢迎登录 ref="accountSwitchPanel"
</view> mode="login"
@confirm="onAccountPicked"
<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="loginMode === 'clinic_admin' ? '请输入手机号' : '请输入手机号'" @input="is_d=false" :placeholder-style="{fontSize:'32rpx'}"
:custom-style="{fontSize:'32rpx',paddingLeft:'60rpx'}" />
</view>
<view class="pwd" v-if="loginMode === 'clinic_admin'">
<d-input height="96" v-model="clinicPassword" type="password" password
borderRadius="96rpx" placeholder="请输入后台登录密码" :placeholder-style="{fontSize:'32rpx'}"
:custom-style="{fontSize:'32rpx',paddingLeft:'32rpx'}" />
</view>
<view class="pwd">
<u-field v-model="smsCode" placeholder="请填写验证码" :placeholder-style="{fontSize:'32rpx'}">
<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>
<!-- :disabled="!form['mobile']||!form['pwd']" -->
<view class="btn">
<u-button :throttle-time="20" shape="circle" @click="register" :ripple="true" :loading="loading">
登录</u-button>
</view>
<view class="register">
<text @click="switchClinicAdmin">{{ loginMode === 'clinic_admin' ? '返回医生/药师登录' : '我是诊所管理员' }}</text>
</view>
<!-- <view class="register">-->
<!-- <text @click="$go('../../subPackages/sub_workbench/workbench_identity')">手机号注册</text>-->
<!-- </view>-->
</view>
</view>
</view> </view>
</template> </template>
<script> <script>
import { import DoctorLogin from '@/components/doctor-login/doctor-login.vue';
login, import AccountSwitchPanel from '@/components/account-switch/account-switch-panel.vue';
register,
agreement,
getUserInfo,
loginOrRe
} from '../../api/all';
import {
sendClinicAdminCode,
clinicAdminLogin,
getClinicAdminMyInfo,
} from '../../api/clinicAdmin';
export default {
data() {
return {
loginMode: 'service_user',
loading: false,
form: {
check: false
},
switchVal: false,
is_d: false,
statusBarHeight: this.$statusBarHeight,
navbarHeight: this.$navbarHeight,
mobile: '',
codeText: '',
smsCode: '',
clinicPassword: '',
code: ''
};
},
onLoad(e) {
// const accountInfo = uni.getAccountInfoSync(); export default {
// console.log('appid', accountInfo.miniProgram.appId); // 小程序 appId components: { DoctorLogin, AccountSwitchPanel },
}, onShow() {
mounted() { const clinicToken = uni.getStorageSync('clinic_admin_token');
const clinicToken = uni.getStorageSync('clinic_admin_token') const token = uni.getStorageSync('token');
if (clinicToken && uni.getStorageSync('loginMode') === 'clinic_admin') { const loginMode = uni.getStorageSync('loginMode');
setTimeout(() => { if (loginMode === 'clinic_admin' && clinicToken) {
uni.reLaunch({ url: '/subPackages/sub_clinic_admin/home/index' }) uni.reLaunch({ url: '/subPackages/sub_clinic_admin/home/index' });
}, 200) return;
return }
} if (token) {
const token = uni.getStorageSync('token') const role = Number(uni.getStorageSync('identity') || uni.getStorageSync('role') || 0);
const info = uni.getStorageSync('loginInfo') if (role === 2) {
if (token&&info) { uni.reLaunch({ url: '/pages/pharmacist/index' });
// 药师 } else {
if (info.role == 2 && info.status > 0 && info!= 3) { uni.reLaunch({ url: '/pages/workbench/index' });
setTimeout(() => {
this.$go('/pages/pharmacist/index', 3)
}, 200)
return
}
// 医生
if (info.role == 1 && info.status > 0 && info.status != 3) {
setTimeout(() => {
this.$go('/pages/workbench/index', 2)
}, 200)
return
}
}
},
methods: {
switchClinicAdmin() {
this.loginMode = this.loginMode === 'clinic_admin' ? 'service_user' : 'clinic_admin'
this.clinicPassword = ''
this.smsCode = ''
},
toLogin() {
uni.login({
provider: 'weixin',
success: (loginRes) => {
this.code = loginRes.code
}
})
},
//登录或者注册接口
register() {
if (!uni.$u.test.mobile(this.form['mobile'])) {
this.$toast('请输入正确的手机号')
return
}
if (!this.smsCode) {
this.$toast('请输入验证码')
return
}
if (!this.form['check']) {
this.$toast('请勾选协议')
return
}
if (this.loginMode === 'clinic_admin') {
if (!this.clinicPassword) {
this.$toast('请输入后台登录密码')
return
}
this.clinicAdminLoginFlow()
return
}
const data = {
store_id: '11001',
mobile: this.form['mobile'],
status: 3,
smsCode: this.smsCode,
code: this.code
}
loginOrRe(data).then(res => {
if (res.errcode != -1) {
this.$toast('登录成功')
// this.$toast(res.data[0])
uni.setStorageSync('token', res.data['token'])
// console.log(res);
uni.setStorageSync('identity', res.data['ServiceUser']['role'])
uni.setStorageSync('logInfo', res.data['ServiceUser'])
// 记录店铺id
uni.setStorageSync('store_id', res.data['StoreDoctor']['store_id'])
// 存储doctor_id或user_id用于WebSocket绑定
if (res.data['ServiceUser'] && res.data['ServiceUser']['id']) {
uni.setStorageSync('doctor_id', res.data['ServiceUser']['id']);
uni.setStorageSync('user_id', res.data['ServiceUser']['id']);
}
// 触发WebSocket初始化登录成功后
uni.$emit('user-login');
const data = {
store_id: uni.getStorageSync('store_id') || res.data['StoreDoctor']['store_id'] || '11001',
}
getUserInfo(data).then(res => {
if (res.errcode != -1) {
uni.setStorageSync('loginInfo', res.data)
uni.setStorageSync('role', res.data.role)
uni.setStorageSync('login_id', res.data['id'])
uni.setStorageSync('login_status', res.data['status'])
}
// 未完善信息
if (res.data.status == 0 ) {
this.$toast('您还没有完善信息,请先完善信息')
// this.$go('../../subPackages/sub_workbench/workbench_upInfo/index')
uni.navigateTo({
url:'../../subPackages/sub_workbench/workbench_upInfo/index'
})
return
}
}).catch(() => {
this.loading = false
this.$toast(res.msg)
})
// 药师
if (res.data.ServiceUser.role == 2 && res.data.ServiceUser.status ==2 ) {
setTimeout(() => {
this.$go('/pages/pharmacist/index', 3)
}, 600)
return
}
// 医生 res.data.ServiceUser.status > 0 && res.data .ServiceUser.status != 3
if (res.data.ServiceUser.role == 1 && res.data.ServiceUser.status==2 ) {
setTimeout(() => {
this.$go('/pages/workbench/index', 2)
}, 600)
return
}
// 医生未通过审核
if (res.data.ServiceUser.role == 1 && res.data.ServiceUser.status == 3) {
// this.$toast(res.data.ServiceUser.reason)
setTimeout(() => {
this.$go('/pages/workbench/examine')
}, 2000)
return
}
// 医生审核中
if (res.data.ServiceUser.role == 1 && res.data.ServiceUser.status == 1) {
this.$toast('您的账号还在审核中')
setTimeout(() => {
this.$go('/pages/workbench/examine')
}, 2000)
return
}
// 药师未通过审核
if (res.data.ServiceUser.role == 2 && res.data.ServiceUser.status == 3) {
// this.$toast(res.data.ServiceUser.reason)
setTimeout(() => {
this.$go('/pages/workbench/examine')
}, 2000)
return
}
// 药师审核中
if (res.data.ServiceUser.role == 2 && res.data.ServiceUser.status == 1) {
this.$toast('您的账号还在审核中')
setTimeout(() => {
this.$go('/pages/workbench/examine')
}, 2000)
return
}
}
if (res.errcode == -1) {
// this.$toast('您还没有账号,请先注册')
// setTimeout(() => {
// this.$go('../../subPackages/sub_workbench/workbench_identity')
// }, 600)
this.$toast(res.msg)
return
}
})
},
checkboxChange(val) {
// console.log(val);
},
codeChange(text) {
this.codeText = text;
},
clinicAdminLoginFlow() {
this.loading = true
clinicAdminLogin(this.form['mobile'], this.clinicPassword, this.smsCode).then(res => {
if (res.code !== 0 || !res.result || !res.result.token) {
this.$toast(res.message || res.msg || '登录失败')
return Promise.reject()
}
uni.setStorageSync('loginMode', 'clinic_admin')
uni.setStorageSync('clinic_admin_token', res.result.token)
return getClinicAdminMyInfo()
}).then((wrap) => {
if (!wrap || !wrap.ok) return
uni.setStorageSync('clinic_admin_user', wrap.data)
this.$toast('登录成功')
setTimeout(() => {
uni.reLaunch({ url: '/subPackages/sub_clinic_admin/home/index' })
}, 400)
}).catch(() => {}).finally(() => {
this.loading = false
})
},
getCode() {
if (this.$refs.uCode.canGetCode) {
uni.showLoading({ title: '正在获取验证码' })
setTimeout(() => {
uni.hideLoading();
if (this.loginMode === 'clinic_admin') {
if (!this.clinicPassword) {
this.$toast('请先输入后台登录密码')
return
}
sendClinicAdminCode(this.form['mobile']).then(res => {
if (res.code === 0 || res.errcode != -1) {
this.$refs.uCode.start();
}
})
return
}
const data = {
store_id: '11001',
mobile: this.form['mobile']
}
register(data).then(res => {
// console.log(res);
if (res.errcode != -1) {
// this.code = res.data.code
uni.setStorageSync('smsCode', res.data['smsCode'])
// if(this.code==res.data.smsCode){
// this.$go('../../subPackages/sub_workbench/workbench_upInfo/index')
// }else{
// this.$u.toast('验证码错误')
// return
// }
}
})
this.$refs.uCode.start();
}, 1000);
} else {
this.$u.toast('倒计时结束后再发送');
}
} }
} }
}; },
methods: {
openAccountPicker({ accounts, defaultAccountId, defaultAccountType }) {
this.$refs.accountSwitchPanel.open(accounts, {
defaultAccountId,
defaultAccountType,
});
},
onAccountPicked(account) {
this.$refs.doctorLogin.onAccountPicked(account);
},
},
};
</script> </script>
<style lang="scss" scoped> <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;
}
.agreement {
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
margin-left: 16rpx;
}
.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;
}
}
.hint {
margin-top: 80rpx;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
}
.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;
}
}
.register {
display: flex;
justify-content: center;
align-items: center;
margin-top: 15px;
text {
color: #999;
font-size: 14px;
cursor: pointer;
word-spacing: 3px;
}
}
</style> </style>

View File

@@ -82,7 +82,7 @@
export default { export default {
components: { components: {
myService, myService,
myPharmacist myPharmacist,
}, },
data() { data() {
return { return {

View File

@@ -10,9 +10,9 @@
</view> </view>
<view class="box" :style="{marginTop: statusBarHeight+navbarHeight + 'px'}"> <view class="box" :style="{marginTop: statusBarHeight+navbarHeight + 'px'}">
<view class="store_" @click="openShow=true"> <view class="store_" @click="openShow=true">
{{doctorInfo.store.store.name || '默认门店'}} {{doctorInfo.store.store.name || '默认门店'}}
<u-icon name="map-fill" color="#fff" size="28" margin-left="12"></u-icon> <u-icon name="map-fill" color="#fff" size="28" margin-left="12"></u-icon>
</view> </view>
<view class="box_info"> <view class="box_info">
<view class="box_info_left"> <view class="box_info_left">
@@ -885,6 +885,7 @@ page {
align-items: center; align-items: center;
font-size: 28rpx; font-size: 28rpx;
color: #FFFFFF; color: #FFFFFF;
padding: 0 24rpx;
margin-bottom: 24rpx; margin-bottom: 24rpx;
.u-icon { .u-icon {

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,7 @@
<!-- 操作面板通栏 --> <!-- 操作面板通栏 -->
<view class="setting-panel"> <view class="setting-panel">
<account-switch-entry />
<view <view
class="setting-item action-item" class="setting-item action-item"
hover-class="item-hover" hover-class="item-hover"
@@ -46,9 +47,10 @@
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '../components/clinic-page-layout.vue'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
export default { export default {
components: { ClinicPageLayout }, components: { ClinicPageLayout, AccountSwitchEntry },
data() { data() {
return { return {
displayName: '', displayName: '',

View File

@@ -1,135 +1,316 @@
<template> <template>
<clinic-page-layout title="药品详情" :show-back="true"> <clinic-page-layout title="药品详情" :show-back="true">
<view v-if="info.id" class="card"> <view v-if="info.id" class="page-container">
<image class="hero" :src="display.imageUrl" mode="aspectFit" /> <!-- 头部信息卡片 -->
<view class="row"><text>药品名称</text><text>{{ display.drugName }}</text></view> <view class="card hero-card">
<view class="row"><text>拼音</text><text>{{ display.pinyin }}</text></view> <view class="image-wrapper">
<view class="row"><text>供货价</text><text>{{ display.buyPrice }}</text></view> <image class="hero" :src="display.imageUrl" mode="aspectFill" />
<view class="row"><text>建议售价</text><text>{{ display.suggestPrice }}</text></view> </view>
<view class="row"><text>售价</text><text>{{ display.price }}</text></view> <view class="main-info">
<view class="row"><text>库存</text><text>{{ display.stock }}</text></view> <text class="drug-name">{{ display.drugName }}</text>
<view class="row"><text>状态</text><text>{{ display.statusText }}</text></view> <text class="pinyin">拼音: {{ display.pinyin_simple || '--' }}</text>
</view> </view>
</view>
<view v-if="info.id" class="btn-group"> <!-- 核心数据卡片 -->
<button class="btn secondary" @click="openPriceEdit">修改售价</button> <view class="card data-card">
<button class="btn primary" @click="toggleStatus"> <view class="row">
{{ display.isOnShelf ? '下架' : '上架' }} <text class="label">状态</text>
</button> <view class="status-tag" :class="display.isOnShelf ? 'tag-on' : 'tag-off'">
</view> {{ display.statusText }}
</view>
</view>
<view class="row">
<text class="label">库存</text>
<text class="value stock-value">{{ display.stock }}</text>
</view>
<price-edit-modal <view class="divider"></view>
:visible="priceModalVisible"
:item="info.id ? info : null" <view class="row">
@close="closePriceEdit" <text class="label">供货价</text>
@confirm="onPriceConfirm" <text class="value">{{ display.buyPrice }}</text>
/> </view>
</clinic-page-layout> <view class="row">
<text class="label">建议售价</text>
<text class="value">{{ display.suggestPrice }}</text>
</view>
<view class="row highlight-row">
<text class="label">当前售价</text>
<text class="value price-large">{{ display.price }}</text>
</view>
</view>
<!-- 底部操作按钮组 -->
<view v-if="info.id" class="action-group">
<view class="btn-large btn-outline" hover-class="btn-hover-opacity" @click="openPriceEdit">
修改售价
</view>
<view
class="btn-large"
:class="display.isOnShelf ? 'btn-danger' : 'btn-primary'"
hover-class="btn-hover-opacity"
@click="toggleStatus"
>
{{ display.isOnShelf ? '下架该药品' : '上架该药品' }}
</view>
</view>
</view>
<price-edit-modal
:visible="priceModalVisible"
:item="info.id ? info : null"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</clinic-page-layout>
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue' import PriceEditModal from './components/PriceEditModal.vue'
import { import {
getWarehouseDetail, getWarehouseDetail,
updateWarehouseStatus, updateWarehouseStatus,
updateWarehousePrice, updateWarehousePrice,
} from '@/api/clinicAdmin.js' } from '@/api/clinicAdmin.js'
import { mapWarehouseRow } from '../common/display.js' import { mapWarehouseRow } from '../common/display.js'
export default { export default {
components: { ClinicPageLayout, PriceEditModal }, components: { ClinicPageLayout, PriceEditModal },
data() { data() {
return { return {
id: 0, id: 0,
info: {}, info: {},
priceModalVisible: false, priceModalVisible: false,
priceSubmitting: false, priceSubmitting: false,
} }
}, },
computed: { computed: {
display() { display() {
return mapWarehouseRow(this.info) return mapWarehouseRow(this.info)
}, },
}, },
onLoad(q) { onLoad(q) {
this.id = Number(q.id || 0) this.id = Number(q.id || 0)
this.load() this.load()
}, },
methods: { methods: {
load() { load() {
getWarehouseDetail(this.id).then(w => { getWarehouseDetail(this.id).then(w => {
if (w.ok) this.info = w.data || {} if (w.ok) this.info = w.data || {}
}) })
}, },
openPriceEdit() { openPriceEdit() {
this.priceModalVisible = true this.priceModalVisible = true
}, },
closePriceEdit() { closePriceEdit() {
this.priceModalVisible = false this.priceModalVisible = false
}, },
onPriceConfirm(price) { onPriceConfirm(price) {
if (this.priceSubmitting) return if (this.priceSubmitting) return
this.priceSubmitting = true this.priceSubmitting = true
updateWarehousePrice({ id: this.id, price }).then(w => { updateWarehousePrice({ id: this.id, price }).then(w => {
if (w.ok) { if (w.ok) {
uni.showToast({ title: '修改成功' }) uni.showToast({ title: '修改成功' })
this.closePriceEdit() this.closePriceEdit()
this.load() this.load()
} }
}).finally(() => { this.priceSubmitting = false }) }).finally(() => { this.priceSubmitting = false })
}, },
toggleStatus() { toggleStatus() {
updateWarehouseStatus(this.id).then(w => { updateWarehouseStatus(this.id).then(w => {
if (w.ok) { if (w.ok) {
uni.showToast({ title: '操作成功' }) uni.showToast({ title: '操作成功' })
this.load() this.load()
} }
}) })
}, },
}, },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 同步了列表页的核心色板 */
$theme-color: #6acdbb;
$theme-color-light: rgba(106, 205, 187, 0.12);
$text-primary: #2c3e50;
$text-secondary: #8a97a3;
$text-light: #b0b8c1;
$bg-color: #f7f9fa;
$border-color: #ebedf0;
$danger-color: #ff6b6b;
$danger-color-light: rgba(255, 107, 107, 0.1);
.page-container {
padding: 24rpx;
padding-bottom: 60rpx;
}
.card { .card {
background: #fff; background: #ffffff;
border-radius: 16rpx; border-radius: 24rpx;
padding: 24rpx; padding: 32rpx;
margin-bottom: 32rpx; margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03); box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
} }
/* 头部卡片特异样式 */
.hero-card {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40rpx;
}
.image-wrapper {
width: 100%;
border-radius: 16rpx;
overflow: hidden;
background: $bg-color;
border: 1rpx solid $border-color;
margin-bottom: 32rpx;
}
.hero { .hero {
width: 100%; width: 100%;
height: 360rpx; height: 400rpx;
margin-bottom: 24rpx; display: block;
border-radius: 12rpx;
background: #f8f8f8;
} }
.main-info {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.drug-name {
font-size: 36rpx;
font-weight: 600;
color: $text-primary;
margin-bottom: 12rpx;
line-height: 1.4;
}
.pinyin {
font-size: 26rpx;
color: $text-secondary;
}
/* 核心数据卡片样式 */
.data-card {
padding: 16rpx 32rpx;
}
.row { .row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 16rpx 0; align-items: center;
font-size: 28rpx; padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
} }
.btn-group {
display: flex; .divider {
flex-direction: column; height: 1rpx;
gap: 24rpx; background-color: $border-color;
margin: 8rpx 0;
} }
.btn {
border-radius: 44rpx; .label {
font-size: 30rpx; font-size: 28rpx;
margin: 0; color: $text-secondary;
} }
.btn.primary {
background: #6acdbb; .value {
color: #fff; font-size: 28rpx;
color: $text-primary;
font-weight: 500;
} }
.btn.secondary {
background: #fff; .stock-value {
color: #6acdbb; font-family: monospace; /* 数字更清晰 */
border: 1rpx solid #6acdbb; font-size: 30rpx;
} }
</style>
.highlight-row {
margin-top: 8rpx;
background: #fafbfa; /* 极浅的背景强调 */
margin: 0 -32rpx;
padding: 32rpx;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
}
.highlight-row .label {
color: $text-primary;
font-weight: 500;
}
.price-large {
font-size: 40rpx;
color: $theme-color;
font-weight: 600;
}
/* 标签样式 */
.status-tag {
font-size: 24rpx;
font-weight: 500;
padding: 6rpx 20rpx;
border-radius: 12rpx;
}
.tag-on {
color: $theme-color;
background: $theme-color-light;
}
.tag-off {
color: $text-secondary;
background: $bg-color;
}
/* 底部操作按钮组 */
.action-group {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 16rpx;
}
.btn-large {
width: 100%;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 500;
transition: opacity 0.2s;
box-sizing: border-box;
}
.btn-primary {
background: $theme-color;
color: #ffffff;
box-shadow: 0 8rpx 24rpx $theme-color-light;
}
.btn-danger {
background: $danger-color;
color: #ffffff;
box-shadow: 0 8rpx 24rpx $danger-color-light;
}
.btn-outline {
background: #ffffff;
color: $theme-color;
border: 2rpx solid $theme-color;
}
.btn-hover-opacity {
opacity: 0.7;
}
</style>

View File

@@ -1,231 +1,383 @@
<template> <template>
<clinic-page-layout :title="pageTitle" :show-back="true"> <clinic-page-layout :title="pageTitle" :show-back="true">
<view class="search"> <!-- 搜索栏优化胶囊式设计增加视觉呼吸感 -->
<input v-model="keyword" placeholder="搜索药品名称" @confirm="reload" /> <view class="search-wrapper">
<text @click="reload">搜索</text> <view class="search-bar">
</view> <input class="search-input" v-model="keyword" placeholder="搜索药品名称" placeholder-class="search-placeholder" @confirm="reload" />
<view class="search-btn" hover-class="btn-hover" @click="reload">搜索</view>
</view>
</view>
<view <view class="list-container">
v-for="item in list" <view
:key="item._wxKey" v-for="item in list"
class="item" :key="item._wxKey"
@click="goDetail(item)" class="item-card"
> hover-class="card-hover"
<image class="thumb" :src="rowDisplay(item).imageUrl" mode="aspectFill" /> @click="goDetail(item)"
<view class="body"> >
<view class="title">{{ rowDisplay(item).drugName }}</view> <!-- 左侧缩略图 -->
<view class="sub">供货 {{ rowDisplay(item).buyPrice }} · 售价 {{ rowDisplay(item).price }}</view> <image class="thumb" :src="rowDisplay(item).imageUrl" mode="aspectFill" />
<view class="sub">库存 {{ rowDisplay(item).stock }}</view>
</view>
<view class="actions" @click.stop>
<view class="tag" :class="rowDisplay(item).isOnShelf ? 'on' : 'off'">{{ rowDisplay(item).statusText }}</view>
<view class="btn-row">
<text class="act-btn" @click="openPriceEdit(item)">改价</text>
<text class="act-btn shelf" @click="toggleStatus(item)">
{{ rowDisplay(item).isOnShelf ? '下架' : '上架' }}
</text>
</view>
</view>
</view>
<view v-if="!loading && !list.length" class="empty">暂无药品</view> <!-- 右侧内容区 -->
<view class="content">
<!-- 顶部标题与状态标签对齐 -->
<view class="header-row">
<text class="title">{{ rowDisplay(item).drugName }}</text>
<view class="status-tag" :class="rowDisplay(item).isOnShelf ? 'tag-on' : 'tag-off'">
{{ rowDisplay(item).statusText }}
</view>
</view>
<price-edit-modal <!-- 中部详细信息 -->
:visible="priceModalVisible" <view class="info-group">
:item="priceEditItem" <text class="info-text">供货 {{ rowDisplay(item).buyPrice }} · 售价 {{ rowDisplay(item).price }}</text>
@close="closePriceEdit" <text class="info-text">库存 {{ rowDisplay(item).stock }}</text>
@confirm="onPriceConfirm" </view>
/>
</clinic-page-layout> <!-- 底部操作按钮横向排列增加点击区域与反馈 -->
<view class="action-row" @click.stop>
<view class="action-btn outline" hover-class="btn-hover-opacity" @click.stop="openPriceEdit(item)">
改价
</view>
<view class="action-btn" :class="rowDisplay(item).isOnShelf ? 'btn-danger' : 'btn-primary'" hover-class="btn-hover-opacity" @click.stop="toggleStatus(item)">
{{ rowDisplay(item).isOnShelf ? '下架' : '上架' }}
</view>
</view>
</view>
</view>
</view>
<!-- 空状态优化 -->
<view v-if="!loading && !list.length" class="empty-state">
<view class="empty-icon"></view>
<text class="empty-text">暂无药品数据</text>
</view>
<price-edit-modal
:visible="priceModalVisible"
:item="priceEditItem"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</clinic-page-layout>
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue' import PriceEditModal from './components/PriceEditModal.vue'
import { import {
getWarehouseList, getWarehouseList,
updateWarehouseStatus, updateWarehouseStatus,
updateWarehousePrice, updateWarehousePrice,
} from '@/api/clinicAdmin.js' } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js' import { withWxKey } from '@/utils/wxListKey.js'
import { mapWarehouseRow } from '../common/display.js' import { mapWarehouseRow } from '../common/display.js'
export default { export default {
components: { ClinicPageLayout, PriceEditModal }, components: { ClinicPageLayout, PriceEditModal },
data() { data() {
return { return {
pageTitle: '仓库管理', pageTitle: '仓库管理',
warehouseType: '', warehouseType: '',
keyword: '', keyword: '',
list: [], list: [],
page: 1, page: 1,
loading: false, loading: false,
priceModalVisible: false, priceModalVisible: false,
priceEditItem: null, priceEditItem: null,
priceSubmitting: false, priceSubmitting: false,
} }
}, },
onLoad(q) { onLoad(q) {
if (q.label) { if (q.label) {
this.pageTitle = decodeURIComponent(q.label) this.pageTitle = decodeURIComponent(q.label)
} }
if (q.type != null && q.type !== '') { if (q.type != null && q.type !== '') {
this.warehouseType = q.type this.warehouseType = q.type
} }
this.reload() this.reload()
}, },
onReachBottom() { onReachBottom() {
this.load(this.page + 1, true) this.load(this.page + 1, true)
}, },
methods: { methods: {
rowDisplay(item) { rowDisplay(item) {
return mapWarehouseRow(item) return mapWarehouseRow(item)
}, },
reload() { reload() {
this.page = 1 this.page = 1
this.load(1, false) this.load(1, false)
}, },
load(page, append) { load(page, append) {
this.loading = true this.loading = true
const params = { page, pageSize: 20 } const params = { page, pageSize: 20 }
if (this.keyword) params.name = this.keyword if (this.keyword) params.name = this.keyword
if (this.warehouseType !== '' && this.warehouseType != null) { if (this.warehouseType !== '' && this.warehouseType != null) {
params.type = this.warehouseType params.type = this.warehouseType
} }
getWarehouseList(params).then(w => { getWarehouseList(params).then(w => {
const items = withWxKey((w.data && w.data.items) || [], 'id', 'wh') const items = withWxKey((w.data && w.data.items) || [], 'id', 'wh')
this.list = append ? this.list.concat(items) : items this.list = append ? this.list.concat(items) : items
this.page = page this.page = page
}).finally(() => { this.loading = false }) }).finally(() => { this.loading = false })
}, },
goDetail(item) { goDetail(item) {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/warehouse/detail?id=' + item.id }) uni.navigateTo({ url: '/subPackages/sub_clinic_admin/warehouse/detail?id=' + item.id })
}, },
openPriceEdit(item) { openPriceEdit(item) {
this.priceEditItem = item this.priceEditItem = item
this.priceModalVisible = true this.priceModalVisible = true
}, },
closePriceEdit() { closePriceEdit() {
this.priceModalVisible = false this.priceModalVisible = false
this.priceEditItem = null this.priceEditItem = null
}, },
onPriceConfirm(price) { onPriceConfirm(price) {
if (!this.priceEditItem || this.priceSubmitting) return if (!this.priceEditItem || this.priceSubmitting) return
this.priceSubmitting = true this.priceSubmitting = true
updateWarehousePrice({ id: this.priceEditItem.id, price }).then(w => { updateWarehousePrice({ id: this.priceEditItem.id, price }).then(w => {
if (w.ok) { if (w.ok) {
uni.showToast({ title: '修改成功' }) uni.showToast({ title: '修改成功' })
this.closePriceEdit() this.closePriceEdit()
this.reload() this.reload()
} }
}).finally(() => { this.priceSubmitting = false }) }).finally(() => { this.priceSubmitting = false })
}, },
toggleStatus(item) { toggleStatus(item) {
const action = item.status === 2 ? '下架' : '上架' const action = item.status === 2 ? '下架' : '上架'
uni.showModal({ uni.showModal({
title: '提示', title: '提示',
content: '确定' + action + '「' + ((item.drug && item.drug.drug_name) || '') + '」?', content: '确定' + action + '「' + ((item.drug && item.drug.drug_name) || '') + '」?',
success: (res) => { success: (res) => {
if (!res.confirm) return if (!res.confirm) return
updateWarehouseStatus(item.id).then(w => { updateWarehouseStatus(item.id).then(w => {
if (w.ok) { if (w.ok) {
uni.showToast({ title: '操作成功' }) uni.showToast({ title: '操作成功' })
item.status = item.status === 2 ? 1 : 2 item.status = item.status === 2 ? 1 : 2
} }
}) })
}, },
}) })
}, },
}, },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.search { /* 核心主题色变量提取,方便后续统一管理 */
display: flex; $theme-color: #6acdbb;
background: #fff; $theme-color-light: rgba(106, 205, 187, 0.12);
padding: 16rpx 24rpx; $text-primary: #2c3e50;
border-radius: 16rpx; $text-secondary: #8a97a3;
margin-bottom: 24rpx; $text-light: #b0b8c1;
align-items: center; $bg-color: #f7f9fa;
$border-color: #ebedf0;
$danger-color: #ff6b6b;
$danger-color-light: rgba(255, 107, 107, 0.1);
.search-wrapper {
padding: 16rpx 24rpx;
background-color: transparent;
position: sticky;
top: 0;
z-index: 10;
} }
.search input {
flex: 1; .search-bar {
font-size: 28rpx; display: flex;
align-items: center;
background: #ffffff;
padding: 12rpx 16rpx 12rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
} }
.search text {
color: #6acdbb; .search-input {
font-size: 28rpx; flex: 1;
margin-left: 16rpx; font-size: 28rpx;
color: $text-primary;
height: 60rpx;
} }
.item {
display: flex; .search-placeholder {
align-items: flex-start; color: $text-light;
background: #fff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
} }
.search-btn {
background: $theme-color;
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
padding: 12rpx 36rpx;
border-radius: 30rpx;
margin-left: 16rpx;
transition: all 0.2s;
}
.list-container {
padding: 0 24rpx 24rpx;
}
.item-card {
display: flex;
background: #ffffff;
padding: 24rpx;
border-radius: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
transition: transform 0.2s, box-shadow 0.2s;
}
.card-hover {
transform: scale(0.99);
background-color: #fafafa;
}
.thumb { .thumb {
width: 100rpx; width: 140rpx;
height: 100rpx; height: 140rpx;
border-radius: 12rpx; border-radius: 16rpx;
flex-shrink: 0; flex-shrink: 0;
background: #f5f5f5; background: $bg-color;
border: 1rpx solid $border-color;
} }
.body {
flex: 1; .content {
margin-left: 20rpx; flex: 1;
min-width: 0; margin-left: 24rpx;
padding-right: 12rpx; min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8rpx;
}
.title { .title {
font-size: 28rpx; font-size: 30rpx;
color: #333; color: $text-primary;
font-weight: 600;
line-height: 1.4;
flex: 1;
margin-right: 16rpx;
/* 单行截断 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.sub {
font-size: 24rpx; .status-tag {
color: #999; font-size: 20rpx;
margin-top: 8rpx; font-weight: 500;
padding: 4rpx 14rpx;
border-radius: 8rpx;
flex-shrink: 0;
line-height: 1.5;
} }
.actions {
flex-shrink: 0; .tag-on {
display: flex; color: $theme-color;
flex-direction: column; background: $theme-color-light;
align-items: flex-end;
gap: 12rpx;
} }
.tag {
font-size: 22rpx; .tag-off {
padding: 4rpx 12rpx; color: $text-secondary;
border-radius: 8rpx; background: $bg-color;
} }
.tag.on {
color: #6acdbb; .info-group {
background: rgba(106, 205, 187, 0.12); display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 16rpx;
} }
.tag.off {
color: #999; .info-text {
background: #f5f5f5; font-size: 24rpx;
color: $text-secondary;
} }
.btn-row {
display: flex; .action-row {
flex-direction: column; display: flex;
gap: 8rpx; justify-content: flex-end;
align-items: flex-end; align-items: center;
gap: 16rpx;
margin-top: auto;
} }
.act-btn {
font-size: 24rpx; .action-btn {
color: #6acdbb; font-size: 24rpx;
padding: 4rpx 0; padding: 10rpx 28rpx;
border-radius: 12rpx;
font-weight: 500;
line-height: 1.5;
transition: opacity 0.2s;
} }
.act-btn.shelf {
color: #666; .action-btn.outline {
color: $text-primary;
background: #ffffff;
border: 1rpx solid $border-color;
} }
.empty {
text-align: center; .action-btn.btn-primary {
color: #999; color: #ffffff;
padding: 60rpx; background: $theme-color;
border: 1rpx solid $theme-color;
} }
</style>
.action-btn.btn-danger {
color: $danger-color;
background: $danger-color-light;
border: 1rpx solid transparent;
}
.btn-hover {
opacity: 0.8;
}
.btn-hover-opacity {
opacity: 0.6;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
background-color: $bg-color;
border-radius: 50%;
margin-bottom: 30rpx;
position: relative;
}
/* 用伪元素做一个简单的空状态图形占位有条件可替换为真实的Image/SVG */
.empty-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80rpx;
height: 80rpx;
border: 8rpx solid $border-color;
border-radius: 16rpx;
}
.empty-text {
color: $text-light;
font-size: 28rpx;
}
</style>

View File

@@ -36,6 +36,10 @@
</view> --> </view> -->
</view> </view>
<view class="p-row-32 m-t-8 white b-r-8">
<account-switch-entry />
</view>
<view class="p-row-32 m-t-8" style="height: 100rpx;"> <view class="p-row-32 m-t-8" style="height: 100rpx;">
<u-button :throttle-time="0" shape="circle" :custom-style="{backgroundColor:'#6ACDBB',color:'#fff',heigth:'96rpx'}" @click="logout"> <u-button :throttle-time="0" shape="circle" :custom-style="{backgroundColor:'#6ACDBB',color:'#fff',heigth:'96rpx'}" @click="logout">
退出登录</u-button> 退出登录</u-button>
@@ -49,8 +53,10 @@
logout, logout,
toUpload, toUpload,
} from '@/api/all.js' } from '@/api/all.js'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
import { chooseAvatarImage } from '@/common/js/select-avatar-image.js'; import { chooseAvatarImage } from '@/common/js/select-avatar-image.js';
export default { export default {
components: { AccountSwitchEntry },
data() { data() {
return { return {
doctorInfo: [] doctorInfo: []

View File

@@ -22,6 +22,9 @@
40056256325 40056256325
</view> </view>
</view> </view>
<view class="p-row-32 m-t-8 white">
<account-switch-entry />
</view>
<view class="p-row-32 m-t-8" style="height: 80rpx;"> <view class="p-row-32 m-t-8" style="height: 80rpx;">
<u-button :throttle-time="0" shape="circle" :custom-style="{backgroundColor:'#6ACDBB',color:'#fff',heigth:'86rpx'}" @click="logout"> <u-button :throttle-time="0" shape="circle" :custom-style="{backgroundColor:'#6ACDBB',color:'#fff',heigth:'86rpx'}" @click="logout">
退出登录</u-button> 退出登录</u-button>
@@ -33,7 +36,9 @@
import { import {
logout logout
} from '@/api/all.js' } from '@/api/all.js'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
export default { export default {
components: { AccountSwitchEntry },
data() { data() {
return {} return {}
}, },

116
utils/loginSession.js Normal file
View File

@@ -0,0 +1,116 @@
import { getClinicAdminMyInfo } from '@/api/clinicAdmin.js';
import { getUserInfo } from '@/api/all.js';
function applyServiceUserProfile(user) {
if (!user || !user.id) return;
uni.setStorageSync('loginInfo', user);
uni.setStorageSync('role', user.role);
uni.setStorageSync('login_id', user.id);
uni.setStorageSync('login_status', user.status);
}
export function saveLoginSession(result) {
if (!result) return;
const token = result.token ? String(result.token).trim() : '';
if (result.account_type === 'admin') {
uni.setStorageSync('loginMode', 'clinic_admin');
uni.setStorageSync('clinic_admin_token', token);
uni.setStorageSync('clinic_admin_user', result.user || {});
uni.removeStorageSync('token');
return;
}
uni.removeStorageSync('loginMode');
uni.removeStorageSync('clinic_admin_token');
uni.removeStorageSync('clinic_admin_user');
uni.setStorageSync('token', token);
const user = result.user || {};
const storeDoctor = result.store_doctor || {};
uni.setStorageSync('identity', user.role);
uni.setStorageSync('logInfo', user);
uni.setStorageSync('store_id', storeDoctor.store_id || '11001');
if (user.id) {
uni.setStorageSync('doctor_id', user.id);
uni.setStorageSync('user_id', user.id);
}
}
export async function fetchProfileAfterLogin(result) {
if (result.account_type === 'admin') {
const wrap = await getClinicAdminMyInfo();
if (wrap && wrap.ok) {
uni.setStorageSync('clinic_admin_user', wrap.data);
}
return;
}
if (result.user && result.user.id) {
applyServiceUserProfile(result.user);
return;
}
const data = {
store_id: uni.getStorageSync('store_id') || '11001',
};
const res = await getUserInfo(data);