优化功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
李琦
2026-01-22 13:46:35 +08:00
parent 721c5ca610
commit e33f2f1edc
9 changed files with 544 additions and 123 deletions

View File

@@ -3,9 +3,13 @@ import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
login_method?: 'phone' | 'login_account' | 'job_number'; // 登录方式类型
password?: string;
username?: string;
phone?: string; // 手机号当login_method为phone时
code?: string;
login_account?: string; // 登录账号当login_method为login_account时
job_number?: string; // 工号当login_method为job_number时
}
/** 登录接口返回值 */
@@ -17,25 +21,62 @@ export namespace AuthApi {
data: string;
status: number;
}
/** 发送验证码返回结果 */
export interface SendCodeResult {
code?: string; // 开发环境返回验证码
accounts?: Array<{
id: number;
login_account: string;
job_number: string;
}>; // 如果手机号绑定多个账号,返回账号列表
need_select?: boolean; // 是否需要选择账号/工号
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('login', {
phone: data.username,
const params: any = {
login_method: data.login_method || 'phone', // 添加默认值
password: data.password,
code: data.code,
});
};
if (data.login_method === 'phone') {
params.phone = data.phone || data.username;
} else if (data.login_method === 'login_account') {
params.login_account = data.login_account;
} else if (data.login_method === 'job_number') {
params.job_number = data.job_number;
}
return requestClient.post<AuthApi.LoginResult>('login', params);
}
/**
* 发送验证码
*/
export async function sendVerificationCode(username: string) {
return requestClient.post('send-verification-code', {
username,
});
export async function sendVerificationCode(params: {
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
login_account?: string;
job_number?: string;
}) {
const requestParams: any = {
login_method: params.login_method || 'phone',
};
if (params.login_method === 'phone') {
requestParams.phone = params.phone || params.username;
} else if (params.login_method === 'login_account') {
requestParams.login_account = params.login_account;
} else if (params.login_method === 'job_number') {
requestParams.job_number = params.job_number;
}
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', requestParams);
}
/**

View File

@@ -1,16 +1,15 @@
<script lang="ts" setup>
import type { BasicOption } from '@vben/types';
import type { Recordable } from '@vben/types';
import { computed, markRaw, ref, useTemplateRef } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { AuthenticationCodeLogin, type VbenFormSchema } from '@vben/common-ui';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { AuthenticationLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { sendVerificationCode } from '#/api';
import { useAuthStore } from '#/store';
import {message} from "ant-design-vue";
import {useRouter} from "vue-router";
import { message } from 'ant-design-vue';
defineOptions({ name: 'Login' });
@@ -20,58 +19,52 @@ const authStore = useAuthStore();
const CODE_LENGTH = 6;
const loading = ref(false);
const MOCK_USER_OPTIONS: BasicOption[] = [
{
label: 'Super',
value: 'vben',
},
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'jack',
},
];
const loginMethod = ref<'phone' | 'login_account' | 'job_number'>('phone');
const needSelectAccount = ref(false);
const accountList = ref<Array<{ id: number; login_account: string; job_number: string }>>([]);
const formSchema = computed((): VbenFormSchema[] => {
return [
/* {
component: 'VbenSelect',
componentProps: {
options: MOCK_USER_OPTIONS,
placeholder: $t('authentication.selectAccount'),
},
fieldName: 'selectAccount',
label: $t('authentication.selectAccount'),
rules: z
.string()
.min(1, { message: $t('authentication.selectAccount') })
.optional()
.default('vben'),
},*/
const schema: VbenFormSchema[] = [
// 1. Radio选择组最上面
{
component: 'RadioGroup',
componentProps: {
optionType: 'button',
options: [
{ label: '手机号登录', value: 'phone' },
{ label: '登录账号登录', value: 'login_account' },
{ label: '工号登录', value: 'job_number' },
],
},
fieldName: 'login_method',
label: '登录方式',
rules: z.string().min(1, { message: '请选择登录方式' }),
defaultValue: 'phone',
dependencies: {
trigger(values, form) {
loginMethod.value = values.login_method;
// 切换登录方式时清空其他字段
if (values.login_method === 'phone') {
form.setValues({ login_account: '', job_number: '' });
} else if (values.login_method === 'login_account') {
form.setValues({ username: '', job_number: '' });
} else if (values.login_method === 'job_number') {
form.setValues({ username: '', login_account: '' });
}
},
triggerFields: ['login_method'],
},
},
];
// 2. 根据选择动态显示输入框
const loginMethodValue = loginMethod.value;
if (loginMethodValue === 'phone') {
schema.push({
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
dependencies: {
trigger(values, form) {
if (values.selectAccount) {
const findUser = MOCK_USER_OPTIONS.find(
(item) => item.value === values.selectAccount,
);
if (findUser) {
form.setValues({
password: 'qiqi991012',
username: '15100000000',
});
}
}
},
triggerFields: ['selectAccount'],
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z
@@ -80,72 +73,188 @@ const formSchema = computed((): VbenFormSchema[] => {
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
}),
},
{
component: 'VbenInputPassword',
});
} else if (loginMethodValue === 'login_account') {
schema.push({
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.password'),
placeholder: '请输入登录账号',
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
/* .regex(
/^(?=.*[A-Z0-9])(?=.*[!@#$%^&*])[A-Z0-9!@#$%^&*]{5,18}$/i,
'密码由5-18位数字、字母、特殊字符组成。',
)*/
},
{
component: 'VbenPinInput',
fieldName: 'login_account',
label: '登录账号',
rules: z.string().min(1, { message: '请输入登录账号' }),
});
} else if (loginMethodValue === 'job_number') {
schema.push({
component: 'VbenInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
return countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
},
placeholder: $t('authentication.code'),
handleSendCode: async () => {
// 模拟发送验证码
// Simulate sending verification code
loading.value = true;
const formApi = loginRef.value?.getFormApi();
if (!formApi) {
loading.value = false;
throw new Error('formApi is not ready');
}
await formApi.validateField('username');
const isPhoneReady = await formApi.isFieldValid('username');
if (!isPhoneReady) {
loading.value = false;
throw new Error('Phone number is not Ready');
}
const password = await formApi.isFieldValid('password');
if (!password) {
loading.value = false;
throw new Error('密码不符合要求 number is not Ready');
}
const { username } = await formApi.getValues();
// 验证username的手机号格式
await sendVerificationCode(username);
placeholder: '请输入工号',
},
fieldName: 'job_number',
label: '工号',
rules: z.string().min(1, { message: '请输入工号' }),
});
}
// 3. 密码输入框(常驻)
schema.push({
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
});
// 4. 验证码输入框(常驻)
schema.push({
component: 'VbenPinInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
return countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
},
placeholder: $t('authentication.code'),
handleSendCode: async () => {
loading.value = true;
const formApi = loginRef.value?.getFormApi();
if (!formApi) {
loading.value = false;
message.success('发送成功,请注意查收');
},
throw new Error('formApi is not ready');
}
const values = await formApi.getValues();
const loginMethodValue = values.login_method || 'phone';
try {
let result;
if (loginMethodValue === 'phone') {
// 手机号登录:验证手机号
await formApi.validateField('username');
const isPhoneReady = await formApi.isFieldValid('username');
if (!isPhoneReady) {
loading.value = false;
throw new Error('手机号格式不正确');
}
const password = await formApi.isFieldValid('password');
if (!password) {
loading.value = false;
throw new Error('密码不符合要求');
}
result = await sendVerificationCode({
login_method: 'phone',
username: values.username
});
} else if (loginMethodValue === 'login_account') {
// 登录账号登录:验证登录账号
await formApi.validateField('login_account');
const isAccountReady = await formApi.isFieldValid('login_account');
if (!isAccountReady) {
loading.value = false;
throw new Error('登录账号不能为空');
}
const password = await formApi.isFieldValid('password');
if (!password) {
loading.value = false;
throw new Error('密码不符合要求');
}
result = await sendVerificationCode({
login_method: 'login_account',
login_account: values.login_account
});
} else if (loginMethodValue === 'job_number') {
// 工号登录:验证工号
await formApi.validateField('job_number');
const isJobNumberReady = await formApi.isFieldValid('job_number');
if (!isJobNumberReady) {
loading.value = false;
throw new Error('工号不能为空');
}
const password = await formApi.isFieldValid('password');
if (!password) {
loading.value = false;
throw new Error('密码不符合要求');
}
result = await sendVerificationCode({
login_method: 'job_number',
job_number: values.job_number
});
}
if (result) {
if (result.need_select && result.accounts && result.accounts.length > 1) {
needSelectAccount.value = true;
accountList.value = result.accounts;
message.warning('该手机号绑定了多个账号,请选择登录账号或工号');
} else {
needSelectAccount.value = false;
message.success('发送成功,请注意查收');
}
// 开发环境显示验证码
if (result.code) {
message.info(`验证码:${result.code}`, 10);
}
}
} catch (error: any) {
message.error(error.message || '发送验证码失败');
throw error;
} finally {
loading.value = false;
}
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
// {
// component: markRaw(SliderCaptcha),
// fieldName: 'captcha',
// rules: z.boolean().refine((value) => value, {
// message: $t('authentication.verifyRequiredTip'),
// }),
// },
];
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
});
return schema;
});
// 自定义提交处理
async function handleLogin(values: Recordable<any>) {
const loginMethodValue = values.login_method || 'phone';
// 验证必填字段
if (loginMethodValue === 'phone') {
if (!values.username || !/^\d{11}$/.test(values.username)) {
message.error('请输入正确的手机号');
return;
}
} else if (loginMethodValue === 'login_account') {
if (!values.login_account || values.login_account.trim() === '') {
message.error('请输入登录账号');
return;
}
} else if (loginMethodValue === 'job_number') {
if (!values.job_number || values.job_number.trim() === '') {
message.error('请输入工号');
return;
}
}
const loginData: any = {
login_method: loginMethodValue,
password: values.password,
code: values.code,
};
// 根据登录方式组装参数
if (loginMethodValue === 'phone') {
loginData.phone = values.username;
} else if (loginMethodValue === 'login_account') {
loginData.login_account = values.login_account;
} else if (loginMethodValue === 'job_number') {
loginData.job_number = values.job_number;
}
await authStore.authLogin(loginData);
}
</script>
<template>
@@ -159,6 +268,6 @@ const formSchema = computed((): VbenFormSchema[] => {
:show-register="false"
:show-remember-me="false"
:show-third-party-login="false"
@submit="authStore.authLogin"
@submit="handleLogin"
/>
</template>

View File

@@ -358,3 +358,11 @@ export async function addDoctorMyDiseaseApi(disease_id: number) {
export async function deleteDoctorMyDiseaseApi(id: number) {
return requestClient.post<any>(`${prefix}delete-my-disease`, { id });
}
/**
* 更新医生签名
* @param data 签名数据,包含 sign_image 字段
*/
export async function updateSignatureApi(data: { sign_image: string }) {
return requestClient.post<any>(`${prefix}update-signature`, data);
}

View File

@@ -0,0 +1,166 @@
<script lang="ts" setup>
/**
* 签名查看和更新模态框组件
*
* @description 用于查看和更新医生签名
* @author 系统
* @date 2024
*/
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import type { VbenFormProps } from '#/adapter/form';
import { updateSignatureApi } from '../api';
// ==================== 表单配置 ====================
const formProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Avatar',
componentProps: {
placeholder: '请上传签名图片',
},
fieldName: 'sign_image',
formItemClass: 'col-span-12',
label: '签名图片',
rules: 'required',
},
],
showDefaultActions: false,
};
const [Form, formApi] = useVbenForm(formProps);
// ==================== 模态框配置 ====================
const currentSignature = ref<string>('');
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
try {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
await updateSignatureApi({ sign_image: values.sign_image });
message.success('更新签名成功');
// 调用回调刷新数据
const data = modalApi.getData<{ onSuccess?: () => void }>();
if (data?.onSuccess) {
data.onSuccess();
}
modalApi.close();
}
} catch (error: any) {
message.error(error?.message || '更新签名失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ signature?: string }>();
if (data?.signature) {
currentSignature.value = data.signature;
// 设置表单初始值
formApi.setValues({ sign_image: data.signature });
} else {
currentSignature.value = '';
formApi.resetFields();
}
}
},
});
// ==================== 计算属性 ====================
/**
* 签名图片URL
* 处理base64格式或URL格式
*/
const signatureImageUrl = computed(() => {
if (!currentSignature.value) return '';
// 如果是base64格式
if (currentSignature.value.startsWith('data:')) {
return currentSignature.value;
}
// 如果是URL格式
if (currentSignature.value.startsWith('http://') || currentSignature.value.startsWith('https://')) {
return currentSignature.value;
}
// 其他情况尝试作为base64处理
return `data:image/jpeg;base64,${currentSignature.value}`;
});
/**
* 是否有签名
*/
const hasSignature = computed(() => {
return !!currentSignature.value;
});
</script>
<template>
<Modal title="查看和更新签名" class="w-[600px]">
<div class="space-y-6">
<!-- 当前签名查看区域 -->
<div v-if="hasSignature" class="space-y-2">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
当前签名
</div>
<div class="flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<Image
:src="signatureImageUrl"
alt="医生签名"
:preview="true"
style="max-height: 160px; max-width: 100%; object-fit: contain;"
/>
</div>
</div>
<!-- 无签名提示 -->
<div v-else class="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-8 text-center dark:border-gray-600 dark:bg-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">
暂无签名请上传签名图片
</div>
</div>
<!-- 更新签名表单区域 -->
<div class="space-y-2">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
更新签名
</div>
<Form />
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
/* 自定义样式 */
</style>

