front/src/views/user/customer/index.vue
zzh a703d1efdd feat: 顾问新客邀请链接与岗位职级字典
注册经 ?advisor= 绑定所属顾问(Account);用户抽屉展示邀请链接;岗位/职级走字典;去掉组织类型岗位。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:45:13 +08:00

1061 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="customer-page">
<div class="customer-page-card">
<!-- 表格工具栏:左侧筛选 + 新增,右侧分页 -->
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="12">
<a-button class="btn-add app-btn-primary-soft" @click="handleAdd">{{ t('user.customer.actions.add') }}
</a-button>
<a-input
v-model="filterState.keyword"
allow-clear
:placeholder="t('user.customer.searchPlaceholder')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-select
v-model="filterState.salesman"
:placeholder="t('user.customer.fields.salesPerson')"
allow-clear
allow-search
style="width: 160px"
>
<a-option
v-for="option in userOptions"
:key="`sales-${option.value}`"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
<a-select
v-model="filterState.customerService"
:placeholder="t('user.customer.fields.customerService')"
allow-clear
allow-search
style="width: 160px"
>
<a-option
v-for="option in userOptions"
:key="`cs-${option.value}`"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
<a-button class="app-btn-search" @click="handleSearch">
{{ t('user.customer.actions.search') }}
</a-button>
<a-button @click="handleResetFilter">
{{ t('user.customer.actions.reset') }}
</a-button>
</a-space>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<!-- 列表区域:固定高度 + mousedown 捕获,避免状态开关与 Handsontable 抢事件 -->
<div ref="tableContainerRef" class="list-wrap" @mousedown.capture="handleTableMouseDown">
<HandsontablePro
ref="handsontableRef"
:columns="hotColumns"
:data="processedTableData"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:search-params="headerSearchParams"
sort-mode="remote"
:sort-field="listSortField === 'balance' ? 'accountBalance' : listSortField"
:sort-order="listSortOrder"
@search="handleHeaderSearch"
@sort-change="handleSortChange"
@action-click="handleActionClick"
/>
</div>
</div>
<!-- 编辑弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? t('user.customer.modal.editTitle') : t('user.customer.modal.addTitle')"
:ok-loading="modalLoading"
width="640px"
@before-ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item v-if="isEdit" :label="t('user.customer.fields.customerCode')" field="customerCode">
<a-input
v-model="formState.customerCode"
:placeholder="t('user.customer.placeholder.input')"
disabled
/>
</a-form-item>
<a-form-item v-else>
<template #label>{{ t('user.customer.fields.customerCode') }}</template>
<span class="customer-code-auto-hint">{{ t('user.customer.hint.autoCustomerCode') }}</span>
</a-form-item>
<a-form-item :label="t('user.customer.fields.customerName')" field="customerName">
<a-input v-model="formState.customerName" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('user.customer.fields.wechatAvatarUrl')" field="wechatAvatarUrl">
<a-input v-model="formState.wechatAvatarUrl" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('user.customer.fields.wechatNickname')" field="wechatNickname">
<a-input v-model="formState.wechatNickname" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('user.customer.fields.email')" field="email">
<a-input v-model="formState.email" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('user.customer.fields.phone')" field="phone">
<a-input v-model="formState.phone" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item v-if="!isEdit" :label="t('user.customer.fields.password')" field="password">
<a-input-password v-model="formState.password" :placeholder="t('user.customer.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('user.customer.fields.accountBalance')" field="accountBalance">
<a-input-number
v-model="formState.accountBalance"
:placeholder="t('user.customer.placeholder.input')"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.totalQuota')" field="totalQuota">
<a-input-number
v-model="formState.totalQuota"
:placeholder="t('user.customer.placeholder.input')"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.usedQuota')" field="usedQuota">
<a-input-number
v-model="formState.usedQuota"
:placeholder="t('user.customer.placeholder.input')"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.status')" field="status">
<a-select
v-model="formState.status"
:placeholder="t('user.customer.placeholder.select')"
allow-clear
>
<a-option :value="CUSTOMER_STATUS.normal">{{ t('user.customer.status.enabled') }}</a-option>
<a-option :value="CUSTOMER_STATUS.disabled">{{ t('user.customer.status.disabled') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('user.customer.fields.memberLevel')" field="memberLevelId">
<a-select
v-model="formState.memberLevelId"
:placeholder="t('user.customer.placeholder.select')"
allow-clear
>
<a-option
v-for="option in memberLevelOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('user.customer.fields.priceTierKey')" field="priceTierKey">
<a-select
v-model="formState.priceTierKey"
:options="priceTierOptions"
allow-search
allow-create
allow-clear
:placeholder="t('user.customer.placeholder.select')"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.remark')" field="remark">
<a-textarea
v-model="formState.remark"
:placeholder="t('user.customer.placeholder.input')"
:auto-size="{ minRows: 2 }"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.salesPerson')" field="salesPerson">
<a-select
v-model="formState.salesPerson"
:placeholder="t('user.customer.placeholder.select')"
allow-clear
allow-search
>
<a-option
v-for="option in userOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('user.customer.fields.customerService')" field="customerService">
<a-select
v-model="formState.customerService"
:placeholder="t('user.customer.placeholder.select')"
allow-clear
allow-search
>
<a-option
v-for="option in userOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="resetPwdVisible"
:title="t('user.customer.actions.resetPassword')"
@ok="handleResetPwdSubmit"
@cancel="resetPwdVisible = false"
>
<a-input-password v-model="resetPwdForm.newPassword" :placeholder="t('user.customer.placeholder.input')" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import type { CustomerDto } from '@/api/customer'
import { CustomerService } from '@/api/customer'
import { resolveAvatarUrl } from '@/utils/avatar'
import { RoleService } from '@/api/rbac/generated/services/RoleService'
import { UserService } from '@/api/rbac/generated/services/UserService'
import {
DEFAULT_CUSTOMER_PASSWORD,
isLetterAndDigitPassword,
} from '@/utils/customerPassword'
import {
CUSTOMER_STATUS,
isCustomerStatusNormal,
toCustomerStatus,
} from '@/utils/customerStatus'
import { getMemberLevelOptions } from '@/api/marketing/memberLevel'
import {
adaptCustomerListHeaderFilters,
withServerFilterColumns,
withSortableColumns,
} from '@/utils/headerFilterAdapter'
const { t } = useI18n()
type CustomerRow = CustomerDto & { password?: string }
const { tableContainerRef, tableHeight } = useTableContainerHeight()
/** 列表区固定高度后由 ResizeObserver 得到稳定像素;兜底避免传 0 */
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const tableData = ref<CustomerRow[]>([])
const listLoading = ref(false)
const totalCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(50)
const filterState = reactive({
keyword: '',
salesman: '' as string | undefined,
customerService: '' as string | undefined,
})
const headerSearchParams = reactive<Record<string, string>>({})
const listSortField = ref<string | null>(null)
const listSortOrder = ref<'asc' | 'desc' | null>(null)
const CUSTOMER_SERVER_FILTER_KEYS = [
'customerCode',
'customerName',
'email',
'phone',
'wechatNickname',
'salesPerson',
'customerService',
'status',
] as const
const CUSTOMER_SORTABLE_KEYS = [
'createTime',
'updateTime',
'customerCode',
'customerName',
'accountBalance',
] as const
const processedTableData = computed(() => tableData.value)
type SelectOption = { label: string; value: string | number }
const userOptions = ref<SelectOption[]>([])
const memberLevelOptions = ref<SelectOption[]>([])
const userList = ref<any[]>([])
const CUSTOMER_ROLE_CODE = 'Customer'
function getUserRoleCodes(u: any): string[] {
const roles = u?.roles ?? u?.Roles ?? []
if (!Array.isArray(roles)) return []
return roles
.map((r: any) => String(r?.code ?? r?.Code ?? '').trim())
.filter(Boolean)
}
/** 客户门户账号(Customer / CustomerSub_* 角色)不可作为销售或顾问 */
function isCustomerPortalUser(u: any, excludedUserIds?: Set<string>): boolean {
const id = String(u?.id ?? u?.Id ?? '')
if (id && excludedUserIds?.has(id)) return true
return getUserRoleCodes(u).some(
(code) =>
code.localeCompare(CUSTOMER_ROLE_CODE, undefined, { sensitivity: 'accent' }) === 0 ||
code.startsWith('CustomerSub_')
)
}
function mapUserToSelectOption(u: any): SelectOption | null {
const account = String(u?.account ?? u?.Account ?? '').trim()
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 {
return String(
u?.name ??
u?.nickName ??
u?.realName ??
u?.userName ??
u?.username ??
u?.account ??
u?.id ??
''
)
}
/** 表单 value 存员工 Account;兼容历史姓名 / id */
function resolveStaffAccountForForm(value: string | number | undefined): string {
if (value == null || value === '') return ''
const str = String(value).trim()
const byAccount = userOptions.value.find((o) => String(o.value) === str)
if (byAccount) return String(byAccount.value)
const byLabel = userOptions.value.find((o) => o.label === str || String(o.label).includes(`(${str})`))
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
}
function normalizeListPayload(raw: any): any[] {
if (Array.isArray(raw)) return raw
if (Array.isArray(raw?.items)) return raw.items
if (Array.isArray(raw?.data?.items)) return raw.data.items
if (Array.isArray(raw?.data)) return raw.data
if (raw && typeof raw === 'object') return [raw]
return []
}
async function loadMemberLevelOptions() {
try {
const items = await getMemberLevelOptions()
memberLevelOptions.value = (items || [])
.map((item: any) => {
const id = item.id ?? item.Id
const name = item.levelName ?? item.LevelName
if (id == null || !name) return null
return { value: Number(id), label: String(name) }
})
.filter((item): item is SelectOption => item != null)
} catch {
memberLevelOptions.value = []
}
}
async function loadFormSelectOptions() {
try {
const pageSize = 100
const roles: any[] = []
const users: any[] = []
// 角色全量分页拉取(接口限制 PageSize 1-100)
{
let page = 1
while (true) {
const res = await RoleService.getRbacRoleGetList(undefined, page, pageSize)
const items = normalizeListPayload(res)
roles.push(...items)
if (items.length < pageSize) break
page += 1
}
}
// 用户全量分页拉取(接口限制 PageSize 1-100)
{
let page = 1
while (true) {
const res = await UserService.getRbacUserGetList(undefined, undefined, page, pageSize)
const items = normalizeListPayload(res)
users.push(...items)
if (items.length < pageSize) break
page += 1
}
}
const excludedUserIds = new Set<string>()
const customerRole = roles.find(
(r: any) =>
String(r?.code ?? r?.Code ?? '').localeCompare(CUSTOMER_ROLE_CODE, undefined, {
sensitivity: 'accent',
}) === 0
)
const customerRoleId = customerRole?.id ?? customerRole?.Id
if (customerRoleId) {
try {
const customerUsers = await UserService.getRbacUserGetUsersByRole(String(customerRoleId))
for (const u of normalizeListPayload(customerUsers)) {
const id = String(u?.id ?? u?.Id ?? '')
if (id) excludedUserIds.add(id)
}
} catch {
// 拉取 Customer 角色用户失败时,仍依赖用户列表上的 roles 字段过滤
}
}
userList.value = users
const staffUsers = users.filter((u) => !isCustomerPortalUser(u, excludedUserIds))
userOptions.value = staffUsers
.map(mapUserToSelectOption)
.filter((item): item is SelectOption => item != null)
} catch (e: any) {
Message.error(e?.message || '加载员工用户下拉失败')
userOptions.value = []
userList.value = []
}
}
function resolveCustomerId(record: CustomerRow | any): string | number | undefined {
const raw = record?.id ?? record?.Id
if (raw === undefined || raw === null || raw === '') return undefined
return raw
}
function mapCustomerRow(r: any): CustomerRow {
return {
...r,
id: r.id ?? r.Id,
accountBalance: r.accountBalance ?? r.balance ?? r.Balance,
salesPerson: r.salesPerson ?? r.salesman ?? r.Salesman,
memberLevelId: r.memberLevelId ?? r.MemberLevelId,
memberLevelName: r.memberLevelName ?? r.MemberLevelName,
priceTierKey: r.priceTierKey ?? r.PriceTierKey ?? '',
}
}
async function loadList() {
listLoading.value = true
try {
const adapted = adaptCustomerListHeaderFilters(headerSearchParams)
const keyword = (adapted.keyword || filterState.keyword.trim() || '').trim()
const { items, total } = await CustomerService.getList({
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword || undefined,
salesman: adapted.salesman || filterState.salesman || undefined,
customerService: adapted.customerService || filterState.customerService || undefined,
status: adapted.status as any,
sortField: listSortField.value || undefined,
sortOrder: listSortOrder.value || undefined,
})
tableData.value = (items || []).map(mapCustomerRow)
totalCount.value = total
} catch (e: any) {
Message.error(e?.message || '加载失败')
tableData.value = []
totalCount.value = 0
} finally {
listLoading.value = false
}
}
function handleHeaderSearch() {
currentPage.value = 1
loadList()
}
function handleSortChange(payload: { field: string | null; order: 'asc' | 'desc' | null }) {
listSortField.value = payload.field === 'accountBalance' ? 'balance' : payload.field
listSortOrder.value = payload.order
currentPage.value = 1
loadList()
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handleResetFilter() {
filterState.keyword = ''
filterState.salesman = undefined
filterState.customerService = undefined
Object.keys(headerSearchParams).forEach((k) => {
headerSearchParams[k] = ''
})
listSortField.value = null
listSortOrder.value = null
currentPage.value = 1
loadList()
}
// HandsontablePro 列定义(按 t_customer 表结构)
const hotColumns = computed(() =>
withSortableColumns(
withServerFilterColumns(
[
{ key: 'customerCode', label: t('user.customer.fields.customerCode'), width: 90 },
{ key: 'customerName', label: t('user.customer.fields.customerName'), width: 100 },
{ key: 'wechatAvatarUrl', label: t('user.customer.fields.wechatAvatar'), width: 70 },
{ key: 'wechatNickname', label: t('user.customer.fields.wechatNickname'), width: 100 },
{ key: 'email', label: t('user.customer.fields.email'), width: 140 },
{ key: 'phone', label: t('user.customer.fields.phone'), width: 110 },
{ key: 'accountBalance', label: t('user.customer.fields.accountBalance'), width: 95 },
{ key: 'totalQuota', label: t('user.customer.fields.totalQuota'), width: 95 },
{ key: 'usedQuota', label: t('user.customer.fields.usedQuota'), width: 95 },
{ key: 'status', label: t('user.customer.fields.status'), width: 75 },
{ key: 'memberLevelName', label: t('user.customer.fields.memberLevel'), width: 100 },
{ key: 'priceTierKey', label: t('user.customer.fields.priceTierKey'), width: 100 },
{ key: 'remark', label: t('user.customer.fields.remark'), width: 100 },
{ key: 'salesPerson', label: t('user.customer.fields.salesPerson'), width: 90 },
{ key: 'customerService', label: t('user.customer.fields.customerService'), width: 90 },
{ key: 'createTime', label: t('user.customer.fields.createTime'), width: 150 },
{ key: 'createBy', label: t('user.customer.fields.createBy'), width: 90 },
{ key: 'updateTime', label: t('user.customer.fields.updateTime'), width: 150 },
{ key: 'updateBy', label: t('user.customer.fields.updateBy'), width: 90 },
{ key: 'lastOnlineTime', label: t('user.customer.fields.lastOnlineTime'), width: 150 },
],
CUSTOMER_SERVER_FILTER_KEYS,
(col) =>
col.key === 'status'
? {
filterType: 'enum' as const,
headerMode: 'icon' as const,
filterOptions: [
{ label: t('user.customer.status.enabled'), value: String(CUSTOMER_STATUS.normal) },
{ label: t('user.customer.status.disabled'), value: String(CUSTOMER_STATUS.disabled) },
],
}
: {},
),
CUSTOMER_SORTABLE_KEYS,
),
)
// 自定义单元格渲染(操作列在前,col: 0=操作列, 1=序号, 2..n=数据列)
function customCellRenderer(row: number, col: number, value: any, rowData: any): string | null {
const dataColIndex = col - 2
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const colKey = hotColumns.value[dataColIndex].key
const avatarUrl = resolveAvatarUrl(rowData?.wechatAvatarUrl || rowData?.wechatAvatar)
if (colKey === 'wechatAvatarUrl') {
return `<img src="${avatarUrl}" style="width:32px;height:32px;border-radius:50%;object-fit:cover" />`
}
if (colKey === 'status') {
const s = rowData?.status
const enabled = isCustomerStatusNormal(s)
const text = enabled ? t('user.customer.status.enabled') : t('user.customer.status.disabled')
return `
<label class="customer-status-switch" data-row-index="${row}" style="display:inline-flex;align-items:center;cursor:pointer;margin:0;">
<input type="checkbox" ${enabled ? 'checked' : ''} style="display:none;">
<span class="switch-track" style="position:relative;width:36px;height:20px;background:${enabled ? '#00b42a' : '#e5e6eb'};border-radius:10px;transition:background .2s;">
<span class="switch-thumb" style="position:absolute;top:2px;left:${enabled ? '18px' : '2px'};width:16px;height:16px;background:#fff;border-radius:50%;box-shadow:0 2px 4px rgba(0,0,0,.2);transition:left .2s;"></span>
</span>
</label>
`
}
if (colKey === 'memberLevelName') {
const name = rowData?.memberLevelName
if (name) return String(name)
const levelId = rowData?.memberLevelId
if (levelId != null) {
const option = memberLevelOptions.value.find((o) => o.value === levelId)
return option?.label ?? String(levelId)
}
return '--'
}
if (['accountBalance', 'totalQuota', 'usedQuota'].includes(colKey)) {
const val = rowData?.[colKey] ?? rowData?.balance
if (val === undefined || val === null) return '--'
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 === 'updateTime') return rowData?.updateTime ?? '--'
if (colKey === 'lastOnlineTime') return rowData?.lastOnlineTime ?? rowData?.lastLoginTime ?? '--'
if (colKey === 'createBy') return rowData?.createBy ?? rowData?.createdBy ?? '--'
if (colKey === 'updateBy') return rowData?.updateBy ?? '--'
if (colKey === 'lastOnlineIp') return rowData?.lastOnlineIp ?? rowData?.lastLoginIp ?? '--'
return null
}
// 操作列渲染(统一图标风格)
function actionRenderer(_row: any, rowIndex: number): string {
return wrapWarehouseActions(
[
renderWarehouseActionBtn('edit', t('user.customer.actions.edit')),
renderWarehouseActionBtn('resetPassword', t('user.customer.actions.resetPassword')),
renderWarehouseActionBtn('omsLogin', t('user.customer.actions.omsLogin'), 'login'),
renderWarehouseActionBtn('delete', t('user.customer.actions.delete')),
],
rowIndex,
)
}
/** 捕获阶段统一处理操作列/状态开关,避免 Handsontable 裁切或列错位导致点击无响应 */
function handleTableMouseDown(e: MouseEvent) {
const target = e.target as HTMLElement | null
if (!target) return
const actionEl = target.closest('[data-action]')
if (actionEl?.closest('.wh-action-group')) {
e.preventDefault()
e.stopPropagation()
const action = actionEl.getAttribute('data-action') || undefined
const rowIndex = parseInt(actionEl.getAttribute('data-row-index') || '0', 10)
const record = tableData.value[rowIndex]
if (!record) return
handleActionClick(record, rowIndex, action)
return
}
handleStatusSwitchMouseDown(e)
}
/** 在捕获阶段处理开关,避免 Handsontable 先拦截 mousedown 导致无法切换 / 点击异常 */
async function handleStatusSwitchMouseDown(e: MouseEvent) {
const switchEl = (e.target as HTMLElement | null)?.closest?.('.customer-status-switch')
if (!switchEl) return
e.preventDefault()
e.stopPropagation()
const rowIndex = parseInt(switchEl.getAttribute('data-row-index') || '0', 10)
const record = tableData.value[rowIndex]
const customerId = resolveCustomerId(record)
if (customerId == null) return
const next = toCustomerStatus(!isCustomerStatusNormal(record.status))
try {
await CustomerService.update(customerId, { ...record, status: next as any })
record.status = next as any
Message.success(t('user.customer.message.updateSuccess'))
} catch (err: any) {
Message.error(err?.message || '更新失败')
}
}
// 操作点击
function handleActionClick(row: any, rowIndex: number, action?: string) {
const record = tableData.value[rowIndex] || row
switch (action) {
case 'edit':
handleEdit(record)
break
case 'delete':
handleDelete(resolveCustomerId(record))
break
case 'resetPassword':
handleResetPassword(record)
break
case 'omsLogin':
handleOmsLogin(record)
break
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
// 表单
const modalVisible = ref(false)
const modalLoading = ref(false)
const isEdit = ref(false)
const formRef = ref()
const currentId = ref<string | number | undefined>(undefined)
const formState = reactive<Partial<CustomerRow>>({
customerCode: '',
customerName: '',
wechatAvatarUrl: '',
wechatNickname: '',
email: '',
phone: '',
password: DEFAULT_CUSTOMER_PASSWORD,
accountBalance: undefined,
totalQuota: undefined,
usedQuota: undefined,
status: CUSTOMER_STATUS.normal,
memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '',
salesPerson: '',
customerService: '',
})
const priceTierOptions = [
{ label: '普通会员', value: '普通会员' },
{ label: '普通客户', value: '普通客户' },
{ label: 'VIP1', value: 'VIP1' },
{ label: 'VIP2', value: 'VIP2' },
{ label: 'VIP3', value: 'VIP3' },
]
const formRules = computed(() => ({
customerName: [{ required: true, message: t('user.customer.rules.customerNameRequired') }],
email: [{ required: true, message: t('user.customer.rules.emailRequired') }],
phone: [{ required: true, message: t('user.customer.rules.phoneRequired') }],
password: isEdit.value
? []
: [
{ required: true, message: t('user.customer.rules.passwordRequired') },
{
validator: (value: string, callback: (error?: string) => void) => {
if (!isLetterAndDigitPassword(value)) {
callback(t('user.customer.rules.passwordLetterAndDigit'))
} else {
callback()
}
},
},
],
}))
function handleAdd() {
isEdit.value = false
currentId.value = undefined
Object.assign(formState, {
customerCode: '',
customerName: '',
wechatAvatarUrl: '',
wechatNickname: '',
email: '',
phone: '',
password: DEFAULT_CUSTOMER_PASSWORD,
accountBalance: undefined,
totalQuota: undefined,
usedQuota: undefined,
status: CUSTOMER_STATUS.normal,
memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '',
salesPerson: '',
customerService: '',
})
modalVisible.value = true
}
function handleEdit(record: CustomerRow) {
isEdit.value = true
currentId.value = resolveCustomerId(record)
Object.assign(formState, {
customerCode: record.customerCode,
customerName: record.customerName,
wechatAvatarUrl: record.wechatAvatarUrl ?? record.wechatAvatar,
wechatNickname: record.wechatNickname,
email: record.email,
phone: record.phone,
accountBalance: record.accountBalance ?? record.balance,
totalQuota: record.totalQuota,
usedQuota: record.usedQuota,
status: record.status ?? CUSTOMER_STATUS.normal,
memberLevelId: record.memberLevelId ?? undefined,
priceTierKey: record.priceTierKey || '普通客户',
remark: record.remark,
salesPerson: resolveStaffAccountForForm(record.salesPerson ?? record.salesman),
customerService: resolveStaffAccountForForm(record.customerService),
})
modalVisible.value = true
}
function handleDelete(id: string | number | undefined) {
if (id === undefined || id === null || id === '') {
Message.warning('无法获取客户ID,请刷新列表后重试')
return
}
Modal.confirm({
content: t('user.customer.modal.deleteConfirm'),
onOk: async () => {
try {
await CustomerService.delete(id)
Message.success(t('user.customer.message.deleteSuccess'))
await loadList()
} catch (e: any) {
Message.error(e?.message || t('user.customer.message.deleteFailed'))
throw e
}
},
})
}
const resetPwdVisible = ref(false)
const resetPwdForm = reactive({ newPassword: '' })
const resetPwdTarget = ref<CustomerRow | null>(null)
function handleResetPassword(record: CustomerRow) {
resetPwdTarget.value = record
resetPwdForm.newPassword = ''
resetPwdVisible.value = true
}
async function handleResetPwdSubmit() {
const customerId = resolveCustomerId(resetPwdTarget.value)
if (customerId == null) {
resetPwdVisible.value = false
return
}
if (!resetPwdForm.newPassword?.trim()) {
Message.warning(t('user.customer.rules.passwordRequired'))
return
}
try {
await CustomerService.resetPassword(customerId, resetPwdForm.newPassword.trim())
Message.success(t('user.customer.message.resetPasswordSuccess'))
resetPwdVisible.value = false
} catch (e: any) {
Message.error(e?.message || t('user.customer.message.resetPasswordFailed'))
}
}
async function handleOmsLogin(record: CustomerRow) {
const customerId = resolveCustomerId(record)
if (customerId == null) {
Message.warning('客户ID无效')
return
}
try {
const result = await CustomerService.omsLogin(customerId)
if (!result?.accessToken) {
Message.error('获取客户登录凭证失败')
return
}
const url = CustomerService.buildCustomerSsoUrl(result)
window.open(url, '_blank', 'noopener,noreferrer')
} catch (e: any) {
Message.error(e?.message || 'OMS登录失败')
}
}
async function handleModalOk(): Promise<boolean> {
try {
await formRef.value?.validate()
} catch {
return false
}
modalLoading.value = true
try {
const salesPersonAccount = resolveStaffAccountForForm(formState.salesPerson)
const customerServiceAccount = resolveStaffAccountForForm(formState.customerService)
if (isEdit.value && currentId.value != null && currentId.value !== '') {
await CustomerService.update(currentId.value, {
...formState,
salesPerson: salesPersonAccount,
customerService: customerServiceAccount,
})
Message.success(t('user.customer.message.updateSuccess'))
} else {
await CustomerService.create({
...formState,
customerCode: undefined,
password: formState.password,
salesPerson: salesPersonAccount,
customerService: customerServiceAccount,
})
Message.success(t('user.customer.message.createSuccess'))
currentPage.value = 1
}
await loadList()
return true
} catch (e: any) {
Message.error(e?.message || t('user.customer.message.saveFailed'))
return false
} finally {
modalLoading.value = false
}
}
function handleModalCancel() {
modalVisible.value = false
}
const handsontableRef = ref<InstanceType<typeof HandsontablePro>>()
onMounted(() => {
loadList()
loadFormSelectOptions()
loadMemberLevelOptions()
})
</script>
<style scoped lang="less">
// 与「我的订单」等页一致:整页可纵向滚动,列表区固定高度由 Handsontable .wtHolder 承担滚动
.customer-page {
width: 100%;
box-sizing: border-box;
padding: 16px 16px 24px;
display: flex;
flex-direction: column;
background: var(--app-page-bg);
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
}
.customer-page-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--app-card-bg);
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-sizing: border-box;
overflow: hidden;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: wrap;
gap: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.toolbar-right {
.pagination-controls {
display: flex;
align-items: center;
}
}
}
.list-wrap {
width: 100%;
min-width: 0;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
position: relative;
.customer-status-switch {
user-select: none;
.switch-track:hover {
opacity: 0.9;
}
}
}
/* 仅拉伸容器,不覆盖 Handsontable 根节点 height(避免与 .wtHolder 内部滚动冲突) */
.list-wrap :deep(.handsontable-pro-container) {
width: 100%;
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.customer-code-auto-hint {
color: var(--color-text-3);
font-size: 14px;
line-height: 32px;
}
</style>