20261030 #16

Merged
zzh merged 4 commits from test into master 2026-10-30 17:38:35 +08:00
40 changed files with 2857 additions and 272 deletions
Showing only changes of commit 072142d7bb - Show all commits

View File

@ -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
```

View File

@ -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),
})),
}
},
}

View File

@ -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,

View File

@ -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),

View File

@ -1519,6 +1519,8 @@ export interface PriceCheckRequest {
postalCode?: string
/** 选用的优惠券实例 Id */
couponInstanceId?: number
/** 代客查价客户(管理端/代理可选;影响会员价与券) */
customerId?: number
}
export interface MarketingPriceBreakdown {

View File

@ -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>

View File

@ -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(() => {

View File

@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -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': '邮箱',

View File

@ -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': '客户',

View File

@ -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: [] },
];

View File

@ -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,

View File

@ -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

View 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()
}

View 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注册后即可查价下单,运单派送完成后按约定结算返佣。`,
},
]

View File

@ -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 {

View File

@ -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}`;
}

View File

@ -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'

View File

@ -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
}

View File

@ -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 {

View File

@ -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">

View File

@ -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';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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"

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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 {

View File

@ -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>);

View File

@ -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',
];