feat: 顾问新客邀请链接与岗位职级字典

注册经 ?advisor= 绑定所属顾问(Account);用户抽屉展示邀请链接;岗位/职级走字典;去掉组织类型岗位。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-03 12:45:13 +08:00
parent bcfddf79f3
commit a703d1efdd
16 changed files with 465 additions and 88 deletions

View File

@ -144,7 +144,9 @@ export const CustomerService = {
assertBusinessOk(r.data) assertBusinessOk(r.data)
}, },
async create(data: Partial<CustomerDto> & { password?: string; inviteCode?: string }): Promise<void> { async create(
data: Partial<CustomerDto> & { password?: string; inviteCode?: string; advisorCode?: string },
): Promise<void> {
const body = { const body = {
customerCode: data.customerCode || undefined, customerCode: data.customerCode || undefined,
customerName: data.customerName, customerName: data.customerName,
@ -165,6 +167,7 @@ export const CustomerService = {
memberLevelId: data.memberLevelId, memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey, priceTierKey: data.priceTierKey,
inviteCode: data.inviteCode?.trim() || undefined, inviteCode: data.inviteCode?.trim() || undefined,
advisorCode: data.advisorCode?.trim() || undefined,
} }
const r = await axiosInstance.post(omsUrl('/customer/create'), body) const r = await axiosInstance.post(omsUrl('/customer/create'), body)
assertBusinessOk(r.data) assertBusinessOk(r.data)

View File

@ -49,6 +49,8 @@ export interface WeChatCustomerRegisterInput {
password: string; password: string;
/** 邀请码(来自 ?ref=,注册时绑定引荐人) */ /** 邀请码(来自 ?ref=,注册时绑定引荐人) */
inviteCode?: string; inviteCode?: string;
/** 顾问邀请(来自 ?advisor=,值为员工账号,注册时绑定所属顾问) */
advisorCode?: string;
wechat: WeChatProfileInput; wechat: WeChatProfileInput;
} }

View File

@ -145,10 +145,21 @@
field="employeePosition" field="employeePosition"
v-bind="itemColProps" v-bind="itemColProps"
> >
<a-input <a-select
v-model="model.position" v-model="model.position"
allow-clear
allow-search
:loading="positionDictLoading"
:placeholder="t('system.employee.form.positionPlaceholder')" :placeholder="t('system.employee.form.positionPlaceholder')"
/> >
<a-option
v-for="item in positionOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
@ -157,10 +168,21 @@
field="employeeJobLevel" field="employeeJobLevel"
v-bind="itemColProps" v-bind="itemColProps"
> >
<a-input <a-select
v-model="model.jobLevel" v-model="model.jobLevel"
allow-clear
allow-search
:loading="jobLevelDictLoading"
:placeholder="t('system.employee.form.jobLevelPlaceholder')" :placeholder="t('system.employee.form.jobLevelPlaceholder')"
/> >
<a-option
v-for="item in jobLevelOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
@ -298,10 +320,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getDictListAll } from '@/api/system/dict'
import type { EmployeeFormState } from '@/types/employee-profile' import type { EmployeeFormState } from '@/types/employee-profile'
const EMPLOYEE_POSITION_DICT_TYPE = 'employee_position'
const EMPLOYEE_JOB_LEVEL_DICT_TYPE = 'employee_job_level'
const { t } = useI18n() const { t } = useI18n()
const props = withDefaults( const props = withDefaults(
@ -317,6 +343,64 @@ const props = withDefaults(
const model = defineModel<EmployeeFormState>({ required: true }) const model = defineModel<EmployeeFormState>({ required: true })
type DictOption = { label: string; value: string }
const positionDictLoading = ref(false)
const positionDictRows = ref<DictOption[]>([])
const jobLevelDictLoading = ref(false)
const jobLevelDictRows = ref<DictOption[]>([])
function withCurrentOption(rows: DictOption[], currentRaw: string | undefined | null): DictOption[] {
const list = [...rows]
const current = String(currentRaw || '').trim()
if (current && !list.some((x) => x.value === current)) {
list.unshift({ label: current, value: current })
}
return list
}
const positionOptions = computed(() => withCurrentOption(positionDictRows.value, model.value?.position))
const jobLevelOptions = computed(() => withCurrentOption(jobLevelDictRows.value, model.value?.jobLevel))
function mapDictRows(rows: Awaited<ReturnType<typeof getDictListAll>>): DictOption[] {
return (rows || [])
.slice()
.sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0))
.map((row) => {
const name = String(row.dictName || '').trim()
const code = String(row.dictCode || '').trim()
return { label: name || code, value: name || code }
})
.filter((x) => x.value)
}
async function loadPositionDict() {
positionDictLoading.value = true
try {
positionDictRows.value = mapDictRows(await getDictListAll({ dictType: EMPLOYEE_POSITION_DICT_TYPE }))
} catch {
positionDictRows.value = []
} finally {
positionDictLoading.value = false
}
}
async function loadJobLevelDict() {
jobLevelDictLoading.value = true
try {
jobLevelDictRows.value = mapDictRows(await getDictListAll({ dictType: EMPLOYEE_JOB_LEVEL_DICT_TYPE }))
} catch {
jobLevelDictRows.value = []
} finally {
jobLevelDictLoading.value = false
}
}
onMounted(() => {
void loadPositionDict()
void loadJobLevelDict()
})
const itemColProps = computed(() => const itemColProps = computed(() =>
props.dense props.dense
? {} ? {}

View File

@ -140,6 +140,23 @@
/> />
</a-form-item> </a-form-item>
</a-col> </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> </a-row>
</section> </section>
@ -237,11 +254,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { IconClose, IconPlus } from '@arco-design/web-vue/es/icon' import { IconClose, IconPlus } from '@arco-design/web-vue/es/icon'
import EmployeeProfileFields from '@/components/EmployeeProfileFields/index.vue' import EmployeeProfileFields from '@/components/EmployeeProfileFields/index.vue'
import type { EmployeeFormState } from '@/types/employee-profile' import type { EmployeeFormState } from '@/types/employee-profile'
import type { UserFormExtState } from '@/types/rbac-user-profile' import type { UserFormExtState } from '@/types/rbac-user-profile'
import { resolveAvatarUrl } from '@/utils/avatar' import { resolveAvatarUrl } from '@/utils/avatar'
import { buildAdvisorInviteRegisterUrl } from '@/utils/inviteLink'
import { $baseMessage } from '@/utils' import { $baseMessage } from '@/utils'
import { readFileAsDataUrl } from '@/utils/image-data-url' import { readFileAsDataUrl } from '@/utils/image-data-url'
@ -324,6 +343,21 @@ const employmentStatusText = computed(() => {
return map[props.employeeFormState.employmentStatus] || '' return map[props.employeeFormState.employmentStatus] || ''
}) })
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 onVisibleChange(val: boolean) { function onVisibleChange(val: boolean) {
emit('update:visible', val) emit('update:visible', val)
} }
@ -374,22 +408,28 @@ defineExpose({
<style scoped lang="less"> <style scoped lang="less">
.ue-drawer { .ue-drawer {
--ue-ink: #1a2e38;
--ue-muted: rgba(26, 46, 56, 0.68);
--ue-soft: rgba(26, 46, 56, 0.48);
--ue-radius: 16px;
--ue-edge: var(--app-acrylic-border);
--ue-panel-bg: var(--app-acrylic-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
margin: -16px; margin: -16px;
background: var(--app-page-bg, #f0f3f4);
} }
.ue-drawer__header { .ue-drawer__header {
flex-shrink: 0; flex-shrink: 0;
padding: 20px 24px 16px; padding: 18px 20px 14px;
background: linear-gradient( border-bottom: 1px solid var(--ue-edge);
135deg, background: var(--ue-panel-bg);
color-mix(in srgb, var(--app-primary-color) 12%, var(--app-card-bg)) 0%, backdrop-filter: var(--app-acrylic-blur);
var(--app-card-bg) 58% -webkit-backdrop-filter: var(--app-acrylic-blur);
);
border-bottom: 1px solid var(--color-border-2, var(--color-border-2));
} }
.ue-drawer__title-row { .ue-drawer__title-row {
@ -411,15 +451,15 @@ defineExpose({
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
line-height: 1.35; line-height: 1.35;
font-weight: 600; font-weight: 700;
color: var(--color-text-1, #1d2129); color: var(--ue-ink);
} }
.ue-drawer__desc { .ue-drawer__desc {
margin: 10px 0 0; margin: 8px 0 0;
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.55;
color: var(--color-text-2, #4e5969); color: var(--ue-muted);
} }
.ue-drawer__close { .ue-drawer__close {
@ -428,15 +468,16 @@ defineExpose({
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border: none; border: 1px solid var(--ue-edge);
border-radius: 8px; border-radius: 12px;
background: rgba(0, 0, 0, 0.04); background: var(--ue-panel-bg);
color: var(--color-text-2, #4e5969); color: var(--ue-muted);
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease; transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover { &:hover {
background: color-mix(in srgb, var(--app-primary-color) 12%, var(--app-card-bg)); border-color: color-mix(in srgb, var(--app-primary-color) 22%, var(--ue-edge));
background: var(--app-acrylic-hover-bg);
color: var(--app-primary-color); color: var(--app-primary-color);
} }
} }
@ -445,29 +486,22 @@ defineExpose({
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 16px 24px 8px; padding: 16px 16px 10px;
} }
.ue-preview { .ue-preview {
display: flex; display: flex;
gap: 14px; gap: 14px;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: 14px;
padding: 14px 16px; padding: 14px 16px;
border-radius: 12px; border-radius: var(--ue-radius);
background: border: 1px solid var(--ue-edge);
radial-gradient( background: var(--ue-panel-bg);
120% 140% at 0% 0%, backdrop-filter: var(--app-acrylic-blur);
color-mix(in srgb, #fff 24%, transparent), -webkit-backdrop-filter: var(--app-acrylic-blur);
transparent 55% box-shadow: none;
), color: var(--ue-ink);
linear-gradient(
135deg,
color-mix(in srgb, var(--app-primary-color) 92%, #102020) 0%,
color-mix(in srgb, var(--app-primary-color) 62%, #1c3333) 100%
);
color: #fff;
box-shadow: 0 10px 28px color-mix(in srgb, var(--app-primary-color) 24%, transparent);
} }
.ue-preview__avatar { .ue-preview__avatar {
@ -478,10 +512,12 @@ defineExpose({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.16); flex-shrink: 0;
border: 1px solid var(--ue-edge);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
flex-shrink: 0;
img { img {
width: 100%; width: 100%;
@ -493,14 +529,15 @@ defineExpose({
.ue-preview__name { .ue-preview__name {
margin: 0; margin: 0;
font-size: 17px; font-size: 17px;
font-weight: 650; font-weight: 700;
line-height: 1.3; line-height: 1.3;
color: var(--ue-ink);
} }
.ue-preview__account { .ue-preview__account {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
opacity: 0.78; color: var(--ue-soft);
} }
.ue-preview__tags { .ue-preview__tags {
@ -515,19 +552,26 @@ defineExpose({
padding: 2px 8px; padding: 2px 8px;
border-radius: 999px; border-radius: 999px;
font-size: 11px; font-size: 11px;
background: rgba(255, 255, 255, 0.14); border: 1px solid var(--ue-edge);
background: color-mix(in srgb, #fff 55%, transparent);
color: var(--ue-muted);
&--accent { &--accent {
background: color-mix(in srgb, #fff 22%, transparent); border-color: color-mix(in srgb, var(--app-primary-color) 22%, var(--ue-edge));
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
} }
} }
.ue-section { .ue-section {
margin-bottom: 16px; margin-bottom: 14px;
padding: 14px 14px 4px; padding: 14px 14px 6px;
border-radius: 10px; border-radius: var(--ue-radius);
background: var(--color-fill-2); border: 1px solid var(--ue-edge);
border: 1px solid var(--color-border-2, var(--color-border-2)); background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
box-shadow: none;
} }
.ue-section__head { .ue-section__head {
@ -541,27 +585,28 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 8px; border-radius: 12px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: var(--app-primary-color); color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, var(--app-card-bg)); border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--ue-edge));
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
} }
.ue-section__title { .ue-section__title {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 700;
color: var(--color-text-1, #1d2129); color: var(--ue-ink);
} }
.ue-section__hint { .ue-section__hint {
margin: 2px 0 0; margin: 2px 0 0;
font-size: 12px; font-size: 12px;
color: var(--color-text-3, #86909c); color: var(--ue-soft);
line-height: 1.4; line-height: 1.4;
} }
@ -573,7 +618,7 @@ defineExpose({
:deep(.arco-form-item-label) { :deep(.arco-form-item-label) {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--color-text-1, #1d2129); color: var(--ue-ink);
} }
:deep(.arco-input-wrapper), :deep(.arco-input-wrapper),
@ -582,7 +627,9 @@ defineExpose({
:deep(.arco-textarea-wrapper), :deep(.arco-textarea-wrapper),
:deep(.arco-input-number), :deep(.arco-input-number),
:deep(.arco-picker) { :deep(.arco-picker) {
border-radius: 6px; border-radius: 12px;
border-color: color-mix(in srgb, var(--ue-edge) 70%, #d0d5dd);
background: color-mix(in srgb, #fff 72%, transparent);
} }
} }
@ -596,14 +643,39 @@ defineExpose({
gap: 4px; 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;
border-radius: 12px;
}
.ue-invite-hint {
color: var(--ue-soft);
font-size: 12px;
line-height: 1.5;
}
.ue-drawer__footer { .ue-drawer__footer {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
padding: 14px 24px 18px; padding: 14px 16px 16px;
border-top: 1px solid var(--color-border-2, var(--color-border-2)); border-top: 1px solid var(--ue-edge);
background: var(--app-card-bg); background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
} }
</style> </style>
@ -617,6 +689,11 @@ defineExpose({
padding: 16px; padding: 16px;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--app-page-bg, #f0f3f4);
}
.arco-drawer {
background: var(--app-page-bg, #f0f3f4);
} }
} }
</style> </style>

View File

@ -2763,6 +2763,12 @@ export default {
'system.user.form.avatarPlaceholder': 'Enter avatar URL', 'system.user.form.avatarPlaceholder': 'Enter avatar URL',
'system.user.form.roles': 'Roles', 'system.user.form.roles': 'Roles',
'system.user.form.rolesPlaceholder': 'Select roles', 'system.user.form.rolesPlaceholder': 'Select roles',
'system.user.form.advisorInviteLink': 'New customer invite link',
'system.user.form.advisorInviteHint':
'Customers who register via this link are auto-bound to this account as their advisor',
'system.user.actions.copyInviteLink': 'Copy link',
'system.user.message.copyInviteSuccess': 'Invite link copied',
'system.user.message.copyInviteFailed': 'Copy failed; please select the link manually',
'system.user.form.phone': 'Phone', 'system.user.form.phone': 'Phone',
'system.user.form.phonePlaceholder': 'Enter phone number', 'system.user.form.phonePlaceholder': 'Enter phone number',
'system.user.form.employeeNo': 'Employee No.', 'system.user.form.employeeNo': 'Employee No.',
@ -2799,9 +2805,9 @@ export default {
'system.employee.form.department': 'Department', 'system.employee.form.department': 'Department',
'system.employee.form.departmentPlaceholder': 'Enter department', 'system.employee.form.departmentPlaceholder': 'Enter department',
'system.employee.form.position': 'Position', 'system.employee.form.position': 'Position',
'system.employee.form.positionPlaceholder': 'Enter position', 'system.employee.form.positionPlaceholder': 'Select position',
'system.employee.form.jobLevel': 'Job Level', 'system.employee.form.jobLevel': 'Job Level',
'system.employee.form.jobLevelPlaceholder': 'Enter job level', 'system.employee.form.jobLevelPlaceholder': 'Select job level',
'system.employee.form.employmentType': 'Employment Type', 'system.employee.form.employmentType': 'Employment Type',
'system.employee.form.workLocation': 'Work Location', 'system.employee.form.workLocation': 'Work Location',
'system.employee.form.workLocationPlaceholder': 'Enter work location', 'system.employee.form.workLocationPlaceholder': 'Enter work location',

View File

@ -2687,6 +2687,11 @@ export default {
'system.user.form.avatarPlaceholder': '请输入头像URL', 'system.user.form.avatarPlaceholder': '请输入头像URL',
'system.user.form.roles': '角色', 'system.user.form.roles': '角色',
'system.user.form.rolesPlaceholder': '请选择角色', 'system.user.form.rolesPlaceholder': '请选择角色',
'system.user.form.advisorInviteLink': '新客邀请链接',
'system.user.form.advisorInviteHint': '客户通过此链接注册后,自动绑定当前账号为所属顾问',
'system.user.actions.copyInviteLink': '复制链接',
'system.user.message.copyInviteSuccess': '邀请链接已复制',
'system.user.message.copyInviteFailed': '复制失败,请手动选择链接',
'system.user.form.phone': '电话', 'system.user.form.phone': '电话',
'system.user.form.phonePlaceholder': '请输入电话', 'system.user.form.phonePlaceholder': '请输入电话',
'system.user.form.employeeNo': '工号', 'system.user.form.employeeNo': '工号',
@ -2721,9 +2726,9 @@ export default {
'system.employee.form.department': '部门', 'system.employee.form.department': '部门',
'system.employee.form.departmentPlaceholder': '请输入部门', 'system.employee.form.departmentPlaceholder': '请输入部门',
'system.employee.form.position': '岗位', 'system.employee.form.position': '岗位',
'system.employee.form.positionPlaceholder': '请输入岗位', 'system.employee.form.positionPlaceholder': '请选择岗位',
'system.employee.form.jobLevel': '职级', 'system.employee.form.jobLevel': '职级',
'system.employee.form.jobLevelPlaceholder': '请输入职级', 'system.employee.form.jobLevelPlaceholder': '请选择职级',
'system.employee.form.employmentType': '用工类型', 'system.employee.form.employmentType': '用工类型',
'system.employee.form.workLocation': '工作地点', 'system.employee.form.workLocation': '工作地点',
'system.employee.form.workLocationPlaceholder': '请输入工作地点', 'system.employee.form.workLocationPlaceholder': '请输入工作地点',

View File

@ -0,0 +1,99 @@
/** 顾问邀请归因暂存:7 天 last-touch(与客户邀请码并列,参数名为 advisor) */
const ADVISOR_INVITE_STORAGE_KEY = 'oms_advisor_invite_v1'
const ADVISOR_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
type AdvisorInvitePayload = {
code: string
ts: number
}
function safeParse(raw: string | null): AdvisorInvitePayload | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as AdvisorInvitePayload
if (!parsed?.code || typeof parsed.ts !== 'number') return null
return { code: String(parsed.code).trim(), ts: parsed.ts }
} catch {
return null
}
}
function isExpired(ts: number, now = Date.now()): boolean {
return now - ts > ADVISOR_INVITE_TTL_MS
}
export function stashAdvisorInvite(advisorCode?: string | null): void {
const code = String(advisorCode ?? '').trim()
if (!code) return
const payload: AdvisorInvitePayload = { code, ts: Date.now() }
const raw = JSON.stringify(payload)
try {
localStorage.setItem(ADVISOR_INVITE_STORAGE_KEY, raw)
} catch {
/* ignore */
}
try {
sessionStorage.setItem(ADVISOR_INVITE_STORAGE_KEY, raw)
} catch {
/* ignore */
}
}
export function readStashedAdvisorInvite(): string {
const now = Date.now()
const fromSession = safeParse(
(() => {
try {
return sessionStorage.getItem(ADVISOR_INVITE_STORAGE_KEY)
} catch {
return null
}
})(),
)
if (fromSession && !isExpired(fromSession.ts, now)) {
return fromSession.code
}
const fromLocal = safeParse(
(() => {
try {
return localStorage.getItem(ADVISOR_INVITE_STORAGE_KEY)
} catch {
return null
}
})(),
)
if (fromLocal && !isExpired(fromLocal.ts, now)) {
stashAdvisorInvite(fromLocal.code)
return fromLocal.code
}
if (fromLocal || fromSession) {
clearStashedAdvisorInvite()
}
return ''
}
export function clearStashedAdvisorInvite(): void {
try {
localStorage.removeItem(ADVISOR_INVITE_STORAGE_KEY)
} catch {
/* ignore */
}
try {
sessionStorage.removeItem(ADVISOR_INVITE_STORAGE_KEY)
} catch {
/* ignore */
}
}
/** 解析顾问邀请:query.advisor → 7 天暂存 */
export function resolveAdvisorInvite(query: Record<string, unknown>): string {
const fromQuery = String(query.advisor ?? '').trim()
if (fromQuery) {
stashAdvisorInvite(fromQuery)
return fromQuery
}
return readStashedAdvisorInvite()
}

View File

@ -6,6 +6,16 @@ export function buildInviteRegisterUrl(inviteCode: string, origin = window.locat
return `${origin}/wechat/register?ref=${encodeURIComponent(code)}`; return `${origin}/wechat/register?ref=${encodeURIComponent(code)}`;
} }
/** 顾问新客邀请落地页:携带 ?advisor=员工账号,注册后绑定所属顾问 */
export function buildAdvisorInviteRegisterUrl(
advisorAccount: string,
origin = window.location.origin,
): string {
const account = advisorAccount.trim();
if (!account) return '';
return `${origin}/wechat/register?advisor=${encodeURIComponent(account)}`;
}
/** 复制/分享用话术 */ /** 复制/分享用话术 */
export function buildInviteShareText(inviteCode: string, inviteUrl: string): string { export function buildInviteShareText(inviteCode: string, inviteUrl: string): string {
const code = inviteCode.trim(); const code = inviteCode.trim();

View File

@ -1,7 +1,7 @@
import type { OrganizationTreeDto } from '@/api/rbac' import type { OrganizationTreeDto } from '@/api/rbac'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types' import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
/** 岗位为末级,不可再挂下级;公司/部门/小组均可新增子组织 */ /** 公司/部门/小组均可新增子组织(历史 type=4「岗位」节点视为末级,兼容旧数据) */
export function canOrganizationHaveChildren(type?: number | null): boolean { export function canOrganizationHaveChildren(type?: number | null): boolean {
return type !== 4 return type !== 4
} }

View File

@ -1,11 +1,10 @@
import type { OrganizationTreeDto } from '@/api/rbac'; import type { OrganizationTreeDto } from '@/api/rbac';
/** 组织类型 → 编码前缀 */ /** 组织类型 → 编码前缀(1 公司 / 2 部门 / 3 小组;岗位走员工档案字典,不作为组织类型) */
export const ORGANIZATION_TYPE_CODES: Record<number, string> = { export const ORGANIZATION_TYPE_CODES: Record<number, string> = {
1: 'GS', // 公司 1: 'GS', // 公司
2: 'BM', // 部门 2: 'BM', // 部门
3: 'XZ', // 小组 3: 'XZ', // 小组
4: 'GW', // 岗位
}; };
export function getOrganizationTypeCode(type?: number | null): string { export function getOrganizationTypeCode(type?: number | null): string {

View File

@ -81,6 +81,11 @@ import type { FieldRule, FormInstance } from '@arco-design/web-vue'
import { CustomerService } from '@/api/customer' import { CustomerService } from '@/api/customer'
import { validateInviteCode } from '@/api/marketing/portal' import { validateInviteCode } from '@/api/marketing/portal'
import { isLetterAndDigitPassword } from '@/utils/customerPassword' import { isLetterAndDigitPassword } from '@/utils/customerPassword'
import {
clearStashedAdvisorInvite,
resolveAdvisorInvite,
stashAdvisorInvite,
} from '@/utils/advisorInviteStorage'
import { stashInviteRef } from '@/utils/inviteRefStorage' import { stashInviteRef } from '@/utils/inviteRefStorage'
import { isTimeoutError, withTimeout } from '@/utils/withTimeout' import { isTimeoutError, withTimeout } from '@/utils/withTimeout'
@ -94,6 +99,7 @@ const router = useRouter()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const submitting = ref(false) const submitting = ref(false)
const inviteCode = ref('') const inviteCode = ref('')
const advisorCode = ref('')
const referrerInfo = ref<Record<string, unknown> | null>(null) const referrerInfo = ref<Record<string, unknown> | null>(null)
const referrerValidated = ref<boolean | null>(null) const referrerValidated = ref<boolean | null>(null)
const referrerLoading = ref(false) const referrerLoading = ref(false)
@ -189,9 +195,11 @@ async function handleSubmit() {
...(inviteCode.value && referrerValidated.value ...(inviteCode.value && referrerValidated.value
? ({ inviteCode: inviteCode.value } as Record<string, string>) ? ({ inviteCode: inviteCode.value } as Record<string, string>)
: {}), : {}),
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
}) })
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage') const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
clearStashedInviteRef() clearStashedInviteRef()
clearStashedAdvisorInvite()
Message.success(t('marketing.register.success')) Message.success(t('marketing.register.success'))
router.replace({ name: 'login', query: inviteCode.value ? {} : undefined }) router.replace({ name: 'login', query: inviteCode.value ? {} : undefined })
} catch (e: any) { } catch (e: any) {
@ -208,6 +216,11 @@ function goLogin() {
onMounted(() => { onMounted(() => {
const ref = String(route.query.ref ?? route.query.invite ?? '').trim() const ref = String(route.query.ref ?? route.query.invite ?? '').trim()
if (ref) void loadReferrer(ref) if (ref) void loadReferrer(ref)
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>)
if (advisor) {
advisorCode.value = advisor
stashAdvisorInvite(advisor)
}
}) })
</script> </script>

View File

@ -102,6 +102,8 @@ export const OMS_DICT_TYPE_REMARKS: Record<string, string> = {
secret_level: '密级', secret_level: '密级',
data_scope: '数据范围', data_scope: '数据范围',
org_scope: '组织范围', org_scope: '组织范围',
employee_position: '员工岗位',
employee_job_level: '员工职级',
} }
/** 按预置种子中的 dictType 生成类型备注行(含排序) */ /** 按预置种子中的 dictType 生成类型备注行(含排序) */
@ -469,6 +471,32 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['1', '仅本级'], ['1', '仅本级'],
['2', '本级及下级'], ['2', '本级及下级'],
]), ]),
...pack('employee_position', [
['generalManager', '总经理'],
['deputyGeneralManager', '副总经理'],
['logisticsAdvisor', '物流顾问'],
['csManager', '客服主管'],
['csSpecialist', '客服专员'],
['opsManager', '运营经理'],
['opsSpecialist', '运营专员'],
['financeManager', '财务经理'],
['financeSpecialist', '财务专员'],
['warehouseManager', '仓储主管'],
['warehouseClerk', '仓管员'],
['customsSpecialist', '关务专员'],
['productManager', '产品经理'],
['techEngineer', '技术工程师'],
['hrAdmin', '人事行政'],
['marketingSpecialist', '市场专员'],
['dispatcher', '调度员'],
]),
...pack('employee_job_level', [
['1', '一级'],
['2', '二级'],
['3', '三级'],
['4', '四级'],
['5', '五级'],
]),
] ]
/** 与库表唯一约束一致:dictType + dictCode */ /** 与库表唯一约束一致:dictType + dictCode */

View File

@ -174,7 +174,6 @@
<a-option :value="1">{{ t('system.organization.form.type.company') }}</a-option> <a-option :value="1">{{ t('system.organization.form.type.company') }}</a-option>
<a-option :value="2">{{ t('system.organization.form.type.department') }}</a-option> <a-option :value="2">{{ t('system.organization.form.type.department') }}</a-option>
<a-option :value="3">{{ t('system.organization.form.type.group') }}</a-option> <a-option :value="3">{{ t('system.organization.form.type.group') }}</a-option>
<a-option :value="4">{{ t('system.organization.form.type.position') }}</a-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item :label="t('system.organization.form.leader')" field="leaderId"> <a-form-item :label="t('system.organization.form.leader')" field="leaderId">
@ -410,7 +409,6 @@ function getTypeName(type?: number) {
1: t('system.organization.form.type.company'), 1: t('system.organization.form.type.company'),
2: t('system.organization.form.type.department'), 2: t('system.organization.form.type.department'),
3: t('system.organization.form.type.group'), 3: t('system.organization.form.type.group'),
4: t('system.organization.form.type.position'),
} }
return names[type || 0] || t('system.organization.type.unknown') return names[type || 0] || t('system.organization.type.unknown')
} }
@ -434,7 +432,8 @@ function handleOrgSearch() {
} }
function customCellRenderer(_row: number, col: number, _value: any, rowData: UserDto): string | null { function customCellRenderer(_row: number, col: number, _value: any, rowData: UserDto): string | null {
const dataColIndex = col - 1 // HandsontablePro 列序:操作 → 序号 → 数据;showActionColumn 时 dataColStart=2
const dataColIndex = col - 2
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const key = hotColumns.value[dataColIndex].key const key = hotColumns.value[dataColIndex].key

View File

@ -177,7 +177,8 @@ const hotColumns = computed(() => [
]) ])
function customCellRenderer(_row: number, col: number, _value: any, rowData: UserDto): string | null { function customCellRenderer(_row: number, col: number, _value: any, rowData: UserDto): string | null {
const dataColIndex = col - 1 // HandsontablePro 列序:操作 → 序号 → 数据;showActionColumn 时 dataColStart=2
const dataColIndex = col - 2
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const key = hotColumns.value[dataColIndex].key const key = hotColumns.value[dataColIndex].key

View File

@ -345,8 +345,11 @@ function isCustomerPortalUser(u: any, excludedUserIds?: Set<string>): boolean {
} }
function mapUserToSelectOption(u: any): SelectOption | null { function mapUserToSelectOption(u: any): SelectOption | null {
const label = getUserDisplayName(u) const account = String(u?.account ?? u?.Account ?? '').trim()
return label ? { value: label, label } : null if (!account) return null
const name = getUserDisplayName(u)
const label = name && name !== account ? `${name}(${account})` : account
return { value: account, label }
} }
function getUserDisplayName(u: any): string { function getUserDisplayName(u: any): string {
@ -362,14 +365,43 @@ function getUserDisplayName(u: any): string {
) )
} }
/** 表单展示/提交用姓名;若历史数据存的是用户 id,则反查姓名 */ /** 表单 value 存员工 Account;兼容历史姓名 / id */
function resolveStaffNameForForm(value: string | number | undefined): string { function resolveStaffAccountForForm(value: string | number | undefined): string {
if (value == null || value === '') return '' if (value == null || value === '') return ''
const str = String(value) const str = String(value).trim()
const byName = userOptions.value.find((o) => o.label === str || String(o.value) === str) const byAccount = userOptions.value.find((o) => String(o.value) === str)
if (byName) return String(byName.value) if (byAccount) return String(byAccount.value)
const user = userList.value.find((u) => String(u?.id ?? u?.Id) === str) const byLabel = userOptions.value.find((o) => o.label === str || String(o.label).includes(`(${str})`))
if (user) return getUserDisplayName(user) if (byLabel) return String(byLabel.value)
const user = userList.value.find(
(u) =>
String(u?.account ?? u?.Account ?? '') === str ||
String(u?.id ?? u?.Id ?? '') === str ||
getUserDisplayName(u) === str,
)
if (user) {
const account = String(user?.account ?? user?.Account ?? '').trim()
if (account) return account
}
return str
}
function formatStaffDisplay(value: string | number | undefined): string {
if (value == null || value === '') return '--'
const str = String(value).trim()
const opt = userOptions.value.find((o) => String(o.value) === str)
if (opt?.label) return String(opt.label)
const user = userList.value.find(
(u) =>
String(u?.account ?? u?.Account ?? '') === str ||
getUserDisplayName(u) === str,
)
if (user) {
const account = String(user?.account ?? user?.Account ?? '').trim()
const name = getUserDisplayName(user)
if (account && name && name !== account) return `${name}(${account})`
return name || account || str
}
return str return str
} }
@ -615,6 +647,12 @@ function customCellRenderer(row: number, col: number, value: any, rowData: any):
if (val === undefined || val === null) return '--' if (val === undefined || val === null) return '--'
return Number(val).toLocaleString() return Number(val).toLocaleString()
} }
if (colKey === 'salesPerson') {
return formatStaffDisplay(rowData?.salesPerson ?? rowData?.salesman)
}
if (colKey === 'customerService') {
return formatStaffDisplay(rowData?.customerService)
}
if (colKey === 'createTime') return rowData?.createTime ?? rowData?.createdAt ?? '--' if (colKey === 'createTime') return rowData?.createTime ?? rowData?.createdAt ?? '--'
if (colKey === 'updateTime') return rowData?.updateTime ?? '--' if (colKey === 'updateTime') return rowData?.updateTime ?? '--'
if (colKey === 'lastOnlineTime') return rowData?.lastOnlineTime ?? rowData?.lastLoginTime ?? '--' if (colKey === 'lastOnlineTime') return rowData?.lastOnlineTime ?? rowData?.lastLoginTime ?? '--'
@ -802,8 +840,8 @@ function handleEdit(record: CustomerRow) {
memberLevelId: record.memberLevelId ?? undefined, memberLevelId: record.memberLevelId ?? undefined,
priceTierKey: record.priceTierKey || '普通客户', priceTierKey: record.priceTierKey || '普通客户',
remark: record.remark, remark: record.remark,
salesPerson: resolveStaffNameForForm(record.salesPerson ?? record.salesman), salesPerson: resolveStaffAccountForForm(record.salesPerson ?? record.salesman),
customerService: resolveStaffNameForForm(record.customerService), customerService: resolveStaffAccountForForm(record.customerService),
}) })
modalVisible.value = true modalVisible.value = true
} }
@ -884,14 +922,14 @@ async function handleModalOk(): Promise<boolean> {
} }
modalLoading.value = true modalLoading.value = true
try { try {
const salesPersonName = resolveStaffNameForForm(formState.salesPerson) const salesPersonAccount = resolveStaffAccountForForm(formState.salesPerson)
const customerServiceName = resolveStaffNameForForm(formState.customerService) const customerServiceAccount = resolveStaffAccountForForm(formState.customerService)
if (isEdit.value && currentId.value != null && currentId.value !== '') { if (isEdit.value && currentId.value != null && currentId.value !== '') {
await CustomerService.update(currentId.value, { await CustomerService.update(currentId.value, {
...formState, ...formState,
salesPerson: salesPersonName, salesPerson: salesPersonAccount,
customerService: customerServiceName, customerService: customerServiceAccount,
}) })
Message.success(t('user.customer.message.updateSuccess')) Message.success(t('user.customer.message.updateSuccess'))
} else { } else {
@ -899,8 +937,8 @@ async function handleModalOk(): Promise<boolean> {
...formState, ...formState,
customerCode: undefined, customerCode: undefined,
password: formState.password, password: formState.password,
salesPerson: salesPersonName, salesPerson: salesPersonAccount,
customerService: customerServiceName, customerService: customerServiceAccount,
}) })
Message.success(t('user.customer.message.createSuccess')) Message.success(t('user.customer.message.createSuccess'))
currentPage.value = 1 currentPage.value = 1

View File

@ -211,6 +211,11 @@ import {
resolveWeChatInviteRef, resolveWeChatInviteRef,
stashWeChatInviteRef, stashWeChatInviteRef,
} from '@/utils/wechatOAuth'; } from '@/utils/wechatOAuth';
import {
clearStashedAdvisorInvite,
resolveAdvisorInvite,
stashAdvisorInvite,
} from '@/utils/advisorInviteStorage';
import { buildInviteRegisterUrl } from '@/utils/inviteLink'; import { buildInviteRegisterUrl } from '@/utils/inviteLink';
import { isTimeoutError, withTimeout } from '@/utils/withTimeout'; import { isTimeoutError, withTimeout } from '@/utils/withTimeout';
import { isWeChatBrowser, hideWeChatBrowserNav } from '@/utils/device'; import { isWeChatBrowser, hideWeChatBrowserNav } from '@/utils/device';
@ -295,6 +300,7 @@ const loadingHint = ref('');
const authModalVisible = ref(false); const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null); const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
const inviteCode = ref(''); const inviteCode = ref('');
const advisorCode = ref('');
const referrerValidated = ref<boolean | null>(null); const referrerValidated = ref<boolean | null>(null);
const referrerInfo = ref<{ name: string; code: string } | null>(null); const referrerInfo = ref<{ name: string; code: string } | null>(null);
let loadingSlowTimer: number | undefined; let loadingSlowTimer: number | undefined;
@ -417,6 +423,7 @@ async function handleSubmit() {
customerName: formState.customerName.trim(), customerName: formState.customerName.trim(),
password: formState.password, password: formState.password,
...(canBindInvite ? { inviteCode: inviteCode.value } : {}), ...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
wechat: { wechat: {
openid: wxUserInfo.value.openid, openid: wxUserInfo.value.openid,
nickname: wxUserInfo.value.nickname, nickname: wxUserInfo.value.nickname,
@ -425,6 +432,7 @@ async function handleSubmit() {
}, },
}); });
clearStashedWeChatInviteRef(); clearStashedWeChatInviteRef();
clearStashedAdvisorInvite();
Message.success(t('wechat.register.success')); Message.success(t('wechat.register.success'));
const account = String(result?.customerCode || '').trim(); const account = String(result?.customerCode || '').trim();
const loggedIn = account const loggedIn = account
@ -454,6 +462,11 @@ onMounted(() => {
if (refCode) { if (refCode) {
void loadReferrer(refCode); void loadReferrer(refCode);
} }
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>);
if (advisor) {
advisorCode.value = advisor;
stashAdvisorInvite(advisor);
}
const code = route.query.code; const code = route.query.code;
if (typeof code !== 'string' || !code) { if (typeof code !== 'string' || !code) {