This commit is contained in:
2026-09-02 10:04:21 +08:00
parent 4f3b83f16d
commit e5e412682c
10 changed files with 41 additions and 22 deletions
@@ -23,6 +23,8 @@ test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /function cancelEditing\(\)/) assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/) assert.match(viewSource, /async function saveProfile\(\)/)
assert.match(viewSource, /请在“登录标识”中修改/) assert.match(viewSource, /请在“登录标识”中修改/)
assert.match(viewSource, /<dt>登录手机号<\/dt><dd>\{\{ auth\.profile\.phone/)
assert.doesNotMatch(viewSource, /<dt>联系电话<\/dt><dd>\{\{ auth\.profile\.phone/)
assert.match(viewSource, /企业归属由平台审核关系确定/) assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/) assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/) assert.match(viewSource, /await auth\.savePersonalProfile/)
@@ -38,6 +40,8 @@ test('changes username and mobile with current-password verification then clears
assert.match(viewSource, /error\.code === 'A0401'/) assert.match(viewSource, /error\.code === 'A0401'/)
assert.match(viewSource, /auth\.clearSession\(\)/) assert.match(viewSource, /auth\.clearSession\(\)/)
assert.match(viewSource, /reason: 'identifiers-updated'/) assert.match(viewSource, /reason: 'identifiers-updated'/)
assert.match(viewSource, /label="登录手机号" prop="phone"/)
assert.match(viewSource, /全部登录会话都会立即退出/)
assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/) assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/)
}) })
@@ -193,7 +193,7 @@ async function changeLoginIdentifiers() {
await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } }) await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } })
} catch (error) { } catch (error) {
identifiersForm.currentPassword = '' identifiersForm.currentPassword = ''
if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或手机号已被使用,请更换后重试' if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或登录手机号已被使用,请更换后重试'
else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误' else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误'
else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试' else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试'
else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试' else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试'
@@ -267,7 +267,7 @@ async function changePassword() {
<dl v-else class="account-info-grid"> <dl v-else class="account-info-grid">
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div> <div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div> <div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div> <div><dt>登录手机号</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div> <div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div> <div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
</dl> </dl>
@@ -277,9 +277,9 @@ async function changePassword() {
<template #label><span class="account-tab-label"><ShieldCheck :size="16" />登录标识</span></template> <template #label><span class="account-tab-label"><ShieldCheck :size="16" />登录标识</span></template>
<div class="identifier-layout"> <div class="identifier-layout">
<el-form ref="identifiersFormRef" :model="identifiersForm" :rules="identifierRules" label-position="top" class="identifier-form" @submit.prevent="changeLoginIdentifiers"> <el-form ref="identifiersFormRef" :model="identifiersForm" :rules="identifierRules" label-position="top" class="identifier-form" @submit.prevent="changeLoginIdentifiers">
<el-alert title="修改成功后,国内与海外的全部登录会话都会立即退出,需要使用新用户名或手机号重新登录。" type="warning" :closable="false" show-icon /> <el-alert title="修改成功后,国内与海外的全部登录会话都会立即退出,需要使用新用户名或登录手机号重新登录。" type="warning" :closable="false" show-icon />
<el-form-item label="用户名" prop="username"><el-input v-model="identifiersForm.username" maxlength="64" autocomplete="username" placeholder="请输入新用户名" /></el-form-item> <el-form-item label="用户名" prop="username"><el-input v-model="identifiersForm.username" maxlength="64" autocomplete="username" placeholder="请输入新用户名" /></el-form-item>
<el-form-item label="中国大陆手机号" prop="phone"><el-input v-model="identifiersForm.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="请输入11位手机号" /></el-form-item> <el-form-item label="登录手机号" prop="phone"><el-input v-model="identifiersForm.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="请输入11位中国大陆手机号" /></el-form-item>
<el-form-item label="当前密码" prop="currentPassword"><el-input v-model="identifiersForm.currentPassword" type="password" show-password maxlength="72" autocomplete="current-password" placeholder="用于确认是本人操作" /></el-form-item> <el-form-item label="当前密码" prop="currentPassword"><el-input v-model="identifiersForm.currentPassword" type="password" show-password maxlength="72" autocomplete="current-password" placeholder="用于确认是本人操作" /></el-form-item>
<el-alert v-if="identifiersError" :title="identifiersError" type="error" :closable="false" show-icon /> <el-alert v-if="identifiersError" :title="identifiersError" type="error" :closable="false" show-icon />
<div class="identifier-actions"><el-button :disabled="identifiersSaving" @click="resetIdentifiersForm">重置</el-button><el-button type="primary" native-type="submit" :loading="identifiersSaving"><Save :size="16" />保存并重新登录</el-button></div> <div class="identifier-actions"><el-button :disabled="identifiersSaving" @click="resetIdentifiersForm">重置</el-button><el-button type="primary" native-type="submit" :loading="identifiersSaving"><Save :size="16" />保存并重新登录</el-button></div>
@@ -53,6 +53,8 @@ test('implements every backend registration field and real submission', () => {
for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) { for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
assert.match(registerSource, new RegExp(`prop="${field}"`)) assert.match(registerSource, new RegExp(`prop="${field}"`))
} }
assert.match(registerSource, /label="登录手机号" prop="legalPhone"/)
assert.match(registerSource, /注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动/)
assert.match(registerSource, /registerMerchant\(/) assert.match(registerSource, /registerMerchant\(/)
assert.match(registerSource, /4–64 位/) assert.match(registerSource, /4–64 位/)
assert.match(registerSource, /8–72 位/) assert.match(registerSource, /8–72 位/)
+1 -1
View File
@@ -131,7 +131,7 @@ async function submit() {
<h2 id="contact-section-title">联系信息</h2> <h2 id="contact-section-title">联系信息</h2>
<div class="register-form-grid"> <div class="register-form-grid">
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item> <el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="法人手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号" :prefix-icon="Phone" /></el-form-item> <el-form-item label="登录手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号" :prefix-icon="Phone" /><span class="register-security-note">注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动。</span></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item> <el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
<el-form-item label="企业地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item> <el-form-item label="企业地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
</div> </div>
@@ -78,6 +78,8 @@ test('starts empty and only loads server role and member records', () => {
assert.match(storeSource, /await resetMerchantMemberPassword/) assert.match(storeSource, /await resetMerchantMemberPassword/)
assert.match(source, /username: string/) assert.match(source, /username: string/)
assert.match(source, /lastLoginAt: string \| null/) assert.match(source, /lastLoginAt: string \| null/)
assert.match(source, /phone: string \| null/)
assert.match(storeSource, /phone: payload\.phone\.trim\(\) \|\| null/)
assert.match(source, /memberCount: number/) assert.match(source, /memberCount: number/)
assert.match(storeSource, /account: member\.username/) assert.match(storeSource, /account: member\.username/)
assert.match(storeSource, /lastLoginAt: formatLastLoginAt\(member\.lastLoginAt\)/) assert.match(storeSource, /lastLoginAt: formatLastLoginAt\(member\.lastLoginAt\)/)
@@ -129,8 +129,8 @@ export interface CreateMerchantMemberRequest {
username: string username: string
password: string password: string
displayName: string displayName: string
phone: string phone: string | null
email: string email: string | null
memberType: 'EMPLOYEE' memberType: 'EMPLOYEE'
memberNo: string memberNo: string
roleIds: Array<number | string> roleIds: Array<number | string>
@@ -138,8 +138,8 @@ export interface CreateMerchantMemberRequest {
export interface UpdateMerchantMemberRequest { export interface UpdateMerchantMemberRequest {
displayName: string displayName: string
phone: string phone: string | null
email: string email: string | null
memberType: string memberType: string
memberNo: string memberNo: string
roleIds: Array<number | string> roleIds: Array<number | string>
@@ -234,8 +234,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
username: payload.account.trim(), username: payload.account.trim(),
password: payload.password, password: payload.password,
displayName: payload.name.trim(), displayName: payload.name.trim(),
phone: payload.phone.trim(), phone: payload.phone.trim() || null,
email: payload.email.trim(), email: payload.email.trim() || null,
memberType: 'EMPLOYEE', memberType: 'EMPLOYEE',
memberNo: payload.memberNo.trim(), memberNo: payload.memberNo.trim(),
roleIds: payload.roleIds, roleIds: payload.roleIds,
@@ -248,8 +248,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
if (!member) throw new Error('成员不存在') if (!member) throw new Error('成员不存在')
const updated = await updateMerchantMember(id, { const updated = await updateMerchantMember(id, {
displayName: payload.name.trim(), displayName: payload.name.trim(),
phone: payload.phone.trim(), phone: payload.phone.trim() || null,
email: payload.email.trim(), email: payload.email.trim() || null,
memberType: member.isAdministrator ? 'OWNER' : 'EMPLOYEE', memberType: member.isAdministrator ? 'OWNER' : 'EMPLOYEE',
memberNo: payload.memberNo.trim(), memberNo: payload.memberNo.trim(),
roleIds: payload.roleIds, roleIds: payload.roleIds,
@@ -150,7 +150,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }], companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }], legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }], contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }], contactPhone: [{ required: true, whitespace: true, message: '请输入公司联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }], contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
} }
const qualificationColumns: DataTableColumn[] = [ const qualificationColumns: DataTableColumn[] = [
@@ -516,7 +516,7 @@ function closeQualificationPreview() {
<header class="info-panel-header"><h3>联系信息</h3></header> <header class="info-panel-header"><h3>联系信息</h3></header>
<dl class="profile-grid"> <dl class="profile-grid">
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div> <div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div> <div><dt>公司联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div> <div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
<div><dt>联系微信</dt><dd>{{ store.company.contactWechat || '-' }}</dd></div> <div><dt>联系微信</dt><dd>{{ store.company.contactWechat || '-' }}</dd></div>
</dl> </dl>
@@ -571,7 +571,7 @@ function closeQualificationPreview() {
<h3>联系信息</h3> <h3>联系信息</h3>
<div class="form-grid-two"> <div class="form-grid-two">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item> <el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入联系电话" /></el-form-item> <el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入公司联系电话" /><small class="profile-field-hint">仅用于公司业务联系,不会修改任何成员的登录手机号。</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item> <el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
</div> </div>
</el-form> </el-form>
@@ -684,6 +684,7 @@ function closeQualificationPreview() {
.mobile-qualification-list { display: none; } .mobile-qualification-list { display: none; }
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; } .profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
.profile-form h3:not(:first-child) { margin-top: 22px; } .profile-form h3:not(:first-child) { margin-top: 22px; }
.profile-field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; } .form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.form-grid-two .full-row { grid-column: 1 / -1; } .form-grid-two .full-row { grid-column: 1 / -1; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; } .drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@@ -30,19 +30,19 @@ const memberRules: FormRules = {
{ required: true, message: '请输入初始密码', trigger: 'blur' }, { required: true, message: '请输入初始密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码需为 8–64 位,并同时包含字母和数字', trigger: 'blur' }, { pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码需为 8–64 位,并同时包含字母和数字', trigger: 'blur' },
], ],
phone: [{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' }], phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的中国大陆登录手机号', trigger: 'blur' }],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
roleIds: [{ type: 'array', required: true, min: 1, message: '请至少选择一个成员角色', trigger: 'change' }], roleIds: [{ type: 'array', required: true, min: 1, message: '请至少选择一个成员角色', trigger: 'change' }],
} }
const memberQueryFields: QueryField[] = [ const memberQueryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '姓名、账号、手机或邮箱' }, { key: 'keyword', type: 'input', placeholder: '姓名、账号、登录手机号或邮箱' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '已停用', value: 'INACTIVE' }] }, { key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '已停用', value: 'INACTIVE' }] },
] ]
const memberTableColumns: DataTableColumn[] = [ const memberTableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions' }, { key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions' },
{ key: 'member', label: '成员', minWidth: 170, slotName: 'member' }, { key: 'member', label: '成员', minWidth: 170, slotName: 'member' },
{ key: 'contact', label: '联系方式', minWidth: 200, slotName: 'contact' }, { key: 'contact', label: '登录手机号 / 邮箱', minWidth: 200, slotName: 'contact' },
{ key: 'roles', label: '角色', minWidth: 130, slotName: 'roles' }, { key: 'roles', label: '角色', minWidth: 130, slotName: 'roles' },
{ key: 'lastLoginAt', prop: 'lastLoginAt', label: '登录记录', minWidth: 130 }, { key: 'lastLoginAt', prop: 'lastLoginAt', label: '登录记录', minWidth: 130 },
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'status', label: '状态', width: 90, slotName: 'status' },
@@ -176,7 +176,7 @@ async function resetPassword(member: TenantMember) {
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" :data="pagedMembers" :columns="memberTableColumns" :total="filteredMembers.length" row-key="id"> <DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" :data="pagedMembers" :columns="memberTableColumns" :total="filteredMembers.length" row-key="id">
<template #actions="{ row }"><el-button v-if="canEditMember" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="canChangeMemberStatus && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></template> <template #actions="{ row }"><el-button v-if="canEditMember" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="canChangeMemberStatus && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></template>
<template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template> <template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template> <template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone || '-' }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
<template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template> <template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template>
<template #status="{ row }"><el-tag :type="(row as TenantMember).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantMember).status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template> <template #status="{ row }"><el-tag :type="(row as TenantMember).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantMember).status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template>
<template #empty><div class="client-empty">没有匹配的企业成员,请调整筛选条件。</div></template> <template #empty><div class="client-empty">没有匹配的企业成员,请调整筛选条件。</div></template>
@@ -186,7 +186,7 @@ async function resetPassword(member: TenantMember) {
<div v-if="pagedMembers.length" class="mobile-member-list"> <div v-if="pagedMembers.length" class="mobile-member-list">
<article v-for="member in pagedMembers" :key="member.id"> <article v-for="member in pagedMembers" :key="member.id">
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header> <header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl> <dl><div><dt>登录手机号</dt><dd>{{ member.phone || '-' }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
<footer><el-button v-if="canEditMember" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="canChangeMemberStatus && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></footer> <footer><el-button v-if="canEditMember" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="canChangeMemberStatus && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></footer>
</article> </article>
</div> </div>
@@ -199,7 +199,7 @@ async function resetPassword(member: TenantMember) {
<el-form-item label="成员姓名" prop="name" required><el-input v-model="form.name" maxlength="64" placeholder="例如 张三" /></el-form-item> <el-form-item label="成员姓名" prop="name" required><el-input v-model="form.name" maxlength="64" placeholder="例如 张三" /></el-form-item>
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item> <el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item> <el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div> <div class="member-form-grid"><el-form-item label="登录手机号" prop="phone"><el-input v-model="form.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="例如 13800138000(选填)" /><span class="member-field-help">用于该成员手机号登录;修改后会同步认证账号。</span></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE' && (!item.builtin || form.roleIds.includes(item.id)))" :key="role.id" :label="role.name" :value="role.id" :disabled="role.builtin" /></el-select></el-form-item> <el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE' && (!item.builtin || form.roleIds.includes(item.id)))" :key="role.id" :label="role.name" :value="role.id" :disabled="role.builtin" /></el-select></el-form-item>
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div> <div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
</el-form> </el-form>
@@ -225,6 +225,7 @@ async function resetPassword(member: TenantMember) {
.mobile-member-list, .mobile-member-empty { display: none; } .mobile-member-list, .mobile-member-empty { display: none; }
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; } .member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
.member-field-help { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); } .member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; } .drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.members-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; } .members-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@@ -94,6 +94,10 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /<dt>成立日期<\/dt><dd>\{\{ store\.company\.establishedDate/) assert.match(profileSource, /<dt>成立日期<\/dt><dd>\{\{ store\.company\.establishedDate/)
assert.match(profileSource, /<dt>注册资本<\/dt><dd>\{\{ store\.company\.registeredCapital/) assert.match(profileSource, /<dt>注册资本<\/dt><dd>\{\{ store\.company\.registeredCapital/)
assert.match(profileSource, /<dt>联系微信<\/dt><dd>\{\{ store\.company\.contactWechat/) assert.match(profileSource, /<dt>联系微信<\/dt><dd>\{\{ store\.company\.contactWechat/)
assert.match(profileSource, /<dt>公司联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
assert.match(profileSource, /label="公司联系电话" prop="contactPhone" required/)
assert.match(profileSource, /不会修改任何成员的登录手机号/)
assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/) assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
}) })
@@ -135,6 +139,11 @@ test('implements member list, role assignment, password reset and status control
assert.match(membersSource, /canChangeMemberStatus.*company:member:status/) assert.match(membersSource, /canChangeMemberStatus.*company:member:status/)
assert.doesNotMatch(membersSource, /company:member:write/) assert.doesNotMatch(membersSource, /company:member:write/)
assert.doesNotMatch(membersSource, /member-metrics/) assert.doesNotMatch(membersSource, /member-metrics/)
assert.match(membersSource, /label="登录手机号" prop="phone"/)
assert.match(membersSource, /登录手机号 \/ 邮箱/)
assert.match(membersSource, /用于该成员手机号登录;修改后会同步认证账号/)
assert.match(membersSource, /phone: \[\{ pattern: \/\^1\[3-9\]\\d\{9\}\$\//)
assert.doesNotMatch(membersSource, /label="联系电话" prop="phone"/)
}) })
test('protects built-in roles and exposes role permission assignment', () => { test('protects built-in roles and exposes role permission assignment', () => {