20261030 #16
@ -75,7 +75,7 @@ OMS 当前是 **单运营方部署**(非多租户 SaaS):客户挂 `t_custo
|
||||
| 10 | **可见费用** | 代理可见客户侧应收/实付与返佣金额;**不可见** `payable` / 买价 / 毛利 |
|
||||
| 11 | **换绑** | 首版不做自助换绑;运营改 **主客户** `AgentId`(仅影响之后新单) |
|
||||
| 12 | **子客户归属** | **跟主客户**:快照前 `ResolveMainCustomer` |
|
||||
| 13 | **计提触发** | **`deliverySuccess` / `deliveryFailed`** 均计提;同单按 `OrderId` 幂等 |
|
||||
| 13 | **计提触发** | **下单写 `estimated`(预计)**;**`deliverySuccess` / `deliveryFailed` 锁定为 `pending`**;取消/全额退 → `void`;同单按 `OrderId` 幂等 |
|
||||
| 14 | **首版资金形态** | **只写返佣明细,不冻结、不入余额、不提现**;运营按清单线下打款;错账人工改明细(P2+ 再账户化) |
|
||||
|
||||
---
|
||||
@ -170,7 +170,8 @@ OMS 当前是 **单运营方部署**(非多租户 SaaS):客户挂 `t_custo
|
||||
→ 客户经链接注册 → 写 Customer.AgentId
|
||||
→ 客户正常查价 / 下单 / 支付(价格逻辑不变)
|
||||
→ 订单快照 Order.AgentId(跟主客户)
|
||||
→ 派送成功/失败 → 写一条返佣明细(金额快照)
|
||||
→ 下单 → 写预计返佣(estimated)
|
||||
→ 派送成功/失败 → 锁定为待结算(pending,金额重算快照)
|
||||
→ 运营按明细线下打款
|
||||
→(P2+)冻结入账 / 提现 / 毛利基数 / 自动 clawback
|
||||
```
|
||||
|
||||
@ -11,8 +11,10 @@ export interface AgentDto {
|
||||
email?: string
|
||||
status: AgentStatus
|
||||
baseType: string
|
||||
baseTypeName?: string
|
||||
commissionRate: number
|
||||
salesProductIds?: string
|
||||
salesProductNames?: string[]
|
||||
remark?: string
|
||||
createTime?: string
|
||||
}
|
||||
@ -46,6 +48,9 @@ export interface AgentPortalCustomerDto {
|
||||
phone?: string
|
||||
agentBindTime?: string
|
||||
createTime?: string
|
||||
lastOrderTime?: string
|
||||
orderCount30d?: number
|
||||
pendingPaymentCount?: number
|
||||
}
|
||||
|
||||
export interface AgentPortalOrderDto {
|
||||
@ -62,6 +67,47 @@ export interface AgentPortalOrderDto {
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
export interface AgentWorkplaceStats {
|
||||
customerCount: number
|
||||
todayCreated: number
|
||||
yesterdayCreated: number
|
||||
dayOverDayGrowthPercent?: number | null
|
||||
pendingPayment: number
|
||||
shipping: number
|
||||
problematic: number
|
||||
deliverySuccess: number
|
||||
deliveryFailed: number
|
||||
estimatedCommissionCount?: number
|
||||
estimatedCommissionAmount?: number
|
||||
pendingCommissionCount: number
|
||||
pendingCommissionAmount: number
|
||||
paidCommissionAmount: number
|
||||
weekNewCustomers: number
|
||||
counts?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AgentCommissionListQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
createTimeStart?: string
|
||||
createTimeEnd?: string
|
||||
}
|
||||
|
||||
export interface AgentWorkplaceTrendPoint {
|
||||
label: string
|
||||
start?: string
|
||||
end?: string
|
||||
orderCount: number
|
||||
commissionAmount: number
|
||||
}
|
||||
|
||||
export interface AgentWorkplaceTrend {
|
||||
dimension: string
|
||||
points: AgentWorkplaceTrendPoint[]
|
||||
}
|
||||
|
||||
interface PageResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
@ -75,6 +121,26 @@ function unwrapPage<T>(data: any): PageResult<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 匿名校验代理编号(注册页) */
|
||||
export async function validateAgentCode(code: string): Promise<{
|
||||
valid: boolean
|
||||
agentCode?: string
|
||||
agentNameMasked?: string
|
||||
message?: string
|
||||
}> {
|
||||
const r = await axiosInstance.get(omsUrl('/agent/public/validate-code'), {
|
||||
params: { code },
|
||||
})
|
||||
assertBusinessOk(r.data)
|
||||
const raw = (r.data?.data ?? r.data) as Record<string, unknown>
|
||||
return {
|
||||
valid: Boolean(raw.valid ?? raw.Valid),
|
||||
agentCode: String(raw.agentCode ?? raw.AgentCode ?? '').trim() || undefined,
|
||||
agentNameMasked: String(raw.agentNameMasked ?? raw.AgentNameMasked ?? '').trim() || undefined,
|
||||
message: String(raw.message ?? raw.Message ?? '').trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentAdminApi = {
|
||||
async list(body: {
|
||||
page?: number
|
||||
@ -143,11 +209,68 @@ export const AgentPortalApi = {
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
|
||||
async commissions(body: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<PageResult<AgentCommissionDto>> {
|
||||
async commissions(body: AgentCommissionListQuery): Promise<PageResult<AgentCommissionDto>> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/portal/commission/list'), body)
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
|
||||
async exportCommissions(body: AgentCommissionListQuery): Promise<void> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/portal/commission/export'), body, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const blob = r.data instanceof Blob ? r.data : new Blob([r.data])
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `代理返佣明细_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
async workplaceStats(): Promise<AgentWorkplaceStats> {
|
||||
const r = await axiosInstance.get(omsUrl('/agent/portal/workplace/stats'))
|
||||
assertBusinessOk(r.data)
|
||||
const raw = (r.data?.data ?? r.data) as Record<string, unknown>
|
||||
const growthRaw = raw.dayOverDayGrowthPercent ?? raw.DayOverDayGrowthPercent
|
||||
return {
|
||||
customerCount: Number(raw.customerCount ?? raw.CustomerCount ?? 0),
|
||||
todayCreated: Number(raw.todayCreated ?? raw.TodayCreated ?? 0),
|
||||
yesterdayCreated: Number(raw.yesterdayCreated ?? raw.YesterdayCreated ?? 0),
|
||||
dayOverDayGrowthPercent:
|
||||
growthRaw == null || growthRaw === '' ? null : Number(growthRaw),
|
||||
pendingPayment: Number(raw.pendingPayment ?? raw.PendingPayment ?? 0),
|
||||
shipping: Number(raw.shipping ?? raw.Shipping ?? 0),
|
||||
problematic: Number(raw.problematic ?? raw.Problematic ?? 0),
|
||||
deliverySuccess: Number(raw.deliverySuccess ?? raw.DeliverySuccess ?? 0),
|
||||
deliveryFailed: Number(raw.deliveryFailed ?? raw.DeliveryFailed ?? 0),
|
||||
pendingCommissionCount: Number(
|
||||
raw.pendingCommissionCount ?? raw.PendingCommissionCount ?? 0,
|
||||
),
|
||||
pendingCommissionAmount: Number(
|
||||
raw.pendingCommissionAmount ?? raw.PendingCommissionAmount ?? 0,
|
||||
),
|
||||
paidCommissionAmount: Number(raw.paidCommissionAmount ?? raw.PaidCommissionAmount ?? 0),
|
||||
weekNewCustomers: Number(raw.weekNewCustomers ?? raw.WeekNewCustomers ?? 0),
|
||||
counts: (raw.counts ?? raw.Counts) as Record<string, number> | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async workplaceTrend(days = 7): Promise<AgentWorkplaceTrend> {
|
||||
const r = await axiosInstance.get(omsUrl('/agent/portal/workplace/trend'), {
|
||||
params: { days },
|
||||
})
|
||||
assertBusinessOk(r.data)
|
||||
const raw = (r.data?.data ?? r.data) as Record<string, unknown>
|
||||
const pointsRaw = (raw.points ?? raw.Points ?? []) as Array<Record<string, unknown>>
|
||||
return {
|
||||
dimension: String(raw.dimension ?? raw.Dimension ?? 'day'),
|
||||
points: pointsRaw.map((p) => ({
|
||||
label: String(p.label ?? p.Label ?? ''),
|
||||
start: p.start != null || p.Start != null ? String(p.start ?? p.Start) : undefined,
|
||||
end: p.end != null || p.End != null ? String(p.end ?? p.End) : undefined,
|
||||
orderCount: Number(p.orderCount ?? p.OrderCount ?? 0),
|
||||
commissionAmount: Number(p.commissionAmount ?? p.CommissionAmount ?? 0),
|
||||
})),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -34,6 +34,9 @@ export interface CustomerDto {
|
||||
salesman?: string | number
|
||||
salesPerson?: string | number
|
||||
customerService?: string | number
|
||||
agentId?: number | null
|
||||
agentCode?: string
|
||||
agentName?: string
|
||||
customerGroup?: CustomerGroup
|
||||
memberLevelId?: number | null
|
||||
memberLevelName?: string
|
||||
@ -53,6 +56,7 @@ export interface CustomerListParams {
|
||||
customerGroup?: number
|
||||
salesman?: string
|
||||
customerService?: string
|
||||
agentId?: number
|
||||
sortField?: string
|
||||
sortOrder?: string
|
||||
}
|
||||
@ -88,6 +92,8 @@ function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
|
||||
remark: data.remark,
|
||||
salesman: data.salesman ?? data.salesPerson,
|
||||
customerService: data.customerService,
|
||||
// 空串表示解绑;有值则按编号绑定
|
||||
agentCode: data.agentCode !== undefined ? String(data.agentCode ?? '').trim() : undefined,
|
||||
customerGroup: data.customerGroup,
|
||||
memberLevelId: data.memberLevelId,
|
||||
priceTierKey: data.priceTierKey,
|
||||
|
||||
@ -28,6 +28,14 @@ export function mapCustomerRow(r: Record<string, unknown>): CustomerDto {
|
||||
remark: (r.remark ?? r.Remark) as string | undefined,
|
||||
salesPerson: (r.salesPerson ?? r.salesman ?? r.Salesman) as string | number | undefined,
|
||||
customerService: (r.customerService ?? r.CustomerService) as string | number | undefined,
|
||||
agentId: (() => {
|
||||
const raw = r.agentId ?? r.AgentId
|
||||
if (raw == null || raw === '') return null
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
})(),
|
||||
agentCode: (r.agentCode ?? r.AgentCode) as string | undefined,
|
||||
agentName: (r.agentName ?? r.AgentName) as string | undefined,
|
||||
lastOnlineTime: (r.lastOnlineTime ?? r.lastLoginTime ?? r.LastLoginTime) as string | undefined,
|
||||
lastOnlineIp: (r.lastOnlineIp ?? r.lastLoginIp ?? r.LastLoginIp) as string | undefined,
|
||||
createTime: (r.createTime ?? r.CreateTime ?? r.createdAt) as string | undefined,
|
||||
@ -55,6 +63,9 @@ function normalizeCustomerDetail(data: CustomerDto): CustomerDto {
|
||||
...data,
|
||||
accountBalance: data.accountBalance ?? data.balance,
|
||||
salesPerson: data.salesPerson ?? data.salesman,
|
||||
agentId: data.agentId ?? null,
|
||||
agentCode: data.agentCode,
|
||||
agentName: data.agentName,
|
||||
lastOnlineTime: data.lastOnlineTime ?? data.lastLoginTime,
|
||||
lastOnlineIp: data.lastOnlineIp ?? data.lastLoginIp,
|
||||
wechatAvatarUrl: resolveAvatarUrl(data.wechatAvatarUrl ?? data.wechatAvatar),
|
||||
|
||||
@ -1519,6 +1519,8 @@ export interface PriceCheckRequest {
|
||||
postalCode?: string
|
||||
/** 选用的优惠券实例 Id */
|
||||
couponInstanceId?: number
|
||||
/** 代客查价客户(管理端/代理可选;影响会员价与券) */
|
||||
customerId?: number
|
||||
}
|
||||
|
||||
export interface MarketingPriceBreakdown {
|
||||
|
||||
@ -50,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<div class="agent-side-rail__dock">
|
||||
<div class="agent-side-rail__toolbar">
|
||||
<div class="agent-side-rail__account">
|
||||
<div class="agent-side-rail__user">
|
||||
<RightTopUser
|
||||
variant="sider"
|
||||
@ -61,13 +61,18 @@
|
||||
:show-recharge-cta="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="agent-side-rail__agent-meta agent-side-rail__fade">
|
||||
<span class="agent-side-rail__agent-name">{{ agentDisplayName }}</span>
|
||||
<div class="agent-side-rail__account-meta agent-side-rail__fade">
|
||||
<div class="agent-side-rail__account-name" :title="agentDisplayName">
|
||||
{{ agentDisplayName }}
|
||||
</div>
|
||||
<div
|
||||
v-if="agentDisplayCode"
|
||||
class="agent-side-rail__account-code"
|
||||
:title="agentDisplayCode"
|
||||
>
|
||||
{{ agentDisplayCode }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-side-rail__footer-row">
|
||||
<span class="agent-side-rail__portal-tag agent-side-rail__fade">渠道代理</span>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-side-rail__toggle"
|
||||
@ -89,10 +94,14 @@ import { computed, onMounted, onUnmounted, ref, type Component } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
IconDashboard,
|
||||
IconDoubleLeft,
|
||||
IconDoubleRight,
|
||||
IconGift,
|
||||
IconList,
|
||||
IconPlusCircle,
|
||||
IconShareInternal,
|
||||
IconThunderbolt,
|
||||
IconUserGroup,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import RightTopUser from '@/components/top-nav/RightTopUser.vue'
|
||||
@ -130,11 +139,26 @@ const railWidth = computed(() =>
|
||||
)
|
||||
|
||||
const navItems = computed<AgentNavItem[]>(() => [
|
||||
{
|
||||
path: '/agent/workplace',
|
||||
label: '工作台',
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
path: '/agent/invite',
|
||||
label: '邀请获客',
|
||||
icon: IconShareInternal,
|
||||
},
|
||||
{
|
||||
path: '/agent/priceCheck',
|
||||
label: '运费查价',
|
||||
icon: IconThunderbolt,
|
||||
},
|
||||
{
|
||||
path: '/agent/placeOrder',
|
||||
label: '运单下单',
|
||||
icon: IconPlusCircle,
|
||||
},
|
||||
{
|
||||
path: '/agent/customers',
|
||||
label: '名下客户',
|
||||
@ -142,15 +166,28 @@ const navItems = computed<AgentNavItem[]>(() => [
|
||||
},
|
||||
{
|
||||
path: '/agent/orders',
|
||||
label: '订单与返佣',
|
||||
label: '运单列表',
|
||||
icon: IconList,
|
||||
},
|
||||
{
|
||||
path: '/agent/commission',
|
||||
label: '返佣明细',
|
||||
icon: IconGift,
|
||||
},
|
||||
])
|
||||
|
||||
const agentDisplayName = computed(
|
||||
() => profile.value?.agentName || profile.value?.agentCode || '代理',
|
||||
() => profile.value?.agentName?.trim() || profile.value?.agentCode || '代理',
|
||||
)
|
||||
|
||||
const agentDisplayCode = computed(() => {
|
||||
const code = profile.value?.agentCode?.trim() || ''
|
||||
const name = profile.value?.agentName?.trim() || ''
|
||||
// 名称与编号相同时只展示一行,避免「一代 / 一代」重复
|
||||
if (!code || code === name) return ''
|
||||
return code
|
||||
})
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(AGENT_SIDE_RAIL_STORAGE_KEY) === '1'
|
||||
@ -397,54 +434,44 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
padding: 10px var(--asr-pad-x) 12px;
|
||||
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
&__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 4px 4px 4px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__user {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__agent-meta {
|
||||
&__account-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__agent-name {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
&__account-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__portal-tag {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
&__account-code {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-3);
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -452,8 +479,9 @@ onUnmounted(() => {
|
||||
|
||||
&__toggle {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
@ -464,8 +492,8 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-primary-color, #1d7281);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
background: rgba(var(--primary-6), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@ -479,12 +507,25 @@ onUnmounted(() => {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&.is-compact &__toolbar {
|
||||
justify-content: center;
|
||||
&.is-compact &__dock {
|
||||
padding: 8px 0 10px;
|
||||
}
|
||||
|
||||
&.is-compact &__portal-tag {
|
||||
&.is-compact &__account {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.is-compact &__account-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.is-compact &__toggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -337,11 +337,15 @@ const displayNameInitial = computed(() => {
|
||||
return name ? name.slice(0, 1).toUpperCase() : '?';
|
||||
});
|
||||
|
||||
/** 后端占位邮箱,不当真实邮箱展示 */
|
||||
/** 后端占位邮箱,不当真实邮箱展示(客户 @noemail.local / 代理 @agent.local) */
|
||||
function isPlaceholderEmail(value: string) {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (!v) return true;
|
||||
return v.endsWith('@noemail.local') || v.includes('@noemail.');
|
||||
return (
|
||||
v.endsWith('@noemail.local') ||
|
||||
v.includes('@noemail.') ||
|
||||
v.endsWith('@agent.local')
|
||||
);
|
||||
}
|
||||
|
||||
const accountSubtitle = computed(() => {
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CustomerService } from '@/api/customer'
|
||||
import { AgentPortalApi } from '@/api/agent'
|
||||
import { extractCustomerId, mapCustomerRow } from '@/api/customer/resolve'
|
||||
import { $baseMessage } from '@/utils'
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||||
|
||||
/**
|
||||
* 仓储页管理端客户上下文:
|
||||
* - 列表/汇总:未选客户 → 不传 customerId → 查全部
|
||||
* - 写入:管理端须显式选择客户
|
||||
* 客户门户登录时由后端会话强制归属,前端不传 customerId。
|
||||
* 仓储/下单页「非客户门户」客户上下文:
|
||||
* - 管理端:列表未选客户 → 不传 customerId → 查全部;写入须显式选客户
|
||||
* - 渠道代理:仅能选名下绑定客户,写入须显式选客户
|
||||
* - 客户门户:由后端会话强制归属,前端不传 customerId
|
||||
*/
|
||||
export function useWarehouseAdminCustomer() {
|
||||
const { t } = useI18n()
|
||||
const rbacUserStore = useRbacUserStore()
|
||||
|
||||
const isAdmin = computed(() => !rbacUserStore.isCustomerPortal())
|
||||
const isCustomer = computed(() => rbacUserStore.isCustomerPortal())
|
||||
const isAgent = computed(() => rbacUserStore.isAgentPortal())
|
||||
/** 需前端显式选客户:管理端 + 渠道代理 */
|
||||
const isAdmin = computed(() => !isCustomer.value)
|
||||
/** undefined = 不筛选(查全部);仅 >0 才作为筛选条件 */
|
||||
const customerId = ref<number>()
|
||||
const customerOptions = ref<Array<{ label: string; value: number }>>([])
|
||||
@ -35,17 +39,32 @@ export function useWarehouseAdminCustomer() {
|
||||
}
|
||||
customerLoading.value = true
|
||||
try {
|
||||
const { items } = await CustomerService.getList({ page: 1, pageSize: 500 })
|
||||
customerOptions.value = (items || [])
|
||||
.map((row) => mapCustomerRow(row as Record<string, unknown>))
|
||||
.map((c) => {
|
||||
const id = extractCustomerId(c as Record<string, unknown>)
|
||||
if (!id) return null
|
||||
const name = c.customerName || c.customerCode || c.phone || `#${id}`
|
||||
const code = c.customerCode ? ` (${c.customerCode})` : ''
|
||||
return { label: `${name}${code}`, value: id }
|
||||
})
|
||||
.filter((x): x is { label: string; value: number } => !!x)
|
||||
if (isAgent.value) {
|
||||
const { items } = await AgentPortalApi.customers({ page: 1, pageSize: 500 })
|
||||
customerOptions.value = (items || [])
|
||||
.map((row) => {
|
||||
const r = row as unknown as Record<string, unknown>
|
||||
const id = Number(r.id ?? r.Id ?? 0)
|
||||
if (!id) return null
|
||||
const name = String(r.customerName ?? r.CustomerName ?? r.customerCode ?? r.CustomerCode ?? `#${id}`)
|
||||
const code = String(r.customerCode ?? r.CustomerCode ?? '')
|
||||
const suffix = code ? ` (${code})` : ''
|
||||
return { label: `${name}${suffix}`, value: id }
|
||||
})
|
||||
.filter((x): x is { label: string; value: number } => !!x)
|
||||
} else {
|
||||
const { items } = await CustomerService.getList({ page: 1, pageSize: 500 })
|
||||
customerOptions.value = (items || [])
|
||||
.map((row) => mapCustomerRow(row as Record<string, unknown>))
|
||||
.map((c) => {
|
||||
const id = extractCustomerId(c as Record<string, unknown>)
|
||||
if (!id) return null
|
||||
const name = c.customerName || c.customerCode || c.phone || `#${id}`
|
||||
const code = c.customerCode ? ` (${c.customerCode})` : ''
|
||||
return { label: `${name}${code}`, value: id }
|
||||
})
|
||||
.filter((x): x is { label: string; value: number } => !!x)
|
||||
}
|
||||
} catch {
|
||||
customerOptions.value = []
|
||||
} finally {
|
||||
@ -56,6 +75,7 @@ export function useWarehouseAdminCustomer() {
|
||||
/**
|
||||
* 追加到 list/save 请求体。
|
||||
* 管理端未选客户时显式去掉 customerId,保证「不选 = 查全部」。
|
||||
* 代理写入必须带 customerId(由 ensureMutationCustomerId 校验)。
|
||||
*/
|
||||
function withCustomerId<T extends Record<string, unknown>>(body: T): T & { customerId?: number } {
|
||||
const next = { ...body } as T & { customerId?: number }
|
||||
@ -66,12 +86,17 @@ export function useWarehouseAdminCustomer() {
|
||||
return next
|
||||
}
|
||||
|
||||
/** 管理端写操作前校验已选客户;客户门户返回 undefined 由后端会话解析 */
|
||||
/** 管理端/代理写操作前校验已选客户;客户门户返回 undefined 由后端会话解析 */
|
||||
function ensureMutationCustomerId(): number | undefined {
|
||||
if (!isAdmin.value) return undefined
|
||||
const id = resolveSelectedCustomerId()
|
||||
if (id == null) {
|
||||
$baseMessage(t('warehouse.common.selectCustomerRequired'), 'warning')
|
||||
$baseMessage(
|
||||
isAgent.value
|
||||
? '请先选择名下客户再下单'
|
||||
: t('warehouse.common.selectCustomerRequired'),
|
||||
'warning',
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return id
|
||||
@ -83,6 +108,7 @@ export function useWarehouseAdminCustomer() {
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
isAgent,
|
||||
customerId,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
|
||||
@ -4906,7 +4906,8 @@ export default {
|
||||
'user.customer.fields.createdBy': 'Created By',
|
||||
'user.customer.fields.updateTime': 'Update Time',
|
||||
'user.customer.fields.updateBy': 'Update By',
|
||||
'user.customer.fields.salesPerson': 'Sales Person',
|
||||
'user.customer.fields.salesPerson': 'Bound Agent',
|
||||
'user.customer.fields.agent': 'Bound Agent',
|
||||
'user.customer.fields.customerService': 'Advisor',
|
||||
'user.customer.fields.role': 'Role',
|
||||
'user.customer.role.customer': 'Customer',
|
||||
|
||||
@ -468,6 +468,15 @@ export default {
|
||||
'marketing.register.referrerLoading': 'Validating invite code…',
|
||||
'marketing.register.referrerTimeout':
|
||||
'Invite validation timed out. Check your network and refresh; you can still register.',
|
||||
'marketing.register.agentHint': 'Channel partner · {name} ({code})',
|
||||
'marketing.register.agentField': 'Agent code (optional)',
|
||||
'marketing.register.agentPlaceholder': 'Enter agent code if you were referred by a partner',
|
||||
'marketing.register.agentLockedHint':
|
||||
'Locked by invite link; you will be bound to this agent after register',
|
||||
'marketing.register.agentInvalid': 'Agent code is invalid or disabled',
|
||||
'marketing.register.agentValidating': 'Validating agent code…',
|
||||
'marketing.register.agentPriorityHint':
|
||||
'Agent code filled — channel agent binding takes priority over friend invite',
|
||||
'marketing.register.customerName': 'Name',
|
||||
'marketing.register.phone': 'Phone',
|
||||
'marketing.register.email': 'Email',
|
||||
|
||||
@ -464,6 +464,13 @@ export default {
|
||||
'marketing.register.invalidRef': '邀请码无效或已失效,您仍可正常注册',
|
||||
'marketing.register.referrerLoading': '正在校验邀请码…',
|
||||
'marketing.register.referrerTimeout': '邀请码校验超时,请检查网络后刷新;您仍可继续注册',
|
||||
'marketing.register.agentHint': '渠道推荐 · {name}({code})',
|
||||
'marketing.register.agentField': '代理编号(选填)',
|
||||
'marketing.register.agentPlaceholder': '有渠道推荐人可填写其代理编号',
|
||||
'marketing.register.agentLockedHint': '已由专属链接锁定,注册后自动绑定该代理',
|
||||
'marketing.register.agentInvalid': '代理编号无效或已停用',
|
||||
'marketing.register.agentValidating': '正在校验代理编号…',
|
||||
'marketing.register.agentPriorityHint': '已填写代理编号,将优先绑定渠道代理(不走好友邀请)',
|
||||
'marketing.register.customerName': '客户名称',
|
||||
'marketing.register.phone': '手机号',
|
||||
'marketing.register.email': '邮箱',
|
||||
|
||||
@ -4929,7 +4929,8 @@ export default {
|
||||
'user.customer.fields.createdBy': '创建人',
|
||||
'user.customer.fields.updateTime': '更新时间',
|
||||
'user.customer.fields.updateBy': '更新人',
|
||||
'user.customer.fields.salesPerson': '所属销售',
|
||||
'user.customer.fields.salesPerson': '绑定代理',
|
||||
'user.customer.fields.agent': '绑定代理',
|
||||
'user.customer.fields.customerService': '所属顾问',
|
||||
'user.customer.fields.role': '角色',
|
||||
'user.customer.role.customer': '客户',
|
||||
|
||||
@ -3,9 +3,13 @@ export const WHITE_LIST = [
|
||||
{ name: 'login', children: [] },
|
||||
{ name: 'adminLogin', children: [] },
|
||||
{ name: 'agentLogin', children: [] },
|
||||
{ name: 'AgentPortalWorkplace', children: [] },
|
||||
{ name: 'AgentPortalInvite', children: [] },
|
||||
{ name: 'AgentPortalCustomers', children: [] },
|
||||
{ name: 'AgentPortalOrders', children: [] },
|
||||
{ name: 'AgentPortalCommission', children: [] },
|
||||
{ name: 'AgentPortalPriceCheck', children: [] },
|
||||
{ name: 'AgentPortalPlaceOrder', children: [] },
|
||||
{ name: 'siteRoot', children: [] },
|
||||
];
|
||||
|
||||
|
||||
@ -64,13 +64,23 @@ const router = createRouter({
|
||||
path: '/agent-portal',
|
||||
name: 'AgentPortal',
|
||||
component: () => import('@/layout/default-layout.vue'),
|
||||
redirect: '/agent/invite',
|
||||
redirect: '/agent/workplace',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
agentPortalOnly: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/agent/workplace',
|
||||
name: 'AgentPortalWorkplace',
|
||||
component: () => import('@/views/agent/workplace/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
agentPortalOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/agent/invite',
|
||||
name: 'AgentPortalInvite',
|
||||
@ -94,7 +104,37 @@ const router = createRouter({
|
||||
{
|
||||
path: '/agent/orders',
|
||||
name: 'AgentPortalOrders',
|
||||
component: () => import('@/views/agent/orders/index.vue'),
|
||||
component: () => import('@/views/order/myOrders/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
agentPortalOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/agent/commission',
|
||||
name: 'AgentPortalCommission',
|
||||
component: () => import('@/views/agent/commission/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
agentPortalOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/agent/priceCheck',
|
||||
name: 'AgentPortalPriceCheck',
|
||||
component: () => import('@/views/order/priceCheck/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
agentPortalOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/agent/placeOrder',
|
||||
name: 'AgentPortalPlaceOrder',
|
||||
component: () => import('@/views/order/placeOrder/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
|
||||
1
src/router/typings.d.ts
vendored
1
src/router/typings.d.ts
vendored
@ -13,6 +13,7 @@ declare module 'vue-router' {
|
||||
menuPermission?: string;
|
||||
customerPortalOnly?: boolean; // If true, only customer portal can access
|
||||
adminPortalOnly?: boolean; // If true, only admin (internal user) portal can access
|
||||
agentPortalOnly?: boolean; // If true, only channel agent portal can access
|
||||
hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu
|
||||
activeMenu?: string; // if set name, the menu will be highlighted according to the name you set
|
||||
order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is
|
||||
|
||||
108
src/utils/agentInviteStorage.ts
Normal file
108
src/utils/agentInviteStorage.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/** 渠道代理注册归因暂存:7 天 last-touch(与 invite / advisor 并列,参数名为 agent) */
|
||||
|
||||
const AGENT_INVITE_STORAGE_KEY = 'oms_agent_invite_v1'
|
||||
const AGENT_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
type AgentInvitePayload = {
|
||||
code: string
|
||||
/** URL 带来的锁定码,手填不应覆盖 */
|
||||
locked?: boolean
|
||||
ts: number
|
||||
}
|
||||
|
||||
function safeParse(raw: string | null): AgentInvitePayload | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AgentInvitePayload
|
||||
if (!parsed?.code || typeof parsed.ts !== 'number') return null
|
||||
return {
|
||||
code: String(parsed.code).trim(),
|
||||
locked: Boolean(parsed.locked),
|
||||
ts: parsed.ts,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(ts: number, now = Date.now()): boolean {
|
||||
return now - ts > AGENT_INVITE_TTL_MS
|
||||
}
|
||||
|
||||
export function stashAgentInvite(agentCode?: string | null, locked = false): void {
|
||||
const code = String(agentCode ?? '').trim()
|
||||
if (!code) return
|
||||
const payload: AgentInvitePayload = { code, locked, ts: Date.now() }
|
||||
const raw = JSON.stringify(payload)
|
||||
try {
|
||||
localStorage.setItem(AGENT_INVITE_STORAGE_KEY, raw)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(AGENT_INVITE_STORAGE_KEY, raw)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function readStashedAgentInvite(): { code: string; locked: boolean } {
|
||||
const now = Date.now()
|
||||
const fromSession = safeParse(
|
||||
(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(AGENT_INVITE_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})(),
|
||||
)
|
||||
if (fromSession && !isExpired(fromSession.ts, now)) {
|
||||
return { code: fromSession.code, locked: Boolean(fromSession.locked) }
|
||||
}
|
||||
|
||||
const fromLocal = safeParse(
|
||||
(() => {
|
||||
try {
|
||||
return localStorage.getItem(AGENT_INVITE_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})(),
|
||||
)
|
||||
if (fromLocal && !isExpired(fromLocal.ts, now)) {
|
||||
stashAgentInvite(fromLocal.code, Boolean(fromLocal.locked))
|
||||
return { code: fromLocal.code, locked: Boolean(fromLocal.locked) }
|
||||
}
|
||||
|
||||
if (fromLocal || fromSession) {
|
||||
clearStashedAgentInvite()
|
||||
}
|
||||
return { code: '', locked: false }
|
||||
}
|
||||
|
||||
export function clearStashedAgentInvite(): void {
|
||||
try {
|
||||
localStorage.removeItem(AGENT_INVITE_STORAGE_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem(AGENT_INVITE_STORAGE_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析代理归因:query.agent → 7 天暂存 */
|
||||
export function resolveAgentInvite(query: Record<string, unknown>): {
|
||||
code: string
|
||||
locked: boolean
|
||||
} {
|
||||
const fromQuery = String(query.agent ?? query.agentCode ?? '').trim()
|
||||
if (fromQuery) {
|
||||
stashAgentInvite(fromQuery, true)
|
||||
return { code: fromQuery, locked: true }
|
||||
}
|
||||
return readStashedAgentInvite()
|
||||
}
|
||||
44
src/utils/agentPortalCopy.ts
Normal file
44
src/utils/agentPortalCopy.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/** 代理门户:催付款 / 邀请话术文案 */
|
||||
|
||||
export function buildPendingPaymentReminderText(opts: {
|
||||
customerName?: string
|
||||
pendingCount?: number
|
||||
agentName?: string
|
||||
}): string {
|
||||
const name = (opts.customerName || '客户').trim()
|
||||
const count = Math.max(0, Number(opts.pendingCount) || 0)
|
||||
const agent = (opts.agentName || '您的渠道顾问').trim()
|
||||
const countPart = count > 0 ? `目前有 ${count} 票运单待付款` : '您有运单待付款'
|
||||
return (
|
||||
`【物流提醒】您好${name !== '客户' ? `,${name}` : ''},` +
|
||||
`${countPart},请尽快登录系统完成支付,以免影响出货。` +
|
||||
`如有疑问可联系${agent}。`
|
||||
)
|
||||
}
|
||||
|
||||
export type AgentInviteTemplateKey = 'wechat' | 'moments' | 'formal'
|
||||
|
||||
export const AGENT_INVITE_TEMPLATES: Array<{
|
||||
key: AgentInviteTemplateKey
|
||||
label: string
|
||||
build: (agentCode: string, inviteUrl: string) => string
|
||||
}> = [
|
||||
{
|
||||
key: 'wechat',
|
||||
label: '微信私聊',
|
||||
build: (code, url) =>
|
||||
`嗨,推荐你用这个渠道寄国际件,注册就送专属通道:${url}\n我的代理编号:${code}`,
|
||||
},
|
||||
{
|
||||
key: 'moments',
|
||||
label: '朋友圈',
|
||||
build: (code, url) =>
|
||||
`国际物流靠谱渠道分享|扫码/点开即可注册下单\n${url}\n编号 ${code}(备注来找我可跟进)`,
|
||||
},
|
||||
{
|
||||
key: 'formal',
|
||||
label: '商务简介',
|
||||
build: (code, url) =>
|
||||
`【IDE Express 渠道邀请】请通过专属链接完成注册绑定:${url}\n代理编号:${code}\n注册后即可查价下单,运单派送完成后按约定结算返佣。`,
|
||||
},
|
||||
]
|
||||
@ -174,7 +174,7 @@ export function adaptOrderListHeaderFilters(
|
||||
|
||||
export interface CustomerListHeaderQuery {
|
||||
keyword?: string;
|
||||
salesman?: string;
|
||||
agentId?: number;
|
||||
customerService?: string;
|
||||
status?: number;
|
||||
}
|
||||
@ -191,9 +191,15 @@ export function adaptCustomerListHeaderFilters(
|
||||
'phone',
|
||||
'wechatNickname',
|
||||
]) || undefined;
|
||||
const salesman = pickHeaderText(params, ['salesPerson', 'salesman']) || undefined;
|
||||
const customerService = pickHeaderText(params, ['customerService']) || undefined;
|
||||
|
||||
let agentId: number | undefined;
|
||||
const agentRaw = pickHeaderText(params, ['agent', 'agentId', 'agentCode']);
|
||||
if (agentRaw) {
|
||||
const n = Number(agentRaw);
|
||||
if (!Number.isNaN(n) && n > 0) agentId = n;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
const statusRaw = asTrimmedString(params.status);
|
||||
if (statusRaw) {
|
||||
@ -205,7 +211,7 @@ export function adaptCustomerListHeaderFilters(
|
||||
}
|
||||
}
|
||||
|
||||
return { keyword, salesman, customerService, status };
|
||||
return { keyword, agentId, customerService, status };
|
||||
}
|
||||
|
||||
export interface AccountFlowHeaderQuery {
|
||||
|
||||
@ -44,10 +44,10 @@ export function buildInviteShareText(inviteCode: string, inviteUrl: string): str
|
||||
return `【IDE Express】送你一个专属邀请,微信打开链接注册即可:${url}\n邀请码:${code}`;
|
||||
}
|
||||
|
||||
/** 渠道代理分享话术 */
|
||||
/** 渠道代理分享话术(默认微信私聊) */
|
||||
export function buildAgentInviteShareText(agentCode: string, inviteUrl: string): string {
|
||||
const code = agentCode.trim();
|
||||
const url = inviteUrl.trim();
|
||||
if (!code || !url) return url || code;
|
||||
return `【IDE Express】通过我的专属链接注册,微信打开即可:${url}\n代理编号:${code}`;
|
||||
return `嗨,推荐你用这个渠道寄国际件,注册就送专属通道:${url}\n我的代理编号:${code}`;
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export const ADMIN_LOGIN_PATH = '/admin'
|
||||
/** 渠道代理登录路径 / 路由名 */
|
||||
export const AGENT_LOGIN_PATH = '/agent'
|
||||
export const AGENT_LOGIN_ROUTE_NAME = 'agentLogin'
|
||||
export const AGENT_HOME_PATH = '/agent/invite'
|
||||
export const AGENT_HOME_PATH = '/agent/workplace'
|
||||
|
||||
/** 客户端(客户)登录路由 */
|
||||
export const CUSTOMER_LOGIN_ROUTE_NAME = 'login'
|
||||
|
||||
@ -11,8 +11,10 @@
|
||||
style="width: 140px"
|
||||
/>
|
||||
<a-select v-model="status" allow-clear placeholder="状态" style="width: 140px">
|
||||
<a-option value="estimated">预计返佣</a-option>
|
||||
<a-option value="pending">待结算</a-option>
|
||||
<a-option value="paid">已打款</a-option>
|
||||
<a-option value="void">已作废</a-option>
|
||||
</a-select>
|
||||
<a-button class="app-btn-search" @click="handleSearch">查询</a-button>
|
||||
</div>
|
||||
@ -129,14 +131,44 @@ function mapRow(raw: AgentCommissionDto): CommissionRow {
|
||||
commissionRate: Number(pick(r, 'commissionRate', 'CommissionRate') ?? 0),
|
||||
commissionAmount: Number(pick(r, 'commissionAmount', 'CommissionAmount') ?? 0),
|
||||
status: st,
|
||||
statusName: st === 'paid' ? '已打款' : '待结算',
|
||||
statusName: resolveStatusName(st),
|
||||
triggerOrderStatus: String(pick(r, 'triggerOrderStatus', 'TriggerOrderStatus') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusName(st: string) {
|
||||
switch (st) {
|
||||
case 'estimated':
|
||||
return '预计返佣'
|
||||
case 'pending':
|
||||
return '待结算'
|
||||
case 'paid':
|
||||
return '已打款'
|
||||
case 'void':
|
||||
return '已作废'
|
||||
default:
|
||||
return st || '--'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(st: string): 'ok' | 'wait' | 'brand' | 'alert' | 'mute' {
|
||||
switch (st) {
|
||||
case 'paid':
|
||||
return 'ok'
|
||||
case 'pending':
|
||||
return 'wait'
|
||||
case 'estimated':
|
||||
return 'brand'
|
||||
case 'void':
|
||||
return 'mute'
|
||||
default:
|
||||
return 'mute'
|
||||
}
|
||||
}
|
||||
|
||||
function actionRenderer(row: CommissionRow, rowIndex: number): string {
|
||||
if (row.status === 'paid') {
|
||||
if (row.status !== 'pending') {
|
||||
return '<span style="color:var(--color-text-3);font-size:12px;">--</span>'
|
||||
}
|
||||
return wrapWarehouseActions(
|
||||
@ -155,7 +187,7 @@ function customCellRenderer(_row: number, col: number, _value: unknown, rowData:
|
||||
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
|
||||
const colKey = hotColumns.value[dataColIndex].key
|
||||
if (colKey === 'statusName') {
|
||||
return buildStatusTagHtml(rowData.statusName, rowData.status === 'paid' ? 'ok' : 'wait')
|
||||
return buildStatusTagHtml(rowData.statusName, resolveStatusTone(rowData.status))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,28 +1,35 @@
|
||||
<template>
|
||||
<div class="agent-orders-page">
|
||||
<div class="agent-orders-page-card">
|
||||
<a-tabs v-model:active-key="tab" class="orders-tabs" @change="onTabChange">
|
||||
<a-tab-pane key="orders" title="订单" />
|
||||
<a-tab-pane key="commission" title="返佣明细" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="agent-commission-page">
|
||||
<div class="agent-commission-page-card">
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-select
|
||||
v-model="status"
|
||||
allow-clear
|
||||
placeholder="结算状态"
|
||||
style="width: 120px"
|
||||
:options="statusOptions"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<a-range-picker
|
||||
v-model="dateRange"
|
||||
style="width: 260px"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<a-input
|
||||
v-if="tab === 'orders'"
|
||||
v-model="keyword"
|
||||
allow-clear
|
||||
placeholder="运单号/追踪号/产品"
|
||||
style="width: 240px"
|
||||
placeholder="运单号"
|
||||
style="width: 180px"
|
||||
@press-enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<a-button v-if="tab === 'orders'" class="app-btn-search" @click="handleSearch">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button class="app-btn-search" @click="handleSearch">查询</a-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space :size="8">
|
||||
<a-button :loading="exporting" @click="handleExport">导出</a-button>
|
||||
<a-button @click="loadList">刷新</a-button>
|
||||
<PaginationPro
|
||||
:total-count="totalCount"
|
||||
@ -38,7 +45,7 @@
|
||||
<a-spin :loading="listLoading" class="list-spin">
|
||||
<div ref="tableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="hotColumns"
|
||||
:columns="commissionColumns"
|
||||
:data="tableRows"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
@ -56,36 +63,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { buildStatusTagHtml } from '@/utils/statusTag'
|
||||
import {
|
||||
AgentPortalApi,
|
||||
type AgentCommissionDto,
|
||||
type AgentPortalOrderDto,
|
||||
} from '@/api/agent'
|
||||
import { AgentPortalApi, type AgentCommissionDto } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentPortalOrders' })
|
||||
defineOptions({ name: 'AgentPortalCommission' })
|
||||
|
||||
const route = useRoute()
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 320))
|
||||
|
||||
type TabKey = 'orders' | 'commission'
|
||||
|
||||
interface OrderRow {
|
||||
id: number
|
||||
serviceNo: string
|
||||
trackNo: string
|
||||
customerName: string
|
||||
productCode: string
|
||||
consigneeCountryName: string
|
||||
orderStatus: string
|
||||
receivableSubtotal: number | string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
interface CommissionRow {
|
||||
id: number
|
||||
serviceNo: string
|
||||
@ -100,25 +91,53 @@ interface CommissionRow {
|
||||
createTime: string
|
||||
}
|
||||
|
||||
const tab = ref<TabKey>('orders')
|
||||
const listLoading = ref(false)
|
||||
const tableRows = ref<Array<OrderRow | CommissionRow>>([])
|
||||
const exporting = ref(false)
|
||||
const tableRows = ref<CommissionRow[]>([])
|
||||
const totalCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const status = ref<string | undefined>()
|
||||
const keyword = ref('')
|
||||
const dateRange = ref<string[] | undefined>()
|
||||
|
||||
const orderColumns = [
|
||||
{ key: 'serviceNo', label: '运单号', width: 140 },
|
||||
{ key: 'trackNo', label: '追踪号', width: 140 },
|
||||
{ key: 'customerName', label: '客户', width: 140 },
|
||||
{ key: 'productCode', label: '产品', width: 120 },
|
||||
{ key: 'consigneeCountryName', label: '国家', width: 100 },
|
||||
{ key: 'orderStatus', label: '状态', width: 110 },
|
||||
{ key: 'receivableSubtotal', label: '应收', width: 100 },
|
||||
{ key: 'createTime', label: '下单时间', width: 170 },
|
||||
const statusOptions = [
|
||||
{ label: '预计返佣', value: 'estimated' },
|
||||
{ label: '待结算', value: 'pending' },
|
||||
{ label: '已打款', value: 'paid' },
|
||||
{ label: '已作废', value: 'void' },
|
||||
]
|
||||
|
||||
function resolveStatusName(st: string) {
|
||||
switch (st) {
|
||||
case 'estimated':
|
||||
return '预计返佣'
|
||||
case 'pending':
|
||||
return '待结算'
|
||||
case 'paid':
|
||||
return '已打款'
|
||||
case 'void':
|
||||
return '已作废'
|
||||
default:
|
||||
return st || '--'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(st: string): 'ok' | 'wait' | 'brand' | 'alert' | 'mute' {
|
||||
switch (st) {
|
||||
case 'paid':
|
||||
return 'ok'
|
||||
case 'pending':
|
||||
return 'wait'
|
||||
case 'estimated':
|
||||
return 'brand'
|
||||
case 'void':
|
||||
return 'mute'
|
||||
default:
|
||||
return 'mute'
|
||||
}
|
||||
}
|
||||
|
||||
const commissionColumns = [
|
||||
{ key: 'serviceNo', label: '运单号', width: 140 },
|
||||
{ key: 'customerName', label: '客户', width: 140 },
|
||||
@ -131,60 +150,52 @@ const commissionColumns = [
|
||||
{ key: 'createTime', label: '计提时间', width: 170 },
|
||||
]
|
||||
|
||||
const hotColumns = computed(() =>
|
||||
tab.value === 'orders' ? orderColumns : commissionColumns,
|
||||
)
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function mapOrder(raw: AgentPortalOrderDto): OrderRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
trackNo: String(pick(r, 'trackNo', 'TrackNo') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
productCode: String(pick(r, 'productCode', 'ProductCode') ?? ''),
|
||||
consigneeCountryName: String(pick(r, 'consigneeCountryName', 'ConsigneeCountryName') ?? ''),
|
||||
orderStatus: String(pick(r, 'orderStatus', 'OrderStatus') ?? ''),
|
||||
receivableSubtotal: pick(r, 'receivableSubtotal', 'ReceivableSubtotal') as number | string,
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function mapCommission(raw: AgentCommissionDto): CommissionRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
const st = String(pick(r, 'status', 'Status') ?? '')
|
||||
const baseType = String(pick(r, 'baseType', 'BaseType') ?? '')
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
baseType: String(pick(r, 'baseType', 'BaseType') ?? ''),
|
||||
baseType: baseType === 'receivable' ? '应收' : baseType === 'freight' ? '运费' : baseType,
|
||||
baseAmount: pick(r, 'baseAmount', 'BaseAmount') as number | string,
|
||||
commissionRate: pick(r, 'commissionRate', 'CommissionRate') as number | string,
|
||||
commissionAmount: pick(r, 'commissionAmount', 'CommissionAmount') as number | string,
|
||||
status: st,
|
||||
statusName: st === 'paid' ? '已打款' : '待结算',
|
||||
statusName: resolveStatusName(st),
|
||||
triggerOrderStatus: String(pick(r, 'triggerOrderStatus', 'TriggerOrderStatus') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
const range = dateRange.value || []
|
||||
return {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
status: status.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
createTimeStart: range[0] || undefined,
|
||||
createTimeEnd: range[1] || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function customCellRenderer(
|
||||
_row: number,
|
||||
col: number,
|
||||
_value: unknown,
|
||||
rowData: OrderRow | CommissionRow,
|
||||
rowData: CommissionRow,
|
||||
): string | null {
|
||||
// 仅序号列
|
||||
const dataColIndex = col - 1
|
||||
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
|
||||
const colKey = hotColumns.value[dataColIndex].key
|
||||
if (tab.value === 'commission' && colKey === 'statusName') {
|
||||
const c = rowData as CommissionRow
|
||||
return buildStatusTagHtml(c.statusName, c.status === 'paid' ? 'ok' : 'wait')
|
||||
if (dataColIndex < 0 || dataColIndex >= commissionColumns.length) return null
|
||||
const colKey = commissionColumns[dataColIndex].key
|
||||
if (colKey === 'statusName') {
|
||||
return buildStatusTagHtml(rowData.statusName, resolveStatusTone(rowData.status))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -192,22 +203,9 @@ function customCellRenderer(
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
if (tab.value === 'orders') {
|
||||
const res = await AgentPortalApi.orders({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapOrder)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
} else {
|
||||
const res = await AgentPortalApi.commissions({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapCommission)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
}
|
||||
const res = await AgentPortalApi.commissions(buildQuery())
|
||||
tableRows.value = (res.items || []).map(mapCommission)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载失败')
|
||||
tableRows.value = []
|
||||
@ -217,22 +215,38 @@ async function loadList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
await AgentPortalApi.exportCommissions({
|
||||
...buildQuery(),
|
||||
page: 1,
|
||||
pageSize: 5000,
|
||||
})
|
||||
Message.success('导出已开始')
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
currentPage.value = 1
|
||||
keyword.value = ''
|
||||
onMounted(() => {
|
||||
const q = String(route.query.status || '').trim()
|
||||
if (['estimated', 'pending', 'paid', 'void'].includes(q)) {
|
||||
status.value = q
|
||||
}
|
||||
void loadList()
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-orders-page {
|
||||
.agent-commission-page {
|
||||
padding: 16px;
|
||||
background: var(--app-page-bg);
|
||||
flex: 1;
|
||||
@ -241,7 +255,7 @@ onMounted(() => void loadList())
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-orders-page-card {
|
||||
.agent-commission-page-card {
|
||||
background: var(--app-card-bg);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
@ -251,17 +265,14 @@ onMounted(() => void loadList())
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.orders-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
margin: 0 0 12px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
@ -269,6 +280,7 @@ onMounted(() => void loadList())
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-spin {
|
||||
@ -38,6 +38,9 @@
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
:show-action-column="true"
|
||||
:action-renderer="actionRenderer"
|
||||
@action-click="handleActionClick"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
@ -47,14 +50,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { AgentPortalApi, type AgentPortalCustomerDto } from '@/api/agent'
|
||||
import { buildPendingPaymentReminderText } from '@/utils/agentPortalCopy'
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
||||
|
||||
defineOptions({ name: 'AgentPortalCustomers' })
|
||||
|
||||
const router = useRouter()
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 320))
|
||||
|
||||
@ -65,6 +72,9 @@ interface CustomerRow {
|
||||
phone: string
|
||||
agentBindTime: string
|
||||
createTime: string
|
||||
lastOrderTime: string
|
||||
orderCount30d: number
|
||||
pendingPaymentCount: number
|
||||
}
|
||||
|
||||
const tableRows = ref<CustomerRow[]>([])
|
||||
@ -73,11 +83,15 @@ const listLoading = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const agentDisplayName = ref('渠道顾问')
|
||||
|
||||
const hotColumns = computed(() => [
|
||||
{ key: 'customerCode', label: '客户编号', width: 140 },
|
||||
{ key: 'customerName', label: '客户名称', width: 160 },
|
||||
{ key: 'phone', label: '手机', width: 130 },
|
||||
{ key: 'customerCode', label: '客户编号', width: 130 },
|
||||
{ key: 'customerName', label: '客户名称', width: 140 },
|
||||
{ key: 'phone', label: '手机', width: 120 },
|
||||
{ key: 'orderCount30d', label: '近30天票数', width: 110 },
|
||||
{ key: 'pendingPaymentCount', label: '待付款', width: 90 },
|
||||
{ key: 'lastOrderTime', label: '最近下单', width: 170 },
|
||||
{ key: 'agentBindTime', label: '绑定时间', width: 170 },
|
||||
{ key: 'createTime', label: '注册时间', width: 170 },
|
||||
])
|
||||
@ -95,6 +109,45 @@ function mapRow(raw: AgentPortalCustomerDto): CustomerRow {
|
||||
phone: String(pick(r, 'phone', 'Phone') ?? ''),
|
||||
agentBindTime: String(pick(r, 'agentBindTime', 'AgentBindTime') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
lastOrderTime: String(pick(r, 'lastOrderTime', 'LastOrderTime') ?? ''),
|
||||
orderCount30d: Number(pick(r, 'orderCount30d', 'OrderCount30d') ?? 0),
|
||||
pendingPaymentCount: Number(pick(r, 'pendingPaymentCount', 'PendingPaymentCount') ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
function actionRenderer(rowData: CustomerRow) {
|
||||
const buttons = [
|
||||
renderWarehouseActionBtn('placeOrder', '代下单', 'ship'),
|
||||
renderWarehouseActionBtn('orders', '看运单', 'view'),
|
||||
]
|
||||
if (rowData.pendingPaymentCount > 0) {
|
||||
buttons.push(renderWarehouseActionBtn('remindPay', '催付款', 'pay'))
|
||||
}
|
||||
return wrapWarehouseActions(buttons)
|
||||
}
|
||||
|
||||
async function handleActionClick(row: CustomerRow, _rowIndex: number, action?: string) {
|
||||
if (!row || !action) return
|
||||
if (action === 'placeOrder') {
|
||||
void router.push({ path: '/agent/placeOrder', query: { customerId: String(row.id) } })
|
||||
return
|
||||
}
|
||||
if (action === 'orders') {
|
||||
void router.push({ path: '/agent/orders', query: { customerId: String(row.id) } })
|
||||
return
|
||||
}
|
||||
if (action === 'remindPay') {
|
||||
const text = buildPendingPaymentReminderText({
|
||||
customerName: row.customerName,
|
||||
pendingCount: row.pendingPaymentCount,
|
||||
agentName: agentDisplayName.value,
|
||||
})
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
Message.success('催付款话术已复制')
|
||||
} catch {
|
||||
Message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,7 +175,16 @@ function handleSearch() {
|
||||
void loadList()
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const profile = await AgentPortalApi.profile()
|
||||
const raw = profile as unknown as Record<string, unknown>
|
||||
agentDisplayName.value = String(raw.agentName ?? raw.AgentName ?? raw.agentCode ?? raw.AgentCode ?? '渠道顾问')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@ -13,6 +13,12 @@
|
||||
客户扫码或打开专属链接完成注册后,自动绑定到你名下;派送成功/失败后按比例计提返佣。
|
||||
</p>
|
||||
<div class="invite-card__actions">
|
||||
<a-select
|
||||
v-model="templateKey"
|
||||
:options="templateOptions"
|
||||
style="width: 128px"
|
||||
size="medium"
|
||||
/>
|
||||
<a-button type="primary" class="app-btn-primary-soft" @click="copyShareText">
|
||||
复制邀请话术
|
||||
</a-button>
|
||||
@ -20,6 +26,27 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="invite-card invite-card--rule">
|
||||
<div class="invite-card__head">
|
||||
<h3 class="invite-card__title">我的返佣规则</h3>
|
||||
</div>
|
||||
<ul class="rule-list">
|
||||
<li>
|
||||
<span class="rule-label">基数类型</span>
|
||||
<span class="rule-value">{{ baseTypeName }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="rule-label">返佣比例</span>
|
||||
<span class="rule-value">{{ commissionRateText }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="rule-label">适用产品</span>
|
||||
<span class="rule-value">{{ productNamesText }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="invite-card__hint">规则由运营配置;改规则不追溯历史明细</p>
|
||||
</section>
|
||||
|
||||
<section class="invite-card invite-card--qr">
|
||||
<div class="invite-card__head">
|
||||
<h3 class="invite-card__title">微信扫码注册</h3>
|
||||
@ -74,7 +101,7 @@
|
||||
<span class="howto-index">03</span>
|
||||
<div>
|
||||
<div class="howto-title">查看客户与返佣</div>
|
||||
<div class="howto-desc">「名下客户」看绑定,「订单与返佣」看计提</div>
|
||||
<div class="howto-desc">「名下客户」看绑定,「运单列表 / 返佣明细」看业务与计提</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
@ -97,25 +124,63 @@ import InviteQrImage from '@/components/InviteQrImage.vue'
|
||||
import { AgentPortalApi, type AgentDto } from '@/api/agent'
|
||||
import {
|
||||
buildAgentInviteRegisterUrl,
|
||||
buildAgentInviteShareText,
|
||||
buildAgentInviteWechatRegisterUrl,
|
||||
} from '@/utils/inviteLink'
|
||||
import {
|
||||
AGENT_INVITE_TEMPLATES,
|
||||
type AgentInviteTemplateKey,
|
||||
} from '@/utils/agentPortalCopy'
|
||||
|
||||
defineOptions({ name: 'AgentPortalInvite' })
|
||||
|
||||
const loading = ref(false)
|
||||
const profile = ref<AgentDto | null>(null)
|
||||
const templateKey = ref<AgentInviteTemplateKey>('wechat')
|
||||
const templateOptions = AGENT_INVITE_TEMPLATES.map((t) => ({
|
||||
label: t.label,
|
||||
value: t.key,
|
||||
}))
|
||||
|
||||
const agentCode = computed(() => {
|
||||
const raw = profile.value as unknown as Record<string, unknown> | null
|
||||
return String(raw?.agentCode ?? raw?.AgentCode ?? '').trim()
|
||||
})
|
||||
|
||||
const profileRaw = computed(
|
||||
() => (profile.value as unknown as Record<string, unknown> | null) || {},
|
||||
)
|
||||
|
||||
const baseTypeName = computed(() => {
|
||||
const name = String(profileRaw.value.baseTypeName ?? profileRaw.value.BaseTypeName ?? '').trim()
|
||||
if (name) return name
|
||||
const code = String(profileRaw.value.baseType ?? profileRaw.value.BaseType ?? '').trim()
|
||||
if (code === 'receivable') return '应收'
|
||||
if (code === 'freight') return '运费'
|
||||
return code || '运费'
|
||||
})
|
||||
|
||||
const commissionRateText = computed(() => {
|
||||
const rate = Number(profileRaw.value.commissionRate ?? profileRaw.value.CommissionRate ?? 0)
|
||||
return `${(rate * 100).toFixed(2)}%`
|
||||
})
|
||||
|
||||
const productNamesText = computed(() => {
|
||||
const names =
|
||||
(profileRaw.value.salesProductNames as string[] | undefined) ||
|
||||
(profileRaw.value.SalesProductNames as string[] | undefined) ||
|
||||
[]
|
||||
if (!names.length) return '不限产品'
|
||||
return names.join('、')
|
||||
})
|
||||
|
||||
const pcUrl = computed(() => buildAgentInviteRegisterUrl(agentCode.value))
|
||||
const wechatUrl = computed(() => buildAgentInviteWechatRegisterUrl(agentCode.value))
|
||||
const shareText = computed(() =>
|
||||
buildAgentInviteShareText(agentCode.value, wechatUrl.value || pcUrl.value),
|
||||
)
|
||||
const shareText = computed(() => {
|
||||
const url = wechatUrl.value || pcUrl.value
|
||||
const tpl =
|
||||
AGENT_INVITE_TEMPLATES.find((t) => t.key === templateKey.value) || AGENT_INVITE_TEMPLATES[0]
|
||||
return tpl.build(agentCode.value, url)
|
||||
})
|
||||
|
||||
async function copyText(text: string) {
|
||||
const value = text.trim()
|
||||
@ -169,6 +234,7 @@ onMounted(() => void loadProfile())
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
|
||||
grid-template-areas:
|
||||
'hero qr'
|
||||
'rule howto'
|
||||
'pc howto';
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
@ -189,6 +255,10 @@ onMounted(() => void loadProfile())
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card--rule {
|
||||
grid-area: rule;
|
||||
}
|
||||
|
||||
.invite-card--qr {
|
||||
grid-area: qr;
|
||||
display: flex;
|
||||
@ -203,6 +273,34 @@ onMounted(() => void loadProfile())
|
||||
grid-area: howto;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rule-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rule-label {
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-value {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.invite-card--empty {
|
||||
max-width: 420px;
|
||||
margin: 48px auto;
|
||||
@ -366,6 +464,7 @@ onMounted(() => void loadProfile())
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'hero'
|
||||
'rule'
|
||||
'qr'
|
||||
'pc'
|
||||
'howto';
|
||||
|
||||
152
src/views/agent/workplace/components/AgentAlertBar.vue
Normal file
152
src/views/agent/workplace/components/AgentAlertBar.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-if="alerts.length" class="alert-bar">
|
||||
<button
|
||||
v-for="item in alerts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="alert-chip"
|
||||
:class="`alert-chip--${item.tone}`"
|
||||
@click="item.onClick()"
|
||||
>
|
||||
<span class="alert-chip__label">{{ item.label }}</span>
|
||||
<span class="alert-chip__value">{{ item.value }}</span>
|
||||
<span v-if="item.action" class="alert-chip__action">{{ item.action }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { AgentWorkplaceStats } from '@/api/agent'
|
||||
import { buildPendingPaymentReminderText } from '@/utils/agentPortalCopy'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: AgentWorkplaceStats | null
|
||||
agentName?: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const alerts = computed(() => {
|
||||
const s = props.stats
|
||||
if (!s) return []
|
||||
const list: Array<{
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
tone: 'warning' | 'danger' | 'primary'
|
||||
action?: string
|
||||
onClick: () => void
|
||||
}> = []
|
||||
|
||||
if (s.pendingPayment > 0) {
|
||||
list.push({
|
||||
key: 'pendingPayment',
|
||||
label: '待付款',
|
||||
value: `${s.pendingPayment} 票`,
|
||||
tone: 'warning',
|
||||
action: '复制催付话术',
|
||||
onClick: async () => {
|
||||
const text = buildPendingPaymentReminderText({
|
||||
pendingCount: s.pendingPayment,
|
||||
agentName: props.agentName || '渠道顾问',
|
||||
})
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
Message.success('催付款话术已复制,可发给客户')
|
||||
} catch {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s.problematic > 0) {
|
||||
list.push({
|
||||
key: 'problematic',
|
||||
label: '问题件',
|
||||
value: `${s.problematic} 票`,
|
||||
tone: 'danger',
|
||||
action: '去处理',
|
||||
onClick: () => {
|
||||
void router.push({ path: '/agent/orders', query: { status: 'problematic' } })
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s.deliveryFailed > 0) {
|
||||
list.push({
|
||||
key: 'deliveryFailed',
|
||||
label: '派送失败',
|
||||
value: `${s.deliveryFailed} 票`,
|
||||
tone: 'danger',
|
||||
action: '去查看',
|
||||
onClick: () => {
|
||||
void router.push({ path: '/agent/orders', query: { status: 'deliveryFailed' } })
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s.weekNewCustomers > 0) {
|
||||
list.push({
|
||||
key: 'weekNew',
|
||||
label: '本周新增绑定',
|
||||
value: `${s.weekNewCustomers} 位`,
|
||||
tone: 'primary',
|
||||
action: '看客户',
|
||||
onClick: () => {
|
||||
void router.push('/agent/customers')
|
||||
},
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.alert-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.alert-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
background: var(--color-fill-2);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.alert-chip--warning {
|
||||
background: rgba(255, 125, 0, 0.12);
|
||||
}
|
||||
|
||||
.alert-chip--danger {
|
||||
background: rgba(245, 63, 63, 0.12);
|
||||
}
|
||||
|
||||
.alert-chip--primary {
|
||||
background: var(--color-primary-light-1, #e8f3ff);
|
||||
}
|
||||
|
||||
.alert-chip__label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.alert-chip__value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.alert-chip__action {
|
||||
font-size: 12px;
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
}
|
||||
</style>
|
||||
208
src/views/agent/workplace/components/AgentBanner.vue
Normal file
208
src/views/agent/workplace/components/AgentBanner.vue
Normal file
@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="banner">
|
||||
<div class="banner-row">
|
||||
<div class="banner-hello">
|
||||
<a-avatar :size="40" class="banner-hello__avatar">
|
||||
<img :src="avatarUrl" alt="" />
|
||||
</a-avatar>
|
||||
<p class="banner-hello__line">
|
||||
<span class="banner-hello__greet">{{ greetText }}</span>
|
||||
<span class="banner-hello__name">{{ displayName }}</span>
|
||||
</p>
|
||||
<div class="banner-local-clock" aria-live="polite">
|
||||
<span class="banner-local-clock__label">本地</span>
|
||||
<span class="banner-local-clock__date">{{ dateText }}</span>
|
||||
<span class="banner-local-clock__time">{{ timeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="commission-chip"
|
||||
:class="{ 'commission-chip--alert': pendingAmount > 0 }"
|
||||
title="查看返佣明细"
|
||||
@click="goCommission"
|
||||
>
|
||||
<span class="commission-chip__label">待结算返佣</span>
|
||||
<span class="commission-chip__value">
|
||||
<template v-if="loading">···</template>
|
||||
<template v-else>¥{{ pendingAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</template>
|
||||
</span>
|
||||
<span class="commission-chip__cta">去查看</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store'
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||||
import { resolveAvatarUrl } from '@/utils/avatar'
|
||||
import type { AgentWorkplaceStats } from '@/api/agent'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: AgentWorkplaceStats | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const rbacUserStore = useRbacUserStore()
|
||||
const now = ref(new Date())
|
||||
let timerId: number | undefined
|
||||
|
||||
const displayName = computed(
|
||||
() => userStore.name?.trim() || rbacUserStore.userInfo?.account || '代理',
|
||||
)
|
||||
const avatarUrl = computed(() =>
|
||||
resolveAvatarUrl(userStore.avatar || rbacUserStore.userInfo?.avatar),
|
||||
)
|
||||
const pendingAmount = computed(() => Number(props.stats?.pendingCommissionAmount || 0))
|
||||
|
||||
const greetText = computed(() => {
|
||||
const h = now.value.getHours()
|
||||
if (h < 11) return '早上好,'
|
||||
if (h < 14) return '中午好,'
|
||||
if (h < 18) return '下午好,'
|
||||
return '晚上好,'
|
||||
})
|
||||
|
||||
const dateText = computed(() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(now.value),
|
||||
)
|
||||
|
||||
const timeText = computed(() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now.value),
|
||||
)
|
||||
|
||||
function goCommission() {
|
||||
void router.push('/agent/commission')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timerId = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerId) window.clearInterval(timerId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.banner {
|
||||
padding: 16px 16px 8px;
|
||||
}
|
||||
|
||||
.banner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-hello {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-hello__avatar {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.banner-hello__line {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.banner-hello__greet {
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-hello__name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-local-clock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-local-clock__time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commission-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-fill-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-light-1, #e8f3ff);
|
||||
}
|
||||
}
|
||||
|
||||
.commission-chip--alert {
|
||||
background: rgba(255, 125, 0, 0.12);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 125, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.commission-chip__label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.commission-chip__value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.commission-chip__cta {
|
||||
font-size: 12px;
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
}
|
||||
</style>
|
||||
305
src/views/agent/workplace/components/AgentDataPanel.vue
Normal file
305
src/views/agent/workplace/components/AgentDataPanel.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="stat-panel">
|
||||
<div class="stat-panel__body">
|
||||
<div v-if="loading" class="stat-panel__loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="stat-grid stat-grid--priority">
|
||||
<button
|
||||
v-for="item in priorityItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="stat-card"
|
||||
:class="[
|
||||
`stat-card--${item.tone}`,
|
||||
{
|
||||
'stat-card--clickable': Boolean(item.to),
|
||||
'stat-card--alert': item.alert && item.value > 0,
|
||||
},
|
||||
]"
|
||||
@click="onStatClick(item)"
|
||||
>
|
||||
<div class="stat-card__main">
|
||||
<div class="stat-card__meta">
|
||||
<span class="stat-card__label">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ formatValue(item) }}</span>
|
||||
<span v-if="item.unit" class="stat-card__unit">{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="item.hint" class="stat-card__hint">{{ item.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid stat-grid--secondary">
|
||||
<button
|
||||
v-for="item in secondaryItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="stat-card stat-card--compact"
|
||||
:class="[`stat-card--${item.tone}`, { 'stat-card--clickable': Boolean(item.to) }]"
|
||||
@click="onStatClick(item)"
|
||||
>
|
||||
<div class="stat-card__main">
|
||||
<div class="stat-card__meta">
|
||||
<span class="stat-card__label">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ formatValue(item) }}</span>
|
||||
<span v-if="item.unit" class="stat-card__unit">{{ item.unit }}</span>
|
||||
<span
|
||||
v-else-if="item.showTrend"
|
||||
class="stat-card__trend"
|
||||
:class="item.trendUp ? 'is-up' : 'is-down'"
|
||||
>
|
||||
{{ item.trendUp ? '↑' : '↓' }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { AgentWorkplaceStats } from '@/api/agent'
|
||||
|
||||
type StatTone = 'neutral' | 'primary' | 'warning' | 'danger' | 'success'
|
||||
|
||||
type StatItem = {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
precision?: number
|
||||
unit?: string
|
||||
showTrend?: boolean
|
||||
trendUp?: boolean
|
||||
tone: StatTone
|
||||
alert?: boolean
|
||||
hint?: string
|
||||
to?: { path: string; query?: Record<string, string> }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
stats: AgentWorkplaceStats | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const allItems = computed<StatItem[]>(() => {
|
||||
const data = props.stats
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'pendingPayment',
|
||||
label: '待付款',
|
||||
value: data?.pendingPayment ?? 0,
|
||||
unit: '票',
|
||||
tone: 'warning',
|
||||
alert: true,
|
||||
hint: '点击查看',
|
||||
to: { path: '/agent/orders', query: { status: 'pendingPayment' } },
|
||||
},
|
||||
{
|
||||
key: 'shipping',
|
||||
label: '运输中',
|
||||
value: data?.shipping ?? 0,
|
||||
unit: '票',
|
||||
tone: 'primary',
|
||||
hint: '点击查看',
|
||||
to: { path: '/agent/orders', query: { status: 'shipping' } },
|
||||
},
|
||||
{
|
||||
key: 'problematic',
|
||||
label: '问题件',
|
||||
value: data?.problematic ?? 0,
|
||||
unit: '票',
|
||||
tone: 'danger',
|
||||
alert: true,
|
||||
hint: '点击查看',
|
||||
to: { path: '/agent/orders', query: { status: 'problematic' } },
|
||||
},
|
||||
{
|
||||
key: 'estimatedCommission',
|
||||
label: '预计返佣',
|
||||
value: data?.estimatedCommissionCount ?? 0,
|
||||
unit: '笔',
|
||||
tone: 'primary',
|
||||
hint: '下单后可见',
|
||||
to: { path: '/agent/commission', query: { status: 'estimated' } },
|
||||
},
|
||||
{
|
||||
key: 'pendingCommission',
|
||||
label: '待结算返佣',
|
||||
value: data?.pendingCommissionCount ?? 0,
|
||||
unit: '笔',
|
||||
tone: 'warning',
|
||||
alert: true,
|
||||
hint: '点击查看',
|
||||
to: { path: '/agent/commission', query: { status: 'pending' } },
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: '名下客户',
|
||||
value: data?.customerCount ?? 0,
|
||||
unit: '位',
|
||||
tone: 'primary',
|
||||
to: { path: '/agent/customers' },
|
||||
},
|
||||
{
|
||||
key: 'weekNew',
|
||||
label: '本周新增',
|
||||
value: data?.weekNewCustomers ?? 0,
|
||||
unit: '位',
|
||||
tone: 'primary',
|
||||
to: { path: '/agent/customers' },
|
||||
},
|
||||
{
|
||||
key: 'todayCreated',
|
||||
label: '今日下单',
|
||||
value: data?.todayCreated ?? 0,
|
||||
unit: '票',
|
||||
tone: 'primary',
|
||||
to: { path: '/agent/orders' },
|
||||
},
|
||||
{
|
||||
key: 'deliveryFailed',
|
||||
label: '派送失败',
|
||||
value: data?.deliveryFailed ?? 0,
|
||||
unit: '票',
|
||||
tone: 'danger',
|
||||
alert: true,
|
||||
to: { path: '/agent/orders', query: { status: 'deliveryFailed' } },
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const priorityItems = computed(() => allItems.value.slice(0, 4))
|
||||
const secondaryItems = computed(() => allItems.value.slice(4))
|
||||
|
||||
function formatValue(item: StatItem) {
|
||||
const n = Number(item.value) || 0
|
||||
if (item.precision != null && item.precision > 0) return n.toFixed(item.precision)
|
||||
return Math.round(n).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function onStatClick(item: StatItem) {
|
||||
if (!item.to) return
|
||||
void router.push(item.to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.stat-panel {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.stat-panel__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-grid--priority {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-grid--secondary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 88px;
|
||||
padding: 14px 14px 12px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stat-card--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card--compact {
|
||||
min-height: 72px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.stat-card__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-card--compact .stat-card__value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-card__unit {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.stat-card__hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.stat-card__trend.is-up {
|
||||
color: rgb(var(--success-6));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card__trend.is-down {
|
||||
color: rgb(var(--danger-6));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card--alert .stat-card__value {
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
|
||||
.stat-card--danger.stat-card--alert .stat-card__value {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stat-grid--priority,
|
||||
.stat-grid--secondary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
src/views/agent/workplace/components/AgentInviteCard.vue
Normal file
78
src/views/agent/workplace/components/AgentInviteCard.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<button type="button" class="invite-chip" @click="goInvite">
|
||||
<span class="invite-chip__icon" aria-hidden="true">
|
||||
<icon-share-internal />
|
||||
</span>
|
||||
<span class="invite-chip__body">
|
||||
<span class="invite-chip__title">邀请获客</span>
|
||||
<span class="invite-chip__desc">复制专属链接 / 二维码,客户注册即绑定</span>
|
||||
</span>
|
||||
<icon-right class="invite-chip__arrow" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { IconRight, IconShareInternal } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
defineOptions({ name: 'AgentInviteCard' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goInvite() {
|
||||
void router.push('/agent/invite')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.invite-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-chip__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary-light-1, #e8f3ff);
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-chip__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.invite-chip__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.invite-chip__desc {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-chip__arrow {
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
112
src/views/agent/workplace/components/AgentQuickOps.vue
Normal file
112
src/views/agent/workplace/components/AgentQuickOps.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<a-card
|
||||
class="general-card"
|
||||
title="快捷作业"
|
||||
:header-style="{ paddingBottom: '0' }"
|
||||
:body-style="{ padding: '12px 16px 16px' }"
|
||||
>
|
||||
<div class="start-business-flow">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.path"
|
||||
type="button"
|
||||
class="flow-step"
|
||||
@click="go(step.path)"
|
||||
>
|
||||
<span class="flow-step__index">{{ index + 1 }}</span>
|
||||
<span class="flow-step__icon">
|
||||
<component :is="step.icon" />
|
||||
</span>
|
||||
<span class="flow-step__title">{{ step.title }}</span>
|
||||
<icon-right class="flow-step__arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
IconGift,
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconRight,
|
||||
IconSearch,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
|
||||
defineOptions({ name: 'AgentQuickOps' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const steps = [
|
||||
{ title: '邀请获客', path: '/agent/invite', icon: IconGift },
|
||||
{ title: '运费查价', path: '/agent/priceCheck', icon: IconSearch },
|
||||
{ title: '运单下单', path: '/agent/placeOrder', icon: IconPlus },
|
||||
{ title: '运单列表', path: '/agent/orders', icon: IconList },
|
||||
]
|
||||
|
||||
function go(path: string) {
|
||||
void router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.start-business-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--color-fill-1);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-light-1, #e8f3ff);
|
||||
|
||||
.flow-step__title,
|
||||
.flow-step__arrow,
|
||||
.flow-step__icon {
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flow-step__index {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-step__icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-2);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.flow-step__title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.flow-step__arrow {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
241
src/views/agent/workplace/components/AgentRecentCommission.vue
Normal file
241
src/views/agent/workplace/components/AgentRecentCommission.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="recent-panel">
|
||||
<div class="recent-panel__head">
|
||||
<span class="recent-panel__title">最近返佣</span>
|
||||
<a-button type="text" size="mini" @click="goAll">全部</a-button>
|
||||
</div>
|
||||
|
||||
<div class="recent-panel__body">
|
||||
<div v-if="loading" class="recent-state">
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="!rows.length" class="recent-state recent-state--empty">
|
||||
<p class="recent-state__title">暂无返佣明细</p>
|
||||
<p class="recent-state__desc">名下客户下单后会出现预计返佣,派送完成锁定为待结算</p>
|
||||
</div>
|
||||
<ul v-else class="commission-list">
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="commission-item"
|
||||
@click="goAll"
|
||||
>
|
||||
<div class="commission-item__main">
|
||||
<span class="commission-item__no">{{ row.serviceNo || `#${row.orderId}` }}</span>
|
||||
<span class="commission-item__name">{{ row.customerName || '客户' }}</span>
|
||||
</div>
|
||||
<div class="commission-item__side">
|
||||
<span class="commission-item__amount">¥{{ formatAmount(row.commissionAmount) }}</span>
|
||||
<span
|
||||
class="commission-item__status"
|
||||
:class="{
|
||||
'is-paid': row.status === 'paid',
|
||||
'is-pending': row.status === 'pending',
|
||||
'is-estimated': row.status === 'estimated',
|
||||
'is-void': row.status === 'void',
|
||||
}"
|
||||
>
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AgentPortalApi, type AgentCommissionDto } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentRecentCommission' })
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const rows = ref<AgentCommissionDto[]>([])
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function normalize(raw: AgentCommissionDto): AgentCommissionDto {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
return {
|
||||
...raw,
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
orderId: Number(pick(r, 'orderId', 'OrderId') ?? 0),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
commissionAmount: Number(pick(r, 'commissionAmount', 'CommissionAmount') ?? 0),
|
||||
status: String(pick(r, 'status', 'Status') ?? 'pending'),
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(v: number) {
|
||||
return Number(v || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(st?: string) {
|
||||
switch (st) {
|
||||
case 'estimated':
|
||||
return '预计返佣'
|
||||
case 'pending':
|
||||
return '待结算'
|
||||
case 'paid':
|
||||
return '已打款'
|
||||
case 'void':
|
||||
return '已作废'
|
||||
default:
|
||||
return '待结算'
|
||||
}
|
||||
}
|
||||
|
||||
function goAll() {
|
||||
void router.push('/agent/commission')
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await AgentPortalApi.commissions({ page: 1, pageSize: 8 })
|
||||
rows.value = (res.items || []).map(normalize)
|
||||
} catch {
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.recent-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.recent-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-panel__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.recent-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.recent-state {
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recent-state__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.recent-state__desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.commission-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commission-item__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.commission-item__no {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commission-item__name {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.commission-item__side {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.commission-item__amount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.commission-item__status {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.commission-item__status.is-pending {
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
|
||||
.commission-item__status.is-estimated {
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
}
|
||||
|
||||
.commission-item__status.is-paid {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
|
||||
.commission-item__status.is-void {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
</style>
|
||||
200
src/views/agent/workplace/components/AgentTrendPanel.vue
Normal file
200
src/views/agent/workplace/components/AgentTrendPanel.vue
Normal file
@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<a-card
|
||||
class="trend-card"
|
||||
:bordered="false"
|
||||
:header-style="{ display: 'none' }"
|
||||
:body-style="{
|
||||
padding: '12px 16px 16px',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<div class="trend-head">
|
||||
<span class="trend-head__title">近7日趋势</span>
|
||||
<a-radio-group v-model="metric" type="button" size="small">
|
||||
<a-radio value="orders">下单量</a-radio>
|
||||
<a-radio value="commission">返佣额</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<a-spin v-if="loading" class="trend-loading" />
|
||||
<div v-else-if="isEmpty" class="trend-empty">
|
||||
<p class="trend-empty__title">暂无趋势数据</p>
|
||||
<p class="trend-empty__desc">邀请客户并代客下单后,这里会显示近7日变化</p>
|
||||
<div class="trend-empty__actions">
|
||||
<a-button type="primary" @click="go('/agent/invite')">邀请获客</a-button>
|
||||
<a-button @click="go('/agent/placeOrder')">运单下单</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="trend-chart-wrap">
|
||||
<Chart width="100%" height="260px" :options="chartOption" />
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Chart from '@/components/chart/index.vue'
|
||||
import { useAppStore } from '@/store'
|
||||
import { AgentPortalApi } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentTrendPanel' })
|
||||
|
||||
const router = useRouter()
|
||||
const metric = ref<'orders' | 'commission'>('orders')
|
||||
const loading = ref(true)
|
||||
const labels = ref<string[]>([])
|
||||
const orderCounts = ref<number[]>([])
|
||||
const commissionAmounts = ref<number[]>([])
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (!labels.value.length) return true
|
||||
const orders = orderCounts.value.reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
const amount = commissionAmounts.value.reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
return orders === 0 && amount === 0
|
||||
})
|
||||
|
||||
const seriesData = computed(() =>
|
||||
metric.value === 'orders' ? orderCounts.value : commissionAmounts.value,
|
||||
)
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const appStore = useAppStore()
|
||||
const isDark = appStore.theme === 'dark'
|
||||
const primary = '#1d7281'
|
||||
return {
|
||||
animationDuration: 280,
|
||||
grid: { left: 8, right: 8, top: 16, bottom: 24, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
confine: true,
|
||||
valueFormatter: (v: number) =>
|
||||
metric.value === 'orders'
|
||||
? `${v} 票`
|
||||
: `¥${Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: labels.value,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: isDark ? 'rgba(255,255,255,0.12)' : '#E5E6EB' } },
|
||||
axisLabel: { color: isDark ? 'rgba(255,255,255,0.55)' : '#86909C', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: {
|
||||
lineStyle: { color: isDark ? 'rgba(255,255,255,0.08)' : '#F2F3F5', type: 'dashed' as const },
|
||||
},
|
||||
axisLabel: { color: isDark ? 'rgba(255,255,255,0.55)' : '#86909C', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: seriesData.value,
|
||||
name: metric.value === 'orders' ? '下单量' : '返佣额',
|
||||
lineStyle: { width: 2, color: primary },
|
||||
itemStyle: { color: primary },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear' as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(29, 114, 129, 0.22)' },
|
||||
{ offset: 1, color: 'rgba(29, 114, 129, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
async function loadTrend() {
|
||||
loading.value = true
|
||||
try {
|
||||
const trend = await AgentPortalApi.workplaceTrend(7)
|
||||
labels.value = trend.points.map((p) => p.label)
|
||||
orderCounts.value = trend.points.map((p) => p.orderCount)
|
||||
commissionAmounts.value = trend.points.map((p) => Number(p.commissionAmount) || 0)
|
||||
} catch {
|
||||
labels.value = []
|
||||
orderCounts.value = []
|
||||
commissionAmounts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
void router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => void loadTrend())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.trend-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.trend-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trend-head__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty,
|
||||
.trend-chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trend-empty__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.trend-empty__desc {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.trend-empty__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trend-chart-wrap {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
||||
208
src/views/agent/workplace/index.vue
Normal file
208
src/views/agent/workplace/index.vue
Normal file
@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="workplace-container workplace-container--agent">
|
||||
<div class="container">
|
||||
<div class="left-side">
|
||||
<div class="panel panel--hero">
|
||||
<AgentBanner :stats="stats" :loading="statsLoading" />
|
||||
<AgentAlertBar :stats="stats" :agent-name="agentName" />
|
||||
<AgentDataPanel :stats="stats" :loading="statsLoading" />
|
||||
</div>
|
||||
<div class="panel panel--analysis panel--analysis-fill">
|
||||
<AgentTrendPanel />
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-side">
|
||||
<AgentInviteCard />
|
||||
<div class="panel moduler-wrap">
|
||||
<AgentQuickOps />
|
||||
</div>
|
||||
<div class="panel panel--recent">
|
||||
<AgentRecentCommission />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { AgentPortalApi, type AgentWorkplaceStats } from '@/api/agent'
|
||||
import AgentAlertBar from './components/AgentAlertBar.vue'
|
||||
import AgentBanner from './components/AgentBanner.vue'
|
||||
import AgentDataPanel from './components/AgentDataPanel.vue'
|
||||
import AgentInviteCard from './components/AgentInviteCard.vue'
|
||||
import AgentQuickOps from './components/AgentQuickOps.vue'
|
||||
import AgentRecentCommission from './components/AgentRecentCommission.vue'
|
||||
import AgentTrendPanel from './components/AgentTrendPanel.vue'
|
||||
|
||||
defineOptions({ name: 'AgentPortalWorkplace' })
|
||||
|
||||
const statsLoading = ref(true)
|
||||
const stats = ref<AgentWorkplaceStats | null>(null)
|
||||
const agentName = ref('渠道顾问')
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
stats.value = await AgentPortalApi.workplaceStats()
|
||||
} catch {
|
||||
stats.value = null
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const profile = await AgentPortalApi.profile()
|
||||
const raw = profile as unknown as Record<string, unknown>
|
||||
agentName.value = String(raw.agentName ?? raw.AgentName ?? raw.agentCode ?? '渠道顾问')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.workplace-container {
|
||||
--wp-card-radius: 16px;
|
||||
--wp-inner-radius: 12px;
|
||||
--wp-card-border: none;
|
||||
--wp-card-bg: #ffffff;
|
||||
--wp-card-shadow:
|
||||
inset 0 2px 8px rgba(15, 23, 42, 0.07),
|
||||
inset 0 1px 1px rgba(15, 23, 42, 0.04);
|
||||
--wp-well-bg: #ffffff;
|
||||
--wp-well-shadow:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 4px rgba(15, 23, 42, 0.05);
|
||||
--wp-well-shadow-deep:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.08),
|
||||
inset 0 2px 6px rgba(15, 23, 42, 0.07);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.left-side {
|
||||
flex: 7;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-side .panel {
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.left-side .panel--hero {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.left-side .panel--analysis-fill {
|
||||
flex: 1 1 0;
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
flex: 3;
|
||||
min-width: 280px;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.right-side .moduler-wrap {
|
||||
flex: 0 0 auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.right-side .panel--recent {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: auto;
|
||||
border: var(--wp-card-border);
|
||||
border-radius: var(--wp-card-radius);
|
||||
background: var(--wp-card-bg);
|
||||
box-shadow: var(--wp-card-shadow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workplace-container :deep(.stat-card),
|
||||
.workplace-container :deep(.invite-chip),
|
||||
.workplace-container :deep(.commission-item) {
|
||||
border: none;
|
||||
background: var(--wp-well-bg);
|
||||
box-shadow: var(--wp-well-shadow);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.workplace-container :deep(.stat-card--clickable:hover),
|
||||
.workplace-container :deep(.invite-chip:hover),
|
||||
.workplace-container :deep(.commission-item:hover) {
|
||||
box-shadow: var(--wp-well-shadow-deep);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.left-side,
|
||||
.right-side {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.left-side .panel--analysis-fill {
|
||||
flex: none;
|
||||
min-height: 360px;
|
||||
height: min(52vh, 480px);
|
||||
}
|
||||
|
||||
.right-side .panel--recent {
|
||||
flex: none;
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -118,6 +118,7 @@
|
||||
import { hasPermission } from '@/utils/rbac-permission';
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer';
|
||||
import { buildWaybillContextFromRow, openAskAdvisor } from '@/composables/useAskAdvisor';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
|
||||
defineOptions({
|
||||
name: 'MyOrders',
|
||||
@ -126,6 +127,20 @@
|
||||
const { t, te } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const isAgentPortal = computed(() => rbacUserStore.isAgentPortal());
|
||||
const placeOrderPath = computed(() =>
|
||||
isAgentPortal.value ? '/agent/placeOrder' : '/order/placeOrder',
|
||||
);
|
||||
const priceCheckPath = computed(() =>
|
||||
isAgentPortal.value ? '/agent/priceCheck' : '/order/priceCheck',
|
||||
);
|
||||
/** 代理从名下客户跳入时带 customerId,列表按该客户过滤 */
|
||||
const queryCustomerId = computed(() => {
|
||||
const raw = route.query.customerId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
return Number.isFinite(id) && id > 0 ? id : undefined;
|
||||
});
|
||||
const isFulfillList = computed(() => route.meta.listMode === 'fulfill');
|
||||
const listFulfillmentTypeFilter = computed(() =>
|
||||
isFulfillList.value ? 'warehouse' : undefined,
|
||||
@ -1043,6 +1058,7 @@
|
||||
filters,
|
||||
sortField,
|
||||
sortOrder,
|
||||
customerId: queryCustomerId.value,
|
||||
});
|
||||
if (requestSeq !== listRequestSeq) {
|
||||
return;
|
||||
@ -2127,10 +2143,10 @@
|
||||
class="list-empty-hint"
|
||||
>
|
||||
<span>{{ t('order.myOrders.empty.hint') }}</span>
|
||||
<a-button class="app-btn-primary-soft" @click="router.push('/order/placeOrder')">
|
||||
<a-button class="app-btn-primary-soft" @click="router.push(placeOrderPath)">
|
||||
{{ t('order.myOrders.empty.goPlaceOrder') }}
|
||||
</a-button>
|
||||
<a-button @click="router.push('/order/priceCheck')">
|
||||
<a-button @click="router.push(priceCheckPath)">
|
||||
{{ t('order.myOrders.empty.goPriceCheck') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@ -90,6 +90,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const {
|
||||
isAdmin,
|
||||
isAgent,
|
||||
customerId: adminCustomerId,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
@ -97,6 +98,14 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
ensureMutationCustomerId,
|
||||
resolveSelectedCustomerId,
|
||||
} = useWarehouseAdminCustomer();
|
||||
const customerSelectPlaceholder = computed(() =>
|
||||
isAgent.value ? '请选择名下客户' : t('order.placeOrder.selectCustomer'),
|
||||
);
|
||||
const orderListPath = computed(() => {
|
||||
if (rbacUserStore.isAgentPortal()) return '/agent/orders';
|
||||
if (rbacUserStore.isCustomerPortal()) return '/order/myOrders';
|
||||
return '/order/waybillManage';
|
||||
});
|
||||
const adminCustomerSelected = computed(
|
||||
() => !isAdmin.value || resolveSelectedCustomerId() != null,
|
||||
);
|
||||
@ -2699,6 +2708,15 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueryCustomerId() {
|
||||
if (!isAdmin.value) return;
|
||||
const raw = route.query.customerId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
adminCustomerId.value = id;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPriceCheckDraft() {
|
||||
const fromPriceCheck = String(route.query.from || '') === 'priceCheck';
|
||||
let draft: Record<string, any> | null = null;
|
||||
@ -2708,6 +2726,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
applyQueryCustomerId();
|
||||
if (!fromPriceCheck && !draft) return;
|
||||
|
||||
activeOrderMode.value = 'single';
|
||||
@ -2799,6 +2818,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyQueryCustomerId();
|
||||
loadSingleFormReferenceOptions();
|
||||
void loadCurrentCustomerCode();
|
||||
loadReceiverAiPrefs();
|
||||
@ -3534,7 +3554,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
if (!info) return;
|
||||
if (info.draftSaved && info.orderId) {
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
path: orderListPath.value,
|
||||
query: { status: 'draft', orderId: String(info.orderId), edit: '1' },
|
||||
});
|
||||
return;
|
||||
@ -3565,7 +3585,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
cancelText: t('order.placeOrder.single.failSaveAsDraftStay'),
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
path: orderListPath.value,
|
||||
query: {
|
||||
status: 'draft',
|
||||
...(orderId ? { orderId: String(orderId), edit: '1' } : {}),
|
||||
@ -3588,7 +3608,11 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
openAskAdvisor(router, {
|
||||
orderId: info?.orderId ? String(info.orderId) : undefined,
|
||||
waybillNo: info?.serviceNo,
|
||||
from: rbacUserStore.isCustomerPortal() ? 'myOrders' : 'waybillManage',
|
||||
from: rbacUserStore.isAgentPortal()
|
||||
? 'agentOrders'
|
||||
: rbacUserStore.isCustomerPortal()
|
||||
? 'myOrders'
|
||||
: 'waybillManage',
|
||||
orderStatus: info?.draftSaved ? 'draft' : undefined,
|
||||
});
|
||||
}
|
||||
@ -3608,7 +3632,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
const info = createFailInfo.value;
|
||||
closeCreateFailModal();
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
path: orderListPath.value,
|
||||
query: {
|
||||
status: 'draft',
|
||||
...(info?.orderId ? { orderId: String(info.orderId) } : {}),
|
||||
@ -3698,11 +3722,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
okText: t('order.placeOrder.single.successGoOrders'),
|
||||
cancelText: t('order.placeOrder.single.successStay'),
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal()
|
||||
? '/order/myOrders'
|
||||
: '/order/waybillManage',
|
||||
});
|
||||
router.push({ path: orderListPath.value });
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
@ -3913,7 +3933,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
allow-search
|
||||
allow-clear
|
||||
size="small"
|
||||
:placeholder="t('order.placeOrder.selectCustomer')"
|
||||
:placeholder="customerSelectPlaceholder"
|
||||
:loading="customerLoading"
|
||||
:options="customerOptions"
|
||||
class="place-order-customer-select"
|
||||
@ -4767,7 +4787,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
allow-search
|
||||
allow-clear
|
||||
size="small"
|
||||
:placeholder="t('order.placeOrder.selectCustomer')"
|
||||
:placeholder="customerSelectPlaceholder"
|
||||
:loading="customerLoading"
|
||||
:options="customerOptions"
|
||||
class="place-order-customer-select"
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
import { getDictListAll } from '@/api/system/dict';
|
||||
import { getWarehouseList } from '@/api/system/warehouse';
|
||||
import { useCurrentWarehouseStore } from '@/store';
|
||||
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
|
||||
import ChannelGuidePanel from './components/ChannelGuidePanel.vue';
|
||||
import ShippingMethodTabSelect from './components/ShippingMethodTabSelect.vue';
|
||||
import AiRecommendCard from './components/AiRecommendCard.vue';
|
||||
@ -33,6 +34,13 @@
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const currentWarehouseStore = useCurrentWarehouseStore();
|
||||
const {
|
||||
isAgent,
|
||||
customerId: agentCustomerId,
|
||||
customerOptions: agentCustomerOptions,
|
||||
customerLoading: agentCustomerLoading,
|
||||
resolveSelectedCustomerId,
|
||||
} = useWarehouseAdminCustomer();
|
||||
|
||||
/** 首屏常用国精简,避免芯片墙;其余靠搜索 */
|
||||
const countryShortcutCodes: string[] = ['US', 'GB', 'DE', 'CA', 'FR', 'ES'];
|
||||
@ -574,6 +582,7 @@
|
||||
const length = parseOptionalDimension(formData.length);
|
||||
const width = parseOptionalDimension(formData.width);
|
||||
const height = parseOptionalDimension(formData.height);
|
||||
const selectedCustomerId = isAgent.value ? resolveSelectedCustomerId() : undefined;
|
||||
const { items, failures, queryHint: hint, availableCoupons: coupons } = await postOrderPriceCheck({
|
||||
destinationCountry: formData.destinationCountry,
|
||||
shippingMethod: formData.shippingMethod || '',
|
||||
@ -587,6 +596,7 @@
|
||||
destinationCity: formData.destinationCity?.trim() || '',
|
||||
postalCode: formData.postalCode?.trim() || '',
|
||||
couponInstanceId: couponInstanceId.value,
|
||||
...(selectedCustomerId != null ? { customerId: selectedCustomerId } : {}),
|
||||
});
|
||||
queryHint.value = hint;
|
||||
availableCoupons.value = coupons ?? [];
|
||||
@ -671,6 +681,7 @@
|
||||
const length = parseOptionalDimension(formData.length);
|
||||
const width = parseOptionalDimension(formData.width);
|
||||
const height = parseOptionalDimension(formData.height);
|
||||
const selectedCustomerId = isAgent.value ? resolveSelectedCustomerId() : undefined;
|
||||
const { items, failures } = await postOrderPriceCheck({
|
||||
destinationCountry: formData.destinationCountry,
|
||||
shippingMethod: productCode,
|
||||
@ -684,6 +695,7 @@
|
||||
destinationCity: formData.destinationCity?.trim() || '',
|
||||
postalCode: formData.postalCode?.trim() || '',
|
||||
couponInstanceId: couponInstanceId.value,
|
||||
...(selectedCustomerId != null ? { customerId: selectedCustomerId } : {}),
|
||||
});
|
||||
const nextRows = processQueryResults(items || []);
|
||||
const replacement =
|
||||
@ -779,14 +791,16 @@
|
||||
} catch {
|
||||
// ignore quota
|
||||
}
|
||||
const selectedCustomerId = isAgent.value ? resolveSelectedCustomerId() : undefined;
|
||||
router
|
||||
.push({
|
||||
name: 'PlaceOrder',
|
||||
name: isAgent.value ? 'AgentPortalPlaceOrder' : 'PlaceOrder',
|
||||
query: {
|
||||
from: 'priceCheck',
|
||||
mode: 'single',
|
||||
productCode: draft.productCode || undefined,
|
||||
countryCode: draft.countryCode || undefined,
|
||||
...(selectedCustomerId != null ? { customerId: String(selectedCustomerId) } : {}),
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@ -970,6 +984,17 @@
|
||||
<icon-question-circle />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-select
|
||||
v-if="isAgent"
|
||||
v-model="agentCustomerId"
|
||||
allow-search
|
||||
allow-clear
|
||||
size="small"
|
||||
placeholder="代客查价(可选)"
|
||||
:loading="agentCustomerLoading"
|
||||
:options="agentCustomerOptions"
|
||||
class="price-check-agent-customer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-primary">
|
||||
@ -1366,9 +1391,15 @@
|
||||
.form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.price-check-agent-customer {
|
||||
margin-left: auto;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.form-head__title {
|
||||
margin: 0;
|
||||
color: var(--color-text-1);
|
||||
|
||||
@ -7,7 +7,40 @@
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="referrerLoading"
|
||||
v-if="agentLoading"
|
||||
type="info"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{ t('marketing.register.agentValidating') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="agentValidated === true && effectiveAgentCode"
|
||||
type="success"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{
|
||||
t('marketing.register.agentHint', {
|
||||
name: agentNameMasked || '渠道代理',
|
||||
code: effectiveAgentCode,
|
||||
})
|
||||
}}
|
||||
<template v-if="agentLocked">
|
||||
<div class="agent-lock-tip">{{ t('marketing.register.agentLockedHint') }}</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="agentValidated === false && formState.agentCodeInput.trim()"
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{ t('marketing.register.agentInvalid') }}
|
||||
</a-alert>
|
||||
|
||||
<a-alert
|
||||
v-if="!effectiveAgentCode && referrerLoading"
|
||||
type="info"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
@ -15,18 +48,20 @@
|
||||
{{ t('marketing.register.referrerLoading') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="referrerInfo"
|
||||
v-else-if="!effectiveAgentCode && referrerInfo"
|
||||
type="success"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{ t('marketing.register.referrerHint', {
|
||||
name: referrerInfo.referrerName ?? referrerInfo.ReferrerName ?? referrerInfo.customerName,
|
||||
code: referrerInfo.inviteCode ?? referrerInfo.InviteCode ?? inviteCode,
|
||||
}) }}
|
||||
{{
|
||||
t('marketing.register.referrerHint', {
|
||||
name: referrerInfo.referrerName ?? referrerInfo.ReferrerName ?? referrerInfo.customerName,
|
||||
code: referrerInfo.inviteCode ?? referrerInfo.InviteCode ?? inviteCode,
|
||||
})
|
||||
}}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="referrerTimedOut"
|
||||
v-else-if="!effectiveAgentCode && referrerTimedOut"
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
@ -34,13 +69,21 @@
|
||||
{{ t('marketing.register.referrerTimeout') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="inviteCode && referrerValidated === false"
|
||||
v-else-if="!effectiveAgentCode && inviteCode && referrerValidated === false"
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{ t('marketing.register.invalidRef') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-if="effectiveAgentCode && inviteCode"
|
||||
type="info"
|
||||
:show-icon="true"
|
||||
class="referrer-alert"
|
||||
>
|
||||
{{ t('marketing.register.agentPriorityHint') }}
|
||||
</a-alert>
|
||||
|
||||
<a-form ref="formRef" :model="formState" :rules="formRules" layout="vertical">
|
||||
<a-form-item field="customerName" :label="t('marketing.register.customerName')">
|
||||
@ -58,6 +101,16 @@
|
||||
<a-form-item field="confirmPassword" :label="t('marketing.register.confirmPassword')">
|
||||
<a-input-password v-model="formState.confirmPassword" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item field="agentCodeInput" :label="t('marketing.register.agentField')">
|
||||
<a-input
|
||||
v-model="formState.agentCodeInput"
|
||||
allow-clear
|
||||
:disabled="agentLocked"
|
||||
:placeholder="t('marketing.register.agentPlaceholder')"
|
||||
@blur="onAgentInputBlur"
|
||||
@clear="onAgentInputClear"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-button type="primary" long :loading="submitting" @click="handleSubmit">
|
||||
{{ submitting ? t('marketing.register.submitting') : t('marketing.register.submit') }}
|
||||
@ -73,19 +126,25 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FieldRule, FormInstance } from '@arco-design/web-vue'
|
||||
import { CustomerService } from '@/api/customer'
|
||||
import { validateInviteCode } from '@/api/marketing/portal'
|
||||
import { validateAgentCode } from '@/api/agent'
|
||||
import { isLetterAndDigitPassword } from '@/utils/customerPassword'
|
||||
import {
|
||||
clearStashedAdvisorInvite,
|
||||
resolveAdvisorInvite,
|
||||
stashAdvisorInvite,
|
||||
} from '@/utils/advisorInviteStorage'
|
||||
import {
|
||||
clearStashedAgentInvite,
|
||||
resolveAgentInvite,
|
||||
stashAgentInvite,
|
||||
} from '@/utils/agentInviteStorage'
|
||||
import { stashInviteRef } from '@/utils/inviteRefStorage'
|
||||
import { isTimeoutError, withTimeout } from '@/utils/withTimeout'
|
||||
import { preferH5Portal } from '@/utils/device'
|
||||
@ -94,6 +153,7 @@ import { isAdminH5Path } from '@/utils/login-portal'
|
||||
defineOptions({ name: 'CustomerRegister' })
|
||||
|
||||
const REFERRER_TIMEOUT_MS = 15000
|
||||
const AGENT_TIMEOUT_MS = 15000
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@ -119,11 +179,15 @@ function resolvePostRegisterLogin() {
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const inviteCode = ref('')
|
||||
const advisorCode = ref('')
|
||||
const agentCode = ref('')
|
||||
const agentLocked = ref(false)
|
||||
const agentValidated = ref<boolean | null>(null)
|
||||
const agentNameMasked = ref('')
|
||||
const agentLoading = ref(false)
|
||||
const referrerInfo = ref<Record<string, unknown> | null>(null)
|
||||
const referrerValidated = ref<boolean | null>(null)
|
||||
const referrerLoading = ref(false)
|
||||
@ -135,8 +199,13 @@ const formState = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agentCodeInput: '',
|
||||
})
|
||||
|
||||
const effectiveAgentCode = computed(() =>
|
||||
agentValidated.value === true ? formState.agentCodeInput.trim() : '',
|
||||
)
|
||||
|
||||
const formRules: Record<string, FieldRule[]> = {
|
||||
customerName: [{ required: true, message: t('marketing.register.customerNameRequired') }],
|
||||
phone: [{ required: true, message: t('marketing.register.phoneRequired') }],
|
||||
@ -164,6 +233,56 @@ const formRules: Record<string, FieldRule[]> = {
|
||||
],
|
||||
}
|
||||
|
||||
async function validateAgent(code: string, locked: boolean) {
|
||||
const trimmed = code.trim()
|
||||
if (!trimmed) {
|
||||
agentValidated.value = null
|
||||
agentNameMasked.value = ''
|
||||
return
|
||||
}
|
||||
agentLoading.value = true
|
||||
try {
|
||||
const data = await withTimeout(validateAgentCode(trimmed), AGENT_TIMEOUT_MS)
|
||||
if (!data.valid) {
|
||||
agentValidated.value = false
|
||||
agentNameMasked.value = ''
|
||||
if (locked) {
|
||||
Message.warning(data.message || t('marketing.register.agentInvalid'))
|
||||
}
|
||||
return
|
||||
}
|
||||
formState.agentCodeInput = data.agentCode || trimmed
|
||||
agentNameMasked.value = data.agentNameMasked || ''
|
||||
agentValidated.value = true
|
||||
agentLocked.value = locked
|
||||
stashAgentInvite(formState.agentCodeInput, locked)
|
||||
} catch {
|
||||
agentValidated.value = false
|
||||
agentNameMasked.value = ''
|
||||
} finally {
|
||||
agentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAgentInputBlur() {
|
||||
if (agentLocked.value) return
|
||||
const code = formState.agentCodeInput.trim()
|
||||
if (!code) {
|
||||
agentValidated.value = null
|
||||
agentNameMasked.value = ''
|
||||
clearStashedAgentInvite()
|
||||
return
|
||||
}
|
||||
void validateAgent(code, false)
|
||||
}
|
||||
|
||||
function onAgentInputClear() {
|
||||
if (agentLocked.value) return
|
||||
agentValidated.value = null
|
||||
agentNameMasked.value = ''
|
||||
clearStashedAgentInvite()
|
||||
}
|
||||
|
||||
async function loadReferrer(code: string) {
|
||||
inviteCode.value = code
|
||||
stashInviteRef(code)
|
||||
@ -209,6 +328,16 @@ async function handleSubmit() {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const typedAgent = formState.agentCodeInput.trim()
|
||||
if (typedAgent && agentValidated.value !== true) {
|
||||
await validateAgent(typedAgent, agentLocked.value)
|
||||
if (agentValidated.value !== true) {
|
||||
Message.warning(t('marketing.register.agentInvalid'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await CustomerService.create({
|
||||
@ -216,8 +345,8 @@ async function handleSubmit() {
|
||||
phone: formState.phone.trim(),
|
||||
email: formState.email.trim(),
|
||||
password: formState.password,
|
||||
...(agentCode.value
|
||||
? ({ agentCode: agentCode.value } as Record<string, string>)
|
||||
...(effectiveAgentCode.value
|
||||
? ({ agentCode: effectiveAgentCode.value } as Record<string, string>)
|
||||
: inviteCode.value && referrerValidated.value
|
||||
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
||||
: {}),
|
||||
@ -226,6 +355,7 @@ async function handleSubmit() {
|
||||
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
|
||||
clearStashedInviteRef()
|
||||
clearStashedAdvisorInvite()
|
||||
clearStashedAgentInvite()
|
||||
Message.success(t('marketing.register.success'))
|
||||
router.replace(resolvePostRegisterLogin())
|
||||
} catch (e: any) {
|
||||
@ -240,13 +370,17 @@ function goLogin() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const agent = String(route.query.agent ?? route.query.agentCode ?? '').trim()
|
||||
if (agent) {
|
||||
agentCode.value = agent
|
||||
const agent = resolveAgentInvite(route.query as Record<string, unknown>)
|
||||
if (agent.code) {
|
||||
formState.agentCodeInput = agent.code
|
||||
agentLocked.value = agent.locked
|
||||
void validateAgent(agent.code, agent.locked)
|
||||
}
|
||||
|
||||
const ref = String(route.query.ref ?? route.query.invite ?? '').trim()
|
||||
// 有代理码时不校验 C2C 邀请码(与后端互斥一致)
|
||||
if (ref && !agent) void loadReferrer(ref)
|
||||
// 有代理码时不校验 C2C(与后端互斥一致);手填校验成功后也会优先 agent
|
||||
if (ref && !agent.code) void loadReferrer(ref)
|
||||
|
||||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>)
|
||||
if (advisor) {
|
||||
advisorCode.value = advisor
|
||||
@ -294,6 +428,12 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-lock-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
margin: 20px 0 0;
|
||||
text-align: center;
|
||||
|
||||
@ -13,15 +13,15 @@
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model="filterState.salesman"
|
||||
:placeholder="t('user.customer.fields.salesPerson')"
|
||||
v-model="filterState.agentId"
|
||||
:placeholder="t('user.customer.fields.agent')"
|
||||
allow-clear
|
||||
allow-search
|
||||
style="width: 160px"
|
||||
style="width: 180px"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in userOptions"
|
||||
:key="`sales-${option.value}`"
|
||||
v-for="option in agentOptions"
|
||||
:key="`agent-filter-${option.value}`"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
@ -349,17 +349,18 @@
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="salesPerson">
|
||||
<template #label>{{ t('user.customer.fields.salesPerson') }}</template>
|
||||
<a-form-item field="agentId">
|
||||
<template #label>{{ t('user.customer.fields.agent') }}</template>
|
||||
<a-select
|
||||
v-model="formState.salesPerson"
|
||||
v-model="formState.agentId"
|
||||
:placeholder="t('user.customer.placeholder.select')"
|
||||
allow-clear
|
||||
allow-search
|
||||
:disabled="isEdit && formState.parentCustomerId != null"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in userOptions"
|
||||
:key="option.value"
|
||||
v-for="option in agentOptions"
|
||||
:key="`agent-form-${option.value}`"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
@ -436,6 +437,7 @@ import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import type { CustomerDto } from '@/api/customer'
|
||||
import { CustomerService } from '@/api/customer'
|
||||
import { AgentAdminApi, type AgentDto } from '@/api/agent'
|
||||
import { resolveAvatarUrl } from '@/utils/avatar'
|
||||
import { RoleService } from '@/api/rbac/generated/services/RoleService'
|
||||
import { UserService } from '@/api/rbac/generated/services/UserService'
|
||||
@ -472,7 +474,7 @@ const pageSize = ref(50)
|
||||
|
||||
const filterState = reactive({
|
||||
keyword: '',
|
||||
salesman: '' as string | undefined,
|
||||
agentId: undefined as number | undefined,
|
||||
customerService: '' as string | undefined,
|
||||
})
|
||||
const headerSearchParams = reactive<Record<string, string>>({})
|
||||
@ -485,7 +487,7 @@ const CUSTOMER_SERVER_FILTER_KEYS = [
|
||||
'email',
|
||||
'phone',
|
||||
'wechatNickname',
|
||||
'salesPerson',
|
||||
'agent',
|
||||
'customerService',
|
||||
'status',
|
||||
] as const
|
||||
@ -501,11 +503,15 @@ const CUSTOMER_SORTABLE_KEYS = [
|
||||
const processedTableData = computed(() => tableData.value)
|
||||
|
||||
type SelectOption = { label: string; value: string | number }
|
||||
type AgentSelectOption = { label: string; value: number; agentCode: string }
|
||||
const userOptions = ref<SelectOption[]>([])
|
||||
const agentOptions = ref<AgentSelectOption[]>([])
|
||||
const agentList = ref<AgentDto[]>([])
|
||||
const memberLevelOptions = ref<SelectOption[]>([])
|
||||
const userList = ref<any[]>([])
|
||||
|
||||
const CUSTOMER_ROLE_CODE = 'Customer'
|
||||
const AGENT_ROLE_CODE = 'Agent'
|
||||
|
||||
function getUserRoleCodes(u: any): string[] {
|
||||
const roles = u?.roles ?? u?.Roles ?? []
|
||||
@ -515,14 +521,15 @@ function getUserRoleCodes(u: any): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** 客户门户账号(Customer / CustomerSub_* 角色)不可作为销售或顾问 */
|
||||
function isCustomerPortalUser(u: any, excludedUserIds?: Set<string>): boolean {
|
||||
/** 客户门户 / 渠道代理账号不可作为所属顾问 */
|
||||
function isExcludedAdvisorUser(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_')
|
||||
code.localeCompare(AGENT_ROLE_CODE, undefined, { sensitivity: 'accent' }) === 0 ||
|
||||
code.startsWith('CustomerSub_'),
|
||||
)
|
||||
}
|
||||
|
||||
@ -612,6 +619,59 @@ async function loadMemberLevelOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 启用状态:后端 EnableStatus.Enabled = 2 */
|
||||
const AGENT_STATUS_ENABLED = 2
|
||||
|
||||
async function loadAgentOptions() {
|
||||
try {
|
||||
const pageSize = 100
|
||||
const agents: AgentDto[] = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const { items } = await AgentAdminApi.list({
|
||||
page,
|
||||
pageSize,
|
||||
status: AGENT_STATUS_ENABLED,
|
||||
})
|
||||
const batch = items || []
|
||||
agents.push(...batch)
|
||||
if (batch.length < pageSize) break
|
||||
page += 1
|
||||
}
|
||||
agentList.value = agents
|
||||
agentOptions.value = agents
|
||||
.filter((a) => a?.id && a.agentCode)
|
||||
.map((a) => {
|
||||
const name = String(a.agentName || '').trim()
|
||||
const code = String(a.agentCode || '').trim()
|
||||
return {
|
||||
value: Number(a.id),
|
||||
agentCode: code,
|
||||
label: name && name !== code ? `${name}(${code})` : code,
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载代理下拉失败')
|
||||
agentList.value = []
|
||||
agentOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentCodeForForm(agentId: number | null | undefined): string {
|
||||
if (agentId == null || agentId <= 0) return ''
|
||||
const hit = agentOptions.value.find((o) => o.value === Number(agentId))
|
||||
if (hit?.agentCode) return hit.agentCode
|
||||
const fromList = agentList.value.find((a) => Number(a.id) === Number(agentId))
|
||||
return String(fromList?.agentCode || '').trim()
|
||||
}
|
||||
|
||||
function formatAgentDisplay(row: CustomerRow | any): string {
|
||||
const name = String(row?.agentName ?? '').trim()
|
||||
const code = String(row?.agentCode ?? '').trim()
|
||||
if (name && code && name !== code) return `${name}(${code})`
|
||||
return name || code || '--'
|
||||
}
|
||||
|
||||
async function loadFormSelectOptions() {
|
||||
try {
|
||||
const pageSize = 100
|
||||
@ -643,27 +703,29 @@ async function loadFormSelectOptions() {
|
||||
}
|
||||
|
||||
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) {
|
||||
const excludeRoleCodes = [CUSTOMER_ROLE_CODE, AGENT_ROLE_CODE]
|
||||
for (const roleCode of excludeRoleCodes) {
|
||||
const role = roles.find(
|
||||
(r: any) =>
|
||||
String(r?.code ?? r?.Code ?? '').localeCompare(roleCode, undefined, {
|
||||
sensitivity: 'accent',
|
||||
}) === 0,
|
||||
)
|
||||
const roleId = role?.id ?? role?.Id
|
||||
if (!roleId) continue
|
||||
try {
|
||||
const customerUsers = await UserService.getRbacUserGetUsersByRole(String(customerRoleId))
|
||||
for (const u of normalizeListPayload(customerUsers)) {
|
||||
const roleUsers = await UserService.getRbacUserGetUsersByRole(String(roleId))
|
||||
for (const u of normalizeListPayload(roleUsers)) {
|
||||
const id = String(u?.id ?? u?.Id ?? '')
|
||||
if (id) excludedUserIds.add(id)
|
||||
}
|
||||
} catch {
|
||||
// 拉取 Customer 角色用户失败时,仍依赖用户列表上的 roles 字段过滤
|
||||
// 按角色拉用户失败时,仍依赖用户列表上的 roles 字段过滤
|
||||
}
|
||||
}
|
||||
|
||||
userList.value = users
|
||||
const staffUsers = users.filter((u) => !isCustomerPortalUser(u, excludedUserIds))
|
||||
const staffUsers = users.filter((u) => !isExcludedAdvisorUser(u, excludedUserIds))
|
||||
userOptions.value = staffUsers
|
||||
.map(mapUserToSelectOption)
|
||||
.filter((item): item is SelectOption => item != null)
|
||||
@ -681,11 +743,14 @@ function resolveCustomerId(record: CustomerRow | any): string | number | undefin
|
||||
}
|
||||
|
||||
function mapCustomerRow(r: any): CustomerRow {
|
||||
const agentIdRaw = r.agentId ?? r.AgentId
|
||||
return {
|
||||
...r,
|
||||
id: r.id ?? r.Id,
|
||||
accountBalance: r.accountBalance ?? r.balance ?? r.Balance,
|
||||
salesPerson: r.salesPerson ?? r.salesman ?? r.Salesman,
|
||||
agentId: agentIdRaw != null && agentIdRaw !== '' ? Number(agentIdRaw) : null,
|
||||
agentCode: r.agentCode ?? r.AgentCode ?? '',
|
||||
agentName: r.agentName ?? r.AgentName ?? '',
|
||||
memberLevelId: r.memberLevelId ?? r.MemberLevelId,
|
||||
memberLevelName: r.memberLevelName ?? r.MemberLevelName,
|
||||
priceTierKey: r.priceTierKey ?? r.PriceTierKey ?? '',
|
||||
@ -701,7 +766,7 @@ async function loadList() {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword || undefined,
|
||||
salesman: adapted.salesman || filterState.salesman || undefined,
|
||||
agentId: adapted.agentId || filterState.agentId || undefined,
|
||||
customerService: adapted.customerService || filterState.customerService || undefined,
|
||||
status: adapted.status as any,
|
||||
sortField: listSortField.value || undefined,
|
||||
@ -753,7 +818,7 @@ const hotColumns = computed(() =>
|
||||
{ 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: 'agent', label: t('user.customer.fields.agent'), width: 120 },
|
||||
{ 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 },
|
||||
@ -816,8 +881,8 @@ function customCellRenderer(row: number, col: number, value: any, rowData: any):
|
||||
if (val === undefined || val === null) return '--'
|
||||
return Number(val).toLocaleString()
|
||||
}
|
||||
if (colKey === 'salesPerson') {
|
||||
return formatStaffDisplay(rowData?.salesPerson ?? rowData?.salesman)
|
||||
if (colKey === 'agent') {
|
||||
return formatAgentDisplay(rowData)
|
||||
}
|
||||
if (colKey === 'customerService') {
|
||||
return formatStaffDisplay(rowData?.customerService)
|
||||
@ -944,8 +1009,9 @@ const formState = reactive<Partial<CustomerRow>>({
|
||||
memberLevelId: undefined,
|
||||
priceTierKey: '普通会员',
|
||||
remark: '',
|
||||
salesPerson: '',
|
||||
agentId: undefined as number | null | undefined,
|
||||
customerService: '',
|
||||
parentCustomerId: null as number | null | undefined,
|
||||
})
|
||||
|
||||
const priceTierOptions = [
|
||||
@ -994,8 +1060,9 @@ function handleAdd() {
|
||||
memberLevelId: undefined,
|
||||
priceTierKey: '普通会员',
|
||||
remark: '',
|
||||
salesPerson: '',
|
||||
agentId: undefined,
|
||||
customerService: '',
|
||||
parentCustomerId: null,
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
@ -1003,6 +1070,20 @@ function handleAdd() {
|
||||
function handleEdit(record: CustomerRow) {
|
||||
isEdit.value = true
|
||||
currentId.value = resolveCustomerId(record)
|
||||
const agentId =
|
||||
record.agentId != null && Number(record.agentId) > 0 ? Number(record.agentId) : undefined
|
||||
// 已停用代理仍展示当前绑定,避免下拉空白
|
||||
if (agentId && !agentOptions.value.some((o) => o.value === agentId)) {
|
||||
const code = String(record.agentCode || '').trim()
|
||||
agentOptions.value = [
|
||||
...agentOptions.value,
|
||||
{
|
||||
value: agentId,
|
||||
agentCode: code,
|
||||
label: formatAgentDisplay(record),
|
||||
},
|
||||
]
|
||||
}
|
||||
Object.assign(formState, {
|
||||
customerCode: record.customerCode,
|
||||
customerName: record.customerName,
|
||||
@ -1017,8 +1098,9 @@ function handleEdit(record: CustomerRow) {
|
||||
memberLevelId: record.memberLevelId ?? undefined,
|
||||
priceTierKey: record.priceTierKey || '普通客户',
|
||||
remark: record.remark,
|
||||
salesPerson: resolveStaffAccountForForm(record.salesPerson ?? record.salesman),
|
||||
agentId,
|
||||
customerService: resolveStaffAccountForForm(record.customerService),
|
||||
parentCustomerId: record.parentCustomerId ?? null,
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
@ -1113,14 +1195,18 @@ async function handleModalOk(): Promise<boolean> {
|
||||
}
|
||||
modalLoading.value = true
|
||||
try {
|
||||
const salesPersonAccount = resolveStaffAccountForForm(formState.salesPerson)
|
||||
const customerServiceAccount = resolveStaffAccountForForm(formState.customerService)
|
||||
const agentCode = resolveAgentCodeForForm(formState.agentId as number | null | undefined)
|
||||
// 主客户才改绑;子客户禁用下拉,不传 agentCode 以免误解绑
|
||||
const canBindAgent = !(isEdit.value && formState.parentCustomerId != null)
|
||||
|
||||
if (isEdit.value && currentId.value != null && currentId.value !== '') {
|
||||
await CustomerService.update(currentId.value, {
|
||||
...formState,
|
||||
salesPerson: salesPersonAccount,
|
||||
salesPerson: undefined,
|
||||
salesman: undefined,
|
||||
customerService: customerServiceAccount,
|
||||
agentCode: canBindAgent ? agentCode : undefined,
|
||||
})
|
||||
Message.success(t('user.customer.message.updateSuccess'))
|
||||
} else {
|
||||
@ -1128,8 +1214,10 @@ async function handleModalOk(): Promise<boolean> {
|
||||
...formState,
|
||||
customerCode: undefined,
|
||||
password: formState.password,
|
||||
salesPerson: salesPersonAccount,
|
||||
salesPerson: undefined,
|
||||
salesman: undefined,
|
||||
customerService: customerServiceAccount,
|
||||
agentCode: agentCode || undefined,
|
||||
})
|
||||
Message.success(t('user.customer.message.createSuccess'))
|
||||
currentPage.value = 1
|
||||
@ -1153,6 +1241,7 @@ const handsontableRef = ref<InstanceType<typeof HandsontablePro>>()
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
loadFormSelectOptions()
|
||||
loadAgentOptions()
|
||||
loadMemberLevelOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -144,8 +144,8 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('user.customer.fields.salesPerson') }}</span>
|
||||
<span class="info-value">{{ formatPerson(profile.salesPerson ?? profile.salesman) }}</span>
|
||||
<span class="info-label">{{ t('user.customer.fields.agent') }}</span>
|
||||
<span class="info-value">{{ formatAgent(profile) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('user.customer.fields.customerService') }}</span>
|
||||
@ -411,6 +411,13 @@ function formatPerson(val: string | number | undefined): string {
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function formatAgent(row: CustomerDto): string {
|
||||
const name = String(row.agentName ?? '').trim()
|
||||
const code = String(row.agentCode ?? '').trim()
|
||||
if (name && code && name !== code) return `${name}(${code})`
|
||||
return name || code || '--'
|
||||
}
|
||||
|
||||
async function copyText(text?: string | number | null) {
|
||||
if (!text) return
|
||||
try {
|
||||
|
||||
@ -91,7 +91,28 @@
|
||||
|
||||
<section class="wechat-h5-card">
|
||||
<a-alert
|
||||
v-if="referrerInfo"
|
||||
v-if="agentLoading"
|
||||
type="info"
|
||||
:show-icon="true"
|
||||
class="wechat-h5-referrer"
|
||||
>
|
||||
{{ t('marketing.register.agentValidating') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="effectiveAgentCode"
|
||||
type="success"
|
||||
:show-icon="true"
|
||||
class="wechat-h5-referrer"
|
||||
>
|
||||
{{
|
||||
t('marketing.register.agentHint', {
|
||||
name: agentNameMasked || '渠道代理',
|
||||
code: effectiveAgentCode,
|
||||
})
|
||||
}}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else-if="referrerInfo"
|
||||
type="success"
|
||||
:show-icon="true"
|
||||
class="wechat-h5-referrer"
|
||||
@ -111,6 +132,14 @@
|
||||
>
|
||||
{{ t('wechat.register.invalidRef') }}
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-if="agentValidated === false && formState.agentCodeInput.trim()"
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
class="wechat-h5-referrer"
|
||||
>
|
||||
{{ t('marketing.register.agentInvalid') }}
|
||||
</a-alert>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
@ -140,6 +169,17 @@
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="agentCodeInput" :label="t('marketing.register.agentField')">
|
||||
<a-input
|
||||
v-model="formState.agentCodeInput"
|
||||
size="large"
|
||||
:disabled="agentLocked"
|
||||
:placeholder="t('marketing.register.agentPlaceholder')"
|
||||
allow-clear
|
||||
@blur="onAgentInputBlur"
|
||||
@clear="onAgentInputClear"
|
||||
/>
|
||||
</a-form-item>
|
||||
<p class="wechat-h5-pwd-tip">
|
||||
{{ t('wechat.register.passwordTipPrefix') }}
|
||||
<strong>{{ DEFAULT_CUSTOMER_PASSWORD }}</strong>
|
||||
@ -200,6 +240,7 @@ import {
|
||||
type WeChatOAuthUserInfo,
|
||||
} from '@/api/wechat';
|
||||
import { validateInviteCode } from '@/api/marketing/portal';
|
||||
import { validateAgentCode } from '@/api/agent';
|
||||
import { handleApiError } from '@/utils/rbac-error-handler';
|
||||
import {
|
||||
DEFAULT_CUSTOMER_PASSWORD,
|
||||
@ -216,6 +257,11 @@ import {
|
||||
resolveAdvisorInvite,
|
||||
stashAdvisorInvite,
|
||||
} from '@/utils/advisorInviteStorage';
|
||||
import {
|
||||
clearStashedAgentInvite,
|
||||
resolveAgentInvite,
|
||||
stashAgentInvite,
|
||||
} from '@/utils/agentInviteStorage';
|
||||
import { buildInviteRegisterUrl } from '@/utils/inviteLink';
|
||||
import { isTimeoutError, withTimeout } from '@/utils/withTimeout';
|
||||
import { isWeChatBrowser, hideWeChatBrowserNav } from '@/utils/device';
|
||||
@ -271,6 +317,8 @@ function goBind() {
|
||||
|
||||
function startOAuth() {
|
||||
authModalVisible.value = false;
|
||||
const agent = formState.agentCodeInput.trim() || agentCode.value;
|
||||
if (agent) stashAgentInvite(agent, agentLocked.value);
|
||||
void redirectToWeChatOAuth('register', inviteCode.value);
|
||||
}
|
||||
|
||||
@ -306,6 +354,10 @@ const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
|
||||
const inviteCode = ref('');
|
||||
const advisorCode = ref('');
|
||||
const agentCode = ref('');
|
||||
const agentLocked = ref(false);
|
||||
const agentValidated = ref<boolean | null>(null);
|
||||
const agentNameMasked = ref('');
|
||||
const agentLoading = ref(false);
|
||||
const referrerValidated = ref<boolean | null>(null);
|
||||
const referrerInfo = ref<{ name: string; code: string } | null>(null);
|
||||
let loadingSlowTimer: number | undefined;
|
||||
@ -313,8 +365,68 @@ let loadingSlowTimer: number | undefined;
|
||||
const formState = reactive({
|
||||
customerName: '',
|
||||
password: DEFAULT_CUSTOMER_PASSWORD,
|
||||
agentCodeInput: '',
|
||||
});
|
||||
|
||||
const effectiveAgentCode = computed(() =>
|
||||
agentValidated.value === true ? formState.agentCodeInput.trim() : '',
|
||||
);
|
||||
|
||||
const AGENT_TIMEOUT_MS = 15000;
|
||||
|
||||
async function validateAgent(code: string, locked: boolean) {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
agentValidated.value = null;
|
||||
agentNameMasked.value = '';
|
||||
return;
|
||||
}
|
||||
agentLoading.value = true;
|
||||
try {
|
||||
const data = await withTimeout(validateAgentCode(trimmed), AGENT_TIMEOUT_MS);
|
||||
if (!data.valid) {
|
||||
agentValidated.value = false;
|
||||
agentNameMasked.value = '';
|
||||
if (locked) {
|
||||
Message.warning(data.message || t('marketing.register.agentInvalid'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
formState.agentCodeInput = data.agentCode || trimmed;
|
||||
agentCode.value = formState.agentCodeInput;
|
||||
agentNameMasked.value = data.agentNameMasked || '';
|
||||
agentValidated.value = true;
|
||||
agentLocked.value = locked;
|
||||
stashAgentInvite(formState.agentCodeInput, locked);
|
||||
} catch {
|
||||
agentValidated.value = false;
|
||||
agentNameMasked.value = '';
|
||||
} finally {
|
||||
agentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAgentInputBlur() {
|
||||
if (agentLocked.value) return;
|
||||
const code = formState.agentCodeInput.trim();
|
||||
if (!code) {
|
||||
agentValidated.value = null;
|
||||
agentNameMasked.value = '';
|
||||
agentCode.value = '';
|
||||
clearStashedAgentInvite();
|
||||
return;
|
||||
}
|
||||
void validateAgent(code, false);
|
||||
}
|
||||
|
||||
function onAgentInputClear() {
|
||||
if (agentLocked.value) return;
|
||||
agentValidated.value = null;
|
||||
agentNameMasked.value = '';
|
||||
agentCode.value = '';
|
||||
clearStashedAgentInvite();
|
||||
}
|
||||
|
||||
const avatarLetter = computed(() => {
|
||||
const name = wxUserInfo.value?.nickname || formState.customerName || 'W';
|
||||
return name.trim().charAt(0).toUpperCase();
|
||||
@ -420,8 +532,17 @@ async function handleSubmit() {
|
||||
const errors = await formRef.value?.validate();
|
||||
if (errors) return;
|
||||
|
||||
const typedAgent = formState.agentCodeInput.trim();
|
||||
if (typedAgent && agentValidated.value !== true) {
|
||||
await validateAgent(typedAgent, agentLocked.value);
|
||||
if (agentValidated.value !== true) {
|
||||
Message.warning(t('marketing.register.agentInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const canBindInvite = Boolean(
|
||||
!agentCode.value && inviteCode.value && referrerValidated.value === true,
|
||||
!effectiveAgentCode.value && inviteCode.value && referrerValidated.value === true,
|
||||
);
|
||||
|
||||
submitting.value = true;
|
||||
@ -429,7 +550,7 @@ async function handleSubmit() {
|
||||
const result = await registerByWechat({
|
||||
customerName: formState.customerName.trim(),
|
||||
password: formState.password,
|
||||
...(agentCode.value ? { agentCode: agentCode.value } : {}),
|
||||
...(effectiveAgentCode.value ? { agentCode: effectiveAgentCode.value } : {}),
|
||||
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
||||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||||
wechat: {
|
||||
@ -441,6 +562,7 @@ async function handleSubmit() {
|
||||
});
|
||||
clearStashedWeChatInviteRef();
|
||||
clearStashedAdvisorInvite();
|
||||
clearStashedAgentInvite();
|
||||
Message.success(t('wechat.register.success'));
|
||||
const account = String(result?.customerCode || '').trim();
|
||||
const loggedIn = account
|
||||
@ -466,12 +588,15 @@ async function handleSubmit() {
|
||||
onMounted(() => {
|
||||
hideWeChatBrowserNav();
|
||||
loadingHint.value = t('wechat.common.loading');
|
||||
const agent = String(route.query.agent ?? route.query.agentCode ?? '').trim();
|
||||
if (agent) {
|
||||
agentCode.value = agent;
|
||||
const agent = resolveAgentInvite(route.query as Record<string, unknown>);
|
||||
if (agent.code) {
|
||||
formState.agentCodeInput = agent.code;
|
||||
agentCode.value = agent.code;
|
||||
agentLocked.value = agent.locked;
|
||||
void validateAgent(agent.code, agent.locked);
|
||||
}
|
||||
const refCode = resolveWeChatInviteRef(route.query as Record<string, unknown>);
|
||||
if (refCode && !agent) {
|
||||
if (refCode && !agent.code) {
|
||||
void loadReferrer(refCode);
|
||||
}
|
||||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>);
|
||||
|
||||
@ -338,7 +338,16 @@ export default defineConfig(({ mode }) => {
|
||||
if (path.startsWith('/agent/admin') || path.startsWith('/agent/portal')) {
|
||||
return undefined;
|
||||
}
|
||||
const agentSpaPages = ['/agent', '/agent/invite', '/agent/customers', '/agent/orders'];
|
||||
const agentSpaPages = [
|
||||
'/agent',
|
||||
'/agent/workplace',
|
||||
'/agent/invite',
|
||||
'/agent/priceCheck',
|
||||
'/agent/placeOrder',
|
||||
'/agent/customers',
|
||||
'/agent/orders',
|
||||
'/agent/commission',
|
||||
];
|
||||
if (agentSpaPages.some((p) => path === p || path === `${p}/`)) {
|
||||
return '/index.html';
|
||||
}
|
||||
@ -575,9 +584,13 @@ export default defineConfig(({ mode }) => {
|
||||
'/Plugin/AddPackage',
|
||||
// 渠道代理门户(API 为 /agent/admin、/agent/portal,勿用前缀匹配)
|
||||
'/agent',
|
||||
'/agent/workplace',
|
||||
'/agent/invite',
|
||||
'/agent/priceCheck',
|
||||
'/agent/placeOrder',
|
||||
'/agent/customers',
|
||||
'/agent/orders',
|
||||
'/agent/commission',
|
||||
'/agentAdmin/list',
|
||||
'/agentAdmin/commission',
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user