front/src/components/UserEditDrawer/index.vue
zzh 8c47c2772a
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m49s
feat: 优化数据权限模板与营销抽屉等运营配置体验
理顺模板配置流程与文案,并统一左侧菜单对比度、会员/券/用户编辑抽屉与因子标签展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 18:25:31 +08:00

704 lines
20 KiB
Vue

<template>
<a-drawer
:visible="visible"
:width="drawerWidth"
unmount-on-close
:esc-to-close="true"
:mask-closable="!confirmLoading"
:header="false"
:footer="false"
class="user-edit-drawer"
:drawer-style="drawerShellStyle"
:body-style="drawerBodyStyle"
@update:visible="onVisibleChange"
@cancel="handleCancel"
>
<div class="ue-drawer">
<header class="ue-drawer__bar">
<div class="ue-drawer__bar-main">
<h2 class="ue-drawer__title">
{{ isEdit ? t('system.user.modal.edit') : t('system.user.modal.add') }}
</h2>
<span class="ue-drawer__chip">{{ t('system.user.drawer.eyebrow') }}</span>
<button
v-if="isEdit && canResetPassword"
type="button"
class="ue-drawer__action"
:disabled="resetPwdLoading || confirmLoading"
@click="openResetPasswordModal"
>
{{ t('system.user.actions.resetPassword') }}
</button>
</div>
<button
type="button"
class="ue-drawer__close"
:aria-label="t('common.cancel')"
:disabled="confirmLoading"
@click="handleCancel"
>
<icon-close />
</button>
</header>
<div class="ue-drawer__body">
<a-form
ref="formRef"
class="ue-form"
layout="vertical"
:model="formState"
:rules="formRules"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.user.drawer.section.account') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.accountHint') }}</span>
</div>
<div class="ue-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item :label="t('system.user.form.name')" field="name">
<a-input
v-model="formState.name"
:placeholder="t('system.user.form.namePlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.account')" field="account">
<a-input
v-model="formState.account"
:placeholder="t('system.user.form.accountPlaceholder')"
:disabled="isEdit"
allow-clear
/>
</a-form-item>
</a-col>
<a-col v-if="!isEdit" :span="12">
<a-form-item :label="t('system.user.form.password')" field="password">
<a-input-password
v-model="formState.password"
:placeholder="t('system.user.form.passwordPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.email')" field="email">
<a-input
v-model="formState.email"
:placeholder="t('system.user.form.emailPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.organization')" field="organizationId">
<a-tree-select
v-model="formState.organizationId"
:placeholder="t('system.user.form.organizationPlaceholder')"
:data="organizationTreeData"
allow-clear
:loading="organizationLoading"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.avatar')" field="avatar">
<a-input
v-model="formState.avatar"
:placeholder="t('system.user.form.avatarPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col v-if="!isEdit" :span="24">
<a-form-item :label="t('system.user.form.roles')" field="roleIds">
<a-select
v-model="formState.roleIds"
multiple
allow-clear
allow-search
:placeholder="t('system.user.form.rolesPlaceholder')"
:options="roleOptions"
/>
</a-form-item>
</a-col>
<a-col v-if="isEdit && advisorInviteUrl" :span="24">
<a-form-item :label="t('system.user.form.advisorInviteLink')">
<div class="ue-invite-row">
<a-input
class="ue-invite-input"
:model-value="advisorInviteUrl"
readonly
/>
<a-button type="primary" class="ue-invite-copy" @click="copyAdvisorInviteLink">
{{ t('system.user.actions.copyInviteLink') }}
</a-button>
</div>
<template #extra>
<span class="ue-invite-hint">{{ t('system.user.form.advisorInviteHint') }}</span>
</template>
</a-form-item>
</a-col>
</a-row>
</div>
</section>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.user.drawer.section.contact') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.contactHint') }}</span>
</div>
<div class="ue-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item :label="t('system.user.form.phone')" field="phone">
<a-input
v-model="formState.phone"
:placeholder="t('system.user.form.phonePlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.employeeNo')" field="employeeNo">
<a-input
v-model="formState.employeeNo"
:placeholder="t('system.user.form.employeeNoPlaceholder')"
:readonly="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.wechatId')" field="wechatId">
<a-input
v-model="formState.wechatId"
:placeholder="t('system.user.form.wechatIdPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.wecomKfUrl')" field="wecomKfUrl">
<a-input
v-model="formState.wecomKfUrl"
:placeholder="t('system.user.form.wecomKfUrlPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.wechatQrCode')" field="wechatQrFiles">
<a-upload
v-model:file-list="formState.wechatQrFiles"
list-type="picture-card"
:limit="1"
accept="image/*"
:custom-request="handleWechatCustomRequest"
>
<template #upload-button>
<div class="ue-upload-placeholder">
<icon-plus />
<span>{{ t('system.user.form.wechatQrUpload') }}</span>
</div>
</template>
</a-upload>
</a-form-item>
</a-col>
</a-row>
</div>
</section>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.employee.section') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.employeeHint') }}</span>
</div>
<div class="ue-card__body">
<EmployeeProfileFields :model-value="employeeFormState" hide-title dense />
</div>
</section>
</a-form>
</div>
<footer class="ue-drawer__footer">
<a-button :disabled="confirmLoading" @click="handleCancel">
{{ t('common.cancel') }}
</a-button>
<a-button type="primary" :loading="confirmLoading" @click="handleOk">
{{ t('common.save') }}
</a-button>
</footer>
</div>
<a-modal
v-model:visible="resetPwdVisible"
:title="t('system.user.modal.resetPasswordTitle')"
:ok-loading="resetPwdLoading"
unmount-on-close
@ok="handleResetPasswordSubmit"
@cancel="closeResetPasswordModal"
>
<p class="ue-reset-tip">
{{
t('system.user.modal.resetPasswordContentSingle', {
account: formState.account || t('system.user.drawer.accountFallback'),
})
}}
</p>
<a-form layout="vertical">
<a-form-item :label="t('system.user.form.newPassword')" required>
<a-input-password
v-model="resetPwdForm.newPassword"
:placeholder="t('system.user.form.newPasswordPlaceholder')"
allow-clear
/>
</a-form-item>
<a-form-item :label="t('system.user.form.confirmPassword')" required>
<a-input-password
v-model="resetPwdForm.confirmPassword"
:placeholder="t('system.user.form.confirmPasswordPlaceholder')"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</a-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { IconClose, IconPlus } from '@arco-design/web-vue/es/icon'
import EmployeeProfileFields from '@/components/EmployeeProfileFields/index.vue'
import { resetUserPassword } from '@/api/rbac/internal-user'
import type { EmployeeFormState } from '@/types/employee-profile'
import type { UserFormExtState } from '@/types/rbac-user-profile'
import { buildAdvisorInviteRegisterUrl } from '@/utils/inviteLink'
import { hasPermission } from '@/utils/rbac-permission'
import { $baseMessage } from '@/utils'
import { readFileAsDataUrl } from '@/utils/image-data-url'
export type UserEditFormState = {
name?: string
account?: string
password?: string
email?: string
avatar?: string
organizationId?: string
roleIds?: string[]
} & UserFormExtState
const props = withDefaults(
defineProps<{
visible: boolean
confirmLoading?: boolean
isEdit?: boolean
userId?: string
formState: UserEditFormState
employeeFormState: EmployeeFormState
organizationTreeData?: any[]
organizationLoading?: boolean
roleOptions?: Array<{ label: string; value: string }>
formRules?: Record<string, unknown>
}>(),
{
confirmLoading: false,
isEdit: false,
userId: '',
organizationTreeData: () => [],
organizationLoading: false,
roleOptions: () => [],
formRules: () => ({}),
},
)
const emit = defineEmits<{
'update:visible': [boolean]
ok: []
cancel: []
}>()
const { t } = useI18n()
const formRef = ref()
const resetPwdVisible = ref(false)
const resetPwdLoading = ref(false)
const resetPwdForm = reactive({
newPassword: '',
confirmPassword: '',
})
const canResetPassword = computed(
() => hasPermission('Rbac.Users.ResetPassword') || hasPermission('Rbac.Users.Update'),
)
const drawerWidth = computed(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) return '100%'
return 640
})
const drawerShellStyle = {
borderRadius: '16px 0 0 16px',
overflow: 'hidden',
boxShadow: '-12px 0 40px rgba(15, 23, 42, 0.12)',
}
const drawerBodyStyle = {
padding: '0',
background: 'var(--app-page-bg)',
height: '100%',
overflow: 'hidden',
}
const advisorInviteUrl = computed(() =>
buildAdvisorInviteRegisterUrl(String(props.formState.account || '')),
)
async function copyAdvisorInviteLink() {
const url = advisorInviteUrl.value
if (!url) return
try {
await navigator.clipboard.writeText(url)
Message.success(t('system.user.message.copyInviteSuccess'))
} catch {
Message.error(t('system.user.message.copyInviteFailed'))
}
}
function openResetPasswordModal() {
if (!props.userId) {
Message.warning(t('system.user.message.resetPasswordFailed'))
return
}
resetPwdForm.newPassword = ''
resetPwdForm.confirmPassword = ''
resetPwdVisible.value = true
}
function closeResetPasswordModal() {
resetPwdVisible.value = false
resetPwdForm.newPassword = ''
resetPwdForm.confirmPassword = ''
}
async function handleResetPasswordSubmit() {
const newPassword = resetPwdForm.newPassword.trim()
const confirmPassword = resetPwdForm.confirmPassword.trim()
if (!newPassword) {
Message.warning(t('system.user.rules.password.required'))
return Promise.reject()
}
if (newPassword.length < 6) {
Message.warning(t('system.user.rules.password.minLength'))
return Promise.reject()
}
if (newPassword !== confirmPassword) {
Message.warning(t('system.user.rules.password.confirmMismatch'))
return Promise.reject()
}
if (!props.userId) {
Message.warning(t('system.user.message.resetPasswordFailed'))
return Promise.reject()
}
resetPwdLoading.value = true
try {
await resetUserPassword(props.userId, newPassword)
Message.success(t('system.user.message.resetPasswordSuccess'))
closeResetPasswordModal()
} catch (e: any) {
Message.error(e?.message || t('system.user.message.resetPasswordFailed'))
return Promise.reject(e)
} finally {
resetPwdLoading.value = false
}
}
function onVisibleChange(val: boolean) {
emit('update:visible', val)
}
function handleCancel() {
if (props.confirmLoading) return
emit('update:visible', false)
emit('cancel')
}
async function handleOk() {
try {
await formRef.value?.validate()
} catch {
return
}
emit('ok')
}
async function handleWechatCustomRequest(options: any) {
const { fileItem, onSuccess, onError } = options
const file = fileItem?.file as File | undefined
if (!file) {
onError?.()
return
}
try {
const dataUrl = await readFileAsDataUrl(
file,
undefined,
t('system.user.message.wechatQrTooLarge'),
)
fileItem.url = dataUrl
fileItem.status = 'done'
fileItem.response = { url: dataUrl }
onSuccess?.({ url: dataUrl })
} catch (e: any) {
fileItem.status = 'error'
$baseMessage(e?.message || t('system.user.message.wechatQrTooLarge'), 'warning')
onError?.(e)
}
}
defineExpose({
validate: () => formRef.value?.validate(),
})
</script>
<style scoped lang="less">
.ue-drawer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--app-page-bg);
outline: none;
}
.ue-drawer__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin: 12px 12px 0;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
.ue-drawer__bar-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.ue-drawer__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--color-text-1);
line-height: 1.3;
white-space: nowrap;
}
.ue-drawer__chip {
flex-shrink: 0;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color) 10%, var(--app-card-bg));
color: var(--app-primary-color);
font-size: 12px;
line-height: 24px;
}
.ue-drawer__action {
flex-shrink: 0;
height: 24px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 28%, var(--color-border-2));
border-radius: 999px;
background: transparent;
color: var(--app-primary-color);
font-size: 12px;
line-height: 22px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
border-color: var(--app-primary-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ue-drawer__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-2);
cursor: pointer;
transition: color 0.2s, background 0.2s, border-color 0.2s;
&:hover:not(:disabled) {
color: var(--app-primary-color);
border-color: color-mix(in srgb, var(--app-primary-color) 30%, var(--app-card-bg));
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ue-drawer__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 12px 16px;
}
.ue-card {
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: var(--app-card-bg);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.04);
overflow: hidden;
}
.ue-card__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px 0;
}
.ue-card__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
}
.ue-card__hint {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.4;
}
.ue-card__body {
padding: 12px 16px 4px;
}
.ue-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
}
:deep(.arco-form-item-label) {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-select-view-multiple),
:deep(.arco-textarea-wrapper),
:deep(.arco-input-number),
:deep(.arco-picker),
:deep(.arco-tree-select-view) {
border-radius: 8px;
}
}
.ue-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-text-3);
font-size: 12px;
gap: 4px;
}
.ue-invite-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.ue-invite-input {
flex: 1;
min-width: 0;
}
.ue-invite-copy {
flex-shrink: 0;
}
.ue-invite-hint {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
}
.ue-reset-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
}
.ue-drawer__footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 0 12px 12px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
</style>
<style lang="less">
.user-edit-drawer {
.arco-drawer-body {
padding: 0 !important;
height: 100%;
overflow: hidden;
}
}
</style>