View File

@@ -15,6 +15,8 @@ import { useUserStore } from '@vben/stores';
import EditCommonPrescriptionModal from './components/EditCommonPrescriptionModal.vue';
// 常用方新增弹窗组件
import AddCommonPrescriptionModal from './components/AddCommonPrescriptionModal.vue';
// 签名查看和更新弹窗组件
import SignatureModal from './components/SignatureModal.vue';
import {
Button,
@@ -201,6 +203,12 @@ const [AddCommonPrescriptionModals, AddCommonPrescriptionModalApi] =
connectedComponent: AddCommonPrescriptionModal,
});
// ==================== 签名查看和更新弹窗 ====================
const [SignatureModals, SignatureModalApi] = useVbenModal({
connectedComponent: SignatureModal,
});
/**
* 打开新增常用方弹窗
*/
@@ -296,6 +304,21 @@ const createDoctorOrder = () => {
});
});
};
/**
* 打开签名查看和更新弹窗
*/
const handleViewSignature = () => {
const signature = data.value?.identity_info?.sign_image || '';
SignatureModalApi.setData({
signature,
onSuccess: () => {
// 刷新医生信息
getMyInfo();
},
});
SignatureModalApi.open();
};
</script>
<template>
@@ -321,11 +344,21 @@ const createDoctorOrder = () => {
<Card v-if="data && activeTabBar === 1" title="个人资料">
<!-- 头部信息 -->
<div class="mb-8 flex flex-col gap-6 md:flex-row md:gap-8">
<img
:src="data?.avatar"
alt="医生头像"
class="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg md:h-32 md:w-32"
/>
<div class="relative">
<img
:src="data?.avatar"
alt="医生头像"
class="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg md:h-32 md:w-32"
/>
<Button
type="primary"
size="small"
class="absolute bottom-0 right-0"
@click="handleViewSignature"
>
查看签名
</Button>
</div>
<div class="flex-1">
<div class="mb-3 flex items-center gap-4">
@@ -743,6 +776,8 @@ const createDoctorOrder = () => {
<EditCommonPrescriptionModals />
<!-- 常用方新增弹窗 -->
<AddCommonPrescriptionModals />
<!-- 签名查看和更新弹窗 -->
<SignatureModals />
</Page>
</template>
<style lang="scss" scoped>

View File

@@ -80,6 +80,14 @@ export async function getQuickMenuApi() {
return requestClient.get<any>(`${prefix}get-quick-menu`);
}
/**
* 生成登录账号
* @param id
*/
export async function generateLoginAccount(id: number) {
return requestClient.post<any>(`${prefix}generate-login-account`, { id });
}
/**
* 编辑账户绑定银行卡
* @param data

View File

@@ -12,6 +12,7 @@ interface RowType {
code: string;
platform_id: string;
phone: string;
login_account: string;
desc: string;
created_at: string;
}
@@ -44,6 +45,13 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'platform.name', title: '所属平台' },
{ field: 'supplier.name', title: '所属供应商' },
{ field: 'phone', title: '手机号码' },
{
field: 'login_account',
align: 'left',
title: '登录账号',
slots: { default: 'login_account' },
width: 150,
},
{ field: 'email', title: '邮箱' },
{ field: 'desc', title: '备注' },
{ field: 'created_at', title: '注册时间' },

View File

@@ -10,7 +10,7 @@ import { Button, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {deleteAdmin, resetPassword} from './api';
import { deleteAdmin, resetPassword, generateLoginAccount } from './api';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
@@ -69,6 +69,40 @@ const resetPasswordApi = (id: number) => {
gridApi.reload();
});
};
const generateAccountApi = async (row: any) => {
try {
const result = await generateLoginAccount(row.id);
if (result.login_account) {
message.success('生成账号成功');
gridApi.reload();
}
} catch (error: any) {
message.error(error.message || '生成账号失败');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success('复制成功');
} catch (error) {
// 降级方案:使用传统方法
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('复制成功');
} catch (err) {
message.error('复制失败');
}
document.body.removeChild(textArea);
}
};
</script>
<template>
@@ -110,6 +144,16 @@ const resetPasswordApi = (id: number) => {
<template #avatar="{ row }">
<Image :src="row.avatar" height="30" width="30" />
</template>
<template #login_account="{ row }">
<span v-if="row.login_account">
<Button type="link" size="small" @click="copyToClipboard(row.login_account)">
{{ row.login_account }}
</Button>
</span>
<Button v-else type="link" size="small" @click="generateAccountApi(row)">
生成账号
</Button>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction

View File

@@ -233,6 +233,7 @@ export const modalFormProps: VbenFormProps = {
label: '中药采购价比例',
rules: 'required',
suffix: () => '%',
defaultValue: 100,
},
{
component: 'InputNumber',
@@ -244,6 +245,7 @@ export const modalFormProps: VbenFormProps = {
label: '中药销售比例',
rules: 'required',
suffix: () => '%',
defaultValue: 100,
},
// {
// component: 'VbenInput',