front/src/components/agent-side-rail/index.vue
zzh 072142d7bb
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m30s
feat: 代理门户返佣预计态与客户绑定代理
运单列表/工作台/返佣四态展示,注册归因与客户表单改绑代理,侧栏与占位邮箱优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 11:07:07 +08:00

532 lines
12 KiB
Vue

<template>
<aside
class="agent-side-rail"
:class="{
'is-collapsed': collapsed,
'is-compact': compactLayout,
'is-animating': animating,
}"
:style="{
width: `${railWidth}px`,
'--asr-shell-width': `${AGENT_SIDE_RAIL_EXPANDED_WIDTH}px`,
}"
aria-label="渠道代理导航"
@transitionend="onRailTransitionEnd"
>
<div class="agent-side-rail__shell">
<a
class="agent-side-rail__brand"
href="/"
:title="t('brand.fullName')"
@click.prevent="goOfficialSite"
>
<img :src="navLogo" :alt="t('brand.name')" class="agent-side-rail__brand-logo" />
<span class="agent-side-rail__brand-name agent-side-rail__fade">{{ t('brand.name') }}</span>
</a>
<div class="agent-side-rail__scroll">
<section class="agent-side-rail__group is-first">
<a-tooltip
v-for="item in navItems"
:key="item.path"
:content="item.label"
position="right"
:disabled="!compactLayout"
>
<button
type="button"
class="agent-side-rail__item"
:class="{ active: isActive(item.path) }"
:aria-current="isActive(item.path) ? 'page' : undefined"
@click="onNavigate(item.path)"
>
<span class="agent-side-rail__icon">
<component :is="item.icon" />
</span>
<span class="agent-side-rail__text agent-side-rail__fade">{{ item.label }}</span>
</button>
</a-tooltip>
</section>
</div>
<div class="agent-side-rail__dock">
<div class="agent-side-rail__account">
<div class="agent-side-rail__user">
<RightTopUser
variant="sider"
compact
dropdown-position="tl"
:account-menus="[]"
show-language-menu
:show-recharge-cta="false"
/>
</div>
<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>
<button
type="button"
class="agent-side-rail__toggle"
:aria-label="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
:title="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
@click="toggleCollapsed"
>
<icon-double-right v-if="collapsed" class="agent-side-rail__toggle-icon" />
<icon-double-left v-else class="agent-side-rail__toggle-icon" />
</button>
</div>
</div>
</div>
</aside>
</template>
<script lang="ts" setup>
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'
import { AgentPortalApi, type AgentDto } from '@/api/agent'
import {
CUSTOMER_SIDE_RAIL_COLLAPSED_WIDTH,
CUSTOMER_SIDE_RAIL_EXPANDED_WIDTH,
} from '@/utils/customer-portal-nav'
import navLogo from '@/assets/logo2.png'
const AGENT_SIDE_RAIL_COLLAPSED_WIDTH = CUSTOMER_SIDE_RAIL_COLLAPSED_WIDTH
const AGENT_SIDE_RAIL_EXPANDED_WIDTH = CUSTOMER_SIDE_RAIL_EXPANDED_WIDTH
const AGENT_SIDE_RAIL_STORAGE_KEY = 'oms.agentSideRail.collapsed'
const RAIL_ANIM_MS = 300
type AgentNavItem = {
path: string
label: string
icon: Component
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const collapsed = ref(readCollapsed())
const compactLayout = ref(collapsed.value)
const animating = ref(false)
let compactTimer = 0
const profile = ref<AgentDto | null>(null)
const railWidth = computed(() =>
collapsed.value ? AGENT_SIDE_RAIL_COLLAPSED_WIDTH : AGENT_SIDE_RAIL_EXPANDED_WIDTH,
)
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: '名下客户',
icon: IconUserGroup,
},
{
path: '/agent/orders',
label: '运单列表',
icon: IconList,
},
{
path: '/agent/commission',
label: '返佣明细',
icon: IconGift,
},
])
const agentDisplayName = computed(
() => 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'
} catch {
return false
}
}
function writeCollapsed(value: boolean) {
try {
localStorage.setItem(AGENT_SIDE_RAIL_STORAGE_KEY, value ? '1' : '0')
} catch {
/* ignore */
}
}
function isActive(path: string) {
const current = route.path
return current === path || current.startsWith(`${path}/`)
}
function onNavigate(path: string) {
if (route.path === path) return
void router.push(path)
}
function goOfficialSite() {
window.location.href = '/'
}
function toggleCollapsed() {
animating.value = true
collapsed.value = !collapsed.value
writeCollapsed(collapsed.value)
if (compactTimer) window.clearTimeout(compactTimer)
if (collapsed.value) {
compactTimer = window.setTimeout(() => {
compactLayout.value = true
animating.value = false
}, RAIL_ANIM_MS)
} else {
compactLayout.value = false
compactTimer = window.setTimeout(() => {
animating.value = false
}, RAIL_ANIM_MS)
}
}
function onRailTransitionEnd(e: TransitionEvent) {
if (e.propertyName !== 'width') return
if (!collapsed.value) compactLayout.value = false
}
onMounted(async () => {
try {
profile.value = await AgentPortalApi.profile()
} catch {
profile.value = null
}
})
onUnmounted(() => {
if (compactTimer) window.clearTimeout(compactTimer)
})
</script>
<style lang="less" scoped>
.agent-side-rail {
--asr-pad-x: 14px;
--asr-item-h: 44px;
--asr-radius: 10px;
--asr-panel-radius: 4px;
--asr-panel-inset: 16px;
--asr-shell-width: 200px;
--asr-ease: cubic-bezier(0.32, 0.72, 0, 1);
--asr-dur: 0.3s;
flex-shrink: 0;
align-self: stretch;
box-sizing: border-box;
height: calc(100% - var(--asr-panel-inset) * 2);
margin: var(--asr-panel-inset) 0 var(--asr-panel-inset) var(--asr-panel-inset);
min-height: 0;
overflow: hidden;
background: var(--app-card-bg, #fff);
border-radius: var(--asr-panel-radius);
transition: width var(--asr-dur) var(--asr-ease);
will-change: width;
z-index: 10;
@media (prefers-reduced-motion: reduce) {
--asr-dur: 0.01ms;
}
&__shell {
width: var(--asr-shell-width);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
&__fade {
opacity: 1;
transform: translateX(0);
transition:
opacity 0.18s ease,
transform 0.18s var(--asr-ease);
}
&.is-collapsed &__fade {
opacity: 0;
transform: translateX(-6px);
pointer-events: none;
transition-duration: 0.12s;
}
&:not(.is-collapsed) &__fade {
transition-delay: 0.1s;
}
&__brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-shrink: 0;
min-height: 56px;
padding: 10px 16px;
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__brand-logo {
display: block;
width: 32px;
height: 32px;
flex-shrink: 0;
object-fit: contain;
}
&__brand-name {
min-width: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__scroll {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 14px var(--asr-pad-x) 12px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
}
&__group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: var(--asr-item-h);
padding: 0 12px 0 14px;
border: none;
border-radius: var(--asr-radius);
background: transparent;
color: var(--color-text-2);
cursor: pointer;
text-align: left;
transition:
background-color 0.14s ease,
color 0.14s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 0;
border-radius: 0 3px 3px 0;
background: var(--app-primary-color, rgb(var(--primary-6)));
transform: translateY(-50%);
opacity: 0;
transition:
height 0.14s ease,
opacity 0.14s ease;
}
&:hover {
color: var(--color-text-1);
background: rgba(var(--primary-6), 0.06);
}
&.active {
color: var(--app-primary-color, rgb(var(--primary-6)));
background: rgba(var(--primary-6), 0.1);
font-weight: 600;
&::before {
height: 18px;
opacity: 1;
}
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
font-size: 18px;
}
&__text {
min-width: 0;
font-size: 14px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__dock {
flex-shrink: 0;
padding: 10px var(--asr-pad-x) 12px;
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__account {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 4px 4px 6px;
border-radius: 8px;
}
&__user {
flex-shrink: 0;
}
&__account-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__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;
}
&__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;
}
&__toggle {
flex-shrink: 0;
width: 28px;
height: 28px;
margin-left: auto;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--app-primary-color, rgb(var(--primary-6)));
background: rgba(var(--primary-6), 0.08);
}
}
&__toggle-icon {
font-size: 14px;
}
&.is-compact &__item {
justify-content: center;
padding: 0;
gap: 0;
}
&.is-compact &__dock {
padding: 8px 0 10px;
}
&.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>