feat(leads): 实现线索管理页面功能

- 添加线索列表展示、筛选和详情查看功能
- 修改API请求配置,将baseURL设为本地开发地址
- 更新接口类型定义,增加线索相关类型
- 添加加载状态、错误处理和自动刷新逻辑
- 移除系统设置菜单项,专注核心功能
This commit is contained in:
2026-02-05 11:31:56 +08:00
parent 6a0d8a3ac1
commit ca8869db66
3 changed files with 216 additions and 10 deletions

View File

@@ -5,17 +5,45 @@ type AdminUserStatusParams = {
max_limit?: number
page?: number
size?: number
mode?: 'SSE' | 'BLOCK'
mode?: 'SSE' | 'BLOCK'
}
export type LeadItem = {
hook_user_id: string
id: number
avatar_url: string
sex: number
follow_up_user_id: string
wechat_add_time: string
auto_status: number
is_synced: number
created_at: string
nickname: string
phone: string
follow_up_name: string
source_type: number
status: number
wechat_status: number
extended_info: Record<string, unknown>
updated_at: string
last_active_at: string | null
}
export type LeadListResponse = {
page: number
size: number
total: number
items: LeadItem[]
}
export const getAdminUserStatus = (params: AdminUserStatusParams = {}) => {
return http.get('/api/v1/admin/user/status', {
return http.get<LeadListResponse, LeadListResponse>('/api/v1/admin/user/status', {
params: {
interval: 2,
max_limit: 100,
page: 1,
size: 30,
mode: 'SSE',
mode: 'BLOCK',
...params,
},
headers: {

View File

@@ -8,7 +8,7 @@ import type {
} from 'axios'
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
baseURL: 'http://127.0.0.1:8000',
timeout: 10000,
})

View File

@@ -10,14 +10,138 @@
<DashboardView v-if="currentView === 'dashboard'" :funnel-data="funnelData" :traffic-data="trafficData" :event-logs="eventLogs" />
<StrategyView v-else-if="currentView === 'strategy'" :sales-stages="salesStages" v-model:current-stage-id="currentStageId" :current-config="currentConfig" @saveConfig="saveConfig" />
<MonitorView v-else-if="currentView === 'monitor'" :active-users="activeUsers" :chat-history="chatHistory" :get-stage-badge="getStageBadge" @toggleManual="toggleManual" />
<div v-else-if="currentView === 'leads'" class="h-full glass-panel rounded-2xl p-6">
<div class="text-lg font-bold text-white mb-2">线索管理</div>
<div class="text-xs text-slate-400">功能建设中</div>
<div v-else-if="currentView === 'leads'" class="h-full glass-panel rounded-2xl p-6 flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-between">
<div>
<div class="text-lg font-bold text-white mb-1">线索管理</div>
<div class="text-xs text-slate-400"> {{ leadsMeta.total }} · 当前 {{ filteredLeads.length }} </div>
</div>
<div class="flex items-center gap-2">
<div class="join">
<button class="btn btn-xs join-item border-slate-700" :class="leadsFilter === 'all' ? 'btn-primary' : 'btn-neutral text-slate-300'" @click="leadsFilter = 'all'">全部</button>
<button class="btn btn-xs join-item border-slate-700" :class="leadsFilter === 'external' ? 'btn-primary' : 'btn-neutral text-slate-300'" @click="leadsFilter = 'external'">外部联系人</button>
<button class="btn btn-xs join-item border-slate-700" :class="leadsFilter === 'internal' ? 'btn-primary' : 'btn-neutral text-slate-300'" @click="leadsFilter = 'internal'">内部联系人</button>
</div>
<button class="btn btn-xs btn-neutral border-slate-700 text-slate-300" @click="fetchLeads">刷新</button>
</div>
</div>
<div v-if="leadsLoading" class="flex-1 flex items-center justify-center text-slate-400 text-sm">
<span class="loading loading-spinner loading-sm mr-2"></span>
加载中
</div>
<div v-else-if="leadsError" class="flex-1 flex items-center justify-center text-slate-400 text-sm">
{{ leadsError }}
</div>
<div v-else class="flex-1 overflow-hidden flex gap-4">
<div class="flex-1 overflow-auto pr-2">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
<button
v-for="item in filteredLeads"
:key="item.id"
class="text-left p-4 rounded-2xl border border-slate-800 bg-slate-900/60 hover:bg-slate-800/60 transition-colors"
:class="selectedLead?.id === item.id ? 'ring-2 ring-primary/60 border-primary/60' : ''"
@click="selectedLead = item"
>
<div class="flex items-center gap-3">
<div class="avatar">
<div class="w-10 rounded-full bg-slate-800 overflow-hidden flex items-center justify-center">
<img v-if="normalizeAvatarUrl(item.avatar_url)" :src="normalizeAvatarUrl(item.avatar_url)" :alt="item.nickname" />
<span v-else class="text-xs text-slate-300">{{ (item.nickname || 'U').slice(0, 1) }}</span>
</div>
</div>
<div class="min-w-0">
<div class="font-medium text-slate-200 truncate">{{ item.nickname || '未命名' }}</div>
<div class="text-[10px] text-slate-500 font-mono truncate">{{ item.hook_user_id }}</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-1 text-[10px]">
<span class="badge badge-ghost badge-xs">{{ sourceTypeLabel(item.source_type) }}</span>
<span class="badge badge-ghost badge-xs">status: {{ item.status }}</span>
<span class="badge badge-ghost badge-xs">wx: {{ item.wechat_status }}</span>
</div>
<div class="mt-3 text-xs text-slate-400 space-y-1">
<div class="flex items-center justify-between">
<span>跟进人</span>
<span class="text-slate-300">{{ item.follow_up_name || '-' }}</span>
</div>
<div class="flex items-center justify-between">
<span>手机号</span>
<span class="text-slate-300 font-mono">{{ item.phone || '-' }}</span>
</div>
<div class="flex items-center justify-between">
<span>添加时间</span>
<span class="text-slate-300 font-mono">{{ formatTime(item.wechat_add_time) }}</span>
</div>
</div>
</button>
</div>
<div v-if="filteredLeads.length === 0" class="text-center text-slate-500 text-xs py-6">暂无数据</div>
</div>
<div class="w-80 shrink-0 glass-panel rounded-2xl p-4 overflow-auto">
<div v-if="!selectedLead" class="h-full flex items-center justify-center text-slate-500 text-sm">
请选择一个客户查看详情
</div>
<div v-else class="space-y-4">
<div class="flex items-center gap-3">
<div class="avatar">
<div class="w-12 rounded-full bg-slate-800 overflow-hidden flex items-center justify-center">
<img v-if="normalizeAvatarUrl(selectedLead.avatar_url)" :src="normalizeAvatarUrl(selectedLead.avatar_url)" :alt="selectedLead.nickname" />
<span v-else class="text-xs text-slate-300">{{ (selectedLead.nickname || 'U').slice(0, 1) }}</span>
</div>
</div>
<div>
<div class="text-lg font-bold text-white">{{ selectedLead.nickname || '未命名' }}</div>
<div class="text-[10px] text-slate-500 font-mono">{{ selectedLead.hook_user_id }}</div>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px]">
<span class="badge badge-ghost badge-xs">{{ sourceTypeLabel(selectedLead.source_type) }}</span>
<span class="badge badge-ghost badge-xs">status: {{ selectedLead.status }}</span>
<span class="badge badge-ghost badge-xs">wx: {{ selectedLead.wechat_status }}</span>
<span class="badge badge-ghost badge-xs">auto: {{ selectedLead.auto_status }}</span>
<span class="badge badge-ghost badge-xs">sync: {{ selectedLead.is_synced }}</span>
</div>
<div class="space-y-2 text-xs text-slate-400">
<div class="flex items-center justify-between">
<span>手机号</span>
<span class="text-slate-300 font-mono">{{ selectedLead.phone || '-' }}</span>
</div>
<div class="flex items-center justify-between">
<span>跟进人</span>
<span class="text-slate-300">{{ selectedLead.follow_up_name || '-' }}</span>
</div>
<div class="flex items-center justify-between">
<span>跟进人ID</span>
<span class="text-slate-300 font-mono">{{ selectedLead.follow_up_user_id || '-' }}</span>
</div>
<div class="flex items-center justify-between">
<span>添加时间</span>
<span class="text-slate-300 font-mono">{{ formatTime(selectedLead.wechat_add_time) }}</span>
</div>
<div class="flex items-center justify-between">
<span>最后活跃</span>
<span class="text-slate-300 font-mono">{{ formatTime(selectedLead.last_active_at) }}</span>
</div>
<div class="flex items-center justify-between">
<span>创建时间</span>
<span class="text-slate-300 font-mono">{{ formatTime(selectedLead.created_at) }}</span>
</div>
<div class="flex items-center justify-between">
<span>更新时间</span>
<span class="text-slate-300 font-mono">{{ formatTime(selectedLead.updated_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="currentView === 'system'" class="h-full glass-panel rounded-2xl p-6">
<!-- <div v-else-if="currentView === 'system'" class="h-full glass-panel rounded-2xl p-6">
<div class="text-lg font-bold text-white mb-2">系统设置</div>
<div class="text-xs text-slate-400">功能建设中</div>
</div>
</div> -->
</transition>
</main>
</div>
@@ -32,6 +156,8 @@ import SidebarNav from './components/SidebarNav.vue';
import DashboardView from './components/DashboardView.vue';
import StrategyView from './components/StrategyView.vue';
import MonitorView from './components/MonitorView.vue';
import { getAdminUserStatus } from '@/api';
import type { LeadListResponse, LeadItem } from '@/api';
const router = useRouter();
const route = useRoute();
@@ -46,7 +172,7 @@ const navItems = [
{ id: 'monitor', label: '会话监控', icon: 'ph-chats-teardrop' },
{ id: 'strategy', label: 'SOP配置', icon: 'ph-sliders-horizontal' },
{ id: 'leads', label: '线索管理', icon: 'ph-users' },
{ id: 'system', label: '系统设置', icon: 'ph-gear' }
// { id: 'system', label: '系统设置', icon: 'ph-gear' }
];
const handleSelect = (id: string) => {
@@ -55,6 +181,52 @@ const handleSelect = (id: string) => {
}
};
const leadData = ref<LeadListResponse | null>(null);
const leadsLoading = ref(false);
const leadsError = ref('');
const leadsItems = computed<LeadItem[]>(() => leadData.value?.items ?? []);
const leadsMeta = computed(() => ({
page: leadData.value?.page ?? 1,
size: leadData.value?.size ?? 30,
total: leadData.value?.total ?? 0,
}));
const leadsFilter = ref<'all' | 'external' | 'internal'>('all');
const selectedLead = ref<LeadItem | null>(null);
const filteredLeads = computed<LeadItem[]>(() => {
if (leadsFilter.value === 'external') {
return leadsItems.value.filter((item) => item.source_type === 1);
}
if (leadsFilter.value === 'internal') {
return leadsItems.value.filter((item) => item.source_type === 0);
}
return leadsItems.value;
});
const normalizeAvatarUrl = (url: string) => url.replace(/`/g, '').trim();
const sourceTypeLabel = (value: number) => (value === 1 ? '外部联系人' : '内部联系人');
const formatTime = (value: string | null) => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const fetchLeads = async () => {
if (leadsLoading.value) return;
leadsLoading.value = true;
leadsError.value = '';
try {
leadData.value = await getAdminUserStatus();
if (!selectedLead.value && leadData.value.items.length > 0) {
selectedLead.value = leadData.value.items[0] ?? null;
}
} catch (error) {
leadsError.value = error instanceof Error ? error.message : '请求失败';
} finally {
leadsLoading.value = false;
}
};
// 1. 销售阶段 (SOP)
const salesStages = [
{ id: 'connect', name: '1. 浅建联 (加微/破冰)', desc: '初步接触,建立信任,发送欢迎语' },
@@ -121,6 +293,12 @@ watch(currentStageId, (newId) => {
}
}, { immediate: true });
watch(currentView, (value) => {
if (value === 'leads' && !leadData.value) {
fetchLeads();
}
}, { immediate: true });
const currentConfig = computed((): Config => {
return configs.value[currentStageId.value]!;
});