注册经 ?advisor= 绑定所属顾问(Account);用户抽屉展示邀请链接;岗位/职级走字典;去掉组织类型岗位。 Co-authored-by: Cursor <cursoragent@cursor.com>
1061 lines
35 KiB
Vue
1061 lines
35 KiB
Vue
<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>
|