feat(M08-D): 补加盟申请与跟进运营
This commit is contained in:
+10
-1
@@ -12,6 +12,7 @@
|
||||
<nav class="nav-list" aria-label="后台模块">
|
||||
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
|
||||
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
|
||||
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: activeModule === 'overview' }"
|
||||
@@ -124,6 +125,11 @@
|
||||
:session="session"
|
||||
/>
|
||||
|
||||
<FranchisePanel
|
||||
v-else-if="activeModule === 'franchise'"
|
||||
:session="session"
|
||||
/>
|
||||
|
||||
<StoresRoomsPanel
|
||||
v-else-if="activeModule === 'stores'"
|
||||
:session="session"
|
||||
@@ -319,6 +325,7 @@ import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Images,
|
||||
Handshake,
|
||||
PanelsTopLeft,
|
||||
RadioTower,
|
||||
RotateCcw,
|
||||
@@ -344,6 +351,7 @@ import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
||||
import DevicesPanel from './components/DevicesPanel.vue';
|
||||
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
||||
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
||||
import FranchisePanel from './components/FranchisePanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
@@ -380,7 +388,7 @@ import { money } from './format';
|
||||
|
||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||
const tokenDraft = ref(savedToken.value);
|
||||
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||
const activeTab = ref('tasks');
|
||||
const lastError = ref('');
|
||||
const lastMessage = ref('');
|
||||
@@ -393,6 +401,7 @@ const activeModuleMeta = computed(() => ({
|
||||
overview: { stage: 'M08-D', title: '平台运营总览' },
|
||||
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
||||
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
||||
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
|
||||
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
CleaningTaskMember,
|
||||
DecorationComponent,
|
||||
DecorationVersion,
|
||||
FranchiseApplication,
|
||||
FranchiseApplicationDetail,
|
||||
FranchiseFollowUpType,
|
||||
FranchiseStatus,
|
||||
DeviceTopology,
|
||||
DeviceType,
|
||||
BusinessStatistics,
|
||||
@@ -499,6 +503,35 @@ export function updateAdvertisement(
|
||||
);
|
||||
}
|
||||
|
||||
export function listFranchiseApplications(session: ApiSession, input: {
|
||||
page: number; pageSize: number; status?: FranchiseStatus; assigneeUserId?: string; search?: string;
|
||||
}) {
|
||||
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||
if (input.status) params.set('status', input.status);
|
||||
if (input.assigneeUserId) params.set('assigneeUserId', input.assigneeUserId);
|
||||
if (input.search) params.set('search', input.search);
|
||||
return request<PageResult<FranchiseApplication>>(session, `/franchise-applications?${params}`);
|
||||
}
|
||||
|
||||
export function getFranchiseApplication(session: ApiSession, applicationId: string) {
|
||||
return request<FranchiseApplicationDetail>(session, `/franchise-applications/${encodeURIComponent(applicationId)}`);
|
||||
}
|
||||
|
||||
export function assignFranchiseApplication(session: ApiSession, applicationId: string, assigneeUserId: string | null) {
|
||||
return request<{ applicationId: string; assigneeUserId: string | null }>(session,
|
||||
`/franchise-applications/${encodeURIComponent(applicationId)}/assignee`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ assigneeUserId }) });
|
||||
}
|
||||
|
||||
export function addFranchiseFollowUp(session: ApiSession, applicationId: string, input: {
|
||||
followUpType: Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>;
|
||||
note: string; nextFollowUpAt: string | null; status?: FranchiseStatus;
|
||||
}) {
|
||||
return request<{ applicationId: string; followUpId: string; status: FranchiseStatus }>(session,
|
||||
`/franchise-applications/${encodeURIComponent(applicationId)}/follow-ups`,
|
||||
{ method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function createStaffUser(
|
||||
session: ApiSession,
|
||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<section class="franchise-page">
|
||||
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||
<section class="franchise-metrics"><span><small>新申请</small><strong>{{ count('NEW') }}</strong></span><span><small>已联系</small><strong>{{ count('CONTACTED') }}</strong></span><span><small>意向明确</small><strong>{{ count('QUALIFIED') }}</strong></span><span><small>本页待跟进</small><strong>{{ dueCount }}</strong></span></section>
|
||||
<section class="panel">
|
||||
<header class="panel-toolbar"><div><p class="section-kicker">M08-D · 加盟运营</p><h3>加盟申请与跟进队列</h3></div><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button></header>
|
||||
<div class="franchise-filters">
|
||||
<el-input v-model="filters.search" clearable placeholder="申请号、城市、联系人或电话" @keyup.enter="applyFilters" />
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||
<el-select v-model="filters.assigneeUserId" clearable filterable placeholder="全部负责人"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select>
|
||||
<el-button type="primary" @click="applyFilters">筛选</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="page.items" row-key="id" class="data-table">
|
||||
<el-table-column label="申请" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.applicationNo }}</strong><span>{{ shortDate(row.createdAt) }} · {{ row.source }}</span></div></template></el-table-column>
|
||||
<el-table-column label="联系人" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.contactName }} · {{ row.contactPhone }}</strong><span>{{ row.city }}</span></div></template></el-table-column>
|
||||
<el-table-column label="需求" min-width="220" show-overflow-tooltip prop="message" />
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="负责人" min-width="130"><template #default="{ row }">{{ row.assigneeName || '待分派' }}</template></el-table-column>
|
||||
<el-table-column label="下次跟进" min-width="150"><template #default="{ row }"><span :class="{ overdue: isOverdue(row.nextFollowUpAt) }">{{ row.nextFollowUpAt ? shortDate(row.nextFollowUpAt) : '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openDetail(row.id)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadData" />
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="drawer" title="加盟申请详情" size="min(680px, 96vw)">
|
||||
<div v-if="detail" class="franchise-detail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="申请号">{{ detail.application.applicationNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusTag(detail.application.status)">{{ statusLabel(detail.application.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="联系人">{{ detail.application.contactName }}</el-descriptions-item><el-descriptions-item label="联系电话"><a :href="`tel:${detail.application.contactPhone}`">{{ detail.application.contactPhone }}</a></el-descriptions-item><el-descriptions-item label="意向城市">{{ detail.application.city }}</el-descriptions-item><el-descriptions-item label="提交时间">{{ shortDate(detail.application.createdAt) }}</el-descriptions-item><el-descriptions-item label="留言" :span="2">{{ detail.application.message || '无' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="franchise-assignment"><strong>负责人</strong><el-select :model-value="detail.application.assigneeUserId || ''" clearable filterable placeholder="待分派" @change="assign"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select></section>
|
||||
<section class="follow-up-form"><h4>记录跟进</h4><el-form label-position="top"><el-form-item label="方式"><el-select v-model="followForm.followUpType"><el-option label="电话" value="CALL" /><el-option label="微信" value="WECHAT" /><el-option label="面谈" value="MEETING" /><el-option label="备注" value="NOTE" /></el-select></el-form-item><el-form-item label="更新状态"><el-select v-model="followForm.status" clearable><el-option v-for="item in allowedStatusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="下次跟进"><el-date-picker v-model="followForm.nextFollowUpAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item><el-form-item label="跟进内容"><el-input v-model="followForm.note" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></el-form><el-button type="primary" :loading="saving" @click="saveFollowUp">保存跟进</el-button></section>
|
||||
<section><h4>跟进历史</h4><el-timeline><el-timeline-item v-for="item in detail.followUps" :key="item.id" :timestamp="shortDate(item.createdAt)" placement="top"><div class="follow-up-card"><strong>{{ followTypeLabel(item.followUpType) }} · {{ item.actorName }}</strong><p>{{ item.note }}</p><small v-if="item.fromStatus !== item.toStatus">{{ item.fromStatus ? statusLabel(item.fromStatus) : '-' }} → {{ item.toStatus ? statusLabel(item.toStatus) : '-' }}</small></div></el-timeline-item></el-timeline><el-empty v-if="detail.followUps.length === 0" description="尚无跟进记录" :image-size="70" /></section>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Eye, RefreshCw } from '@lucide/vue';
|
||||
import { ApiError, addFranchiseFollowUp, assignFranchiseApplication, getFranchiseApplication, listFranchiseApplications, listStaffUsers, type ApiSession } from '../api';
|
||||
import { shortDate } from '../format';
|
||||
import type { FranchiseApplication, FranchiseApplicationDetail, FranchiseFollowUpType, FranchiseStatus, ManagedUser, PageResult } from '../types';
|
||||
|
||||
const props = defineProps<{ session: ApiSession }>(); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const drawer = ref(false); const staff = ref<ManagedUser[]>([]); const detail = ref<FranchiseApplicationDetail | null>(null);
|
||||
const page = reactive<PageResult<FranchiseApplication>>({ items: [], total: 0, page: 1, pageSize: 20 }); const filters = reactive({ search: '', status: '' as FranchiseStatus | '', assigneeUserId: '' }); const followForm = reactive({ followUpType: 'CALL' as Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>, status: '' as FranchiseStatus | '', nextFollowUpAt: '', note: '' });
|
||||
const statusOptions: Array<{ value: FranchiseStatus; label: string }> = [{ value: 'NEW', label: '新申请' }, { value: 'CONTACTED', label: '已联系' }, { value: 'QUALIFIED', label: '意向明确' }, { value: 'REJECTED', label: '已拒绝' }, { value: 'CONVERTED', label: '已签约待开通' }];
|
||||
const transitions: Record<FranchiseStatus, FranchiseStatus[]> = { NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'], QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: [] };
|
||||
const allowedStatusOptions = computed(() => detail.value ? statusOptions.filter((item) => transitions[detail.value!.application.status].includes(item.value)) : []); const dueCount = computed(() => page.items.filter((item) => isOverdue(item.nextFollowUpAt)).length);
|
||||
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
|
||||
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [result, users] = await Promise.all([listFranchiseApplications(props.session, { page: page.page, pageSize: page.pageSize, status: filters.status || undefined, assigneeUserId: filters.assigneeUserId || undefined, search: filters.search.trim() || undefined }), staff.value.length ? Promise.resolve({ items: staff.value }) : listStaffUsers(props.session, { page: 1, pageSize: 100, status: 'ACTIVE' })]); Object.assign(page, result); staff.value = users.items; } catch (error) { captureError(error); } finally { loading.value = false; } }
|
||||
function applyFilters() { page.page = 1; void loadData(); } async function openDetail(id: string) { drawer.value = true; detail.value = null; try { detail.value = await getFranchiseApplication(props.session, id); Object.assign(followForm, { followUpType: 'CALL', status: '', nextFollowUpAt: '', note: '' }); } catch (error) { captureError(error); } }
|
||||
async function assign(value: string) { if (!detail.value) return; saving.value = true; try { await assignFranchiseApplication(props.session, detail.value.application.id, value || null); ElMessage.success('负责人已更新'); await refreshDetail(); await loadData(); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||
async function saveFollowUp() { if (!detail.value || !followForm.note.trim()) return ElMessage.warning('请填写跟进内容'); saving.value = true; try { await addFranchiseFollowUp(props.session, detail.value.application.id, { followUpType: followForm.followUpType, note: followForm.note.trim(), nextFollowUpAt: followForm.nextFollowUpAt || null, status: followForm.status || undefined }); ElMessage.success('跟进记录已保存'); await refreshDetail(); await loadData(); Object.assign(followForm, { status: '', note: '' }); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||
async function refreshDetail() { if (detail.value) detail.value = await getFranchiseApplication(props.session, detail.value.application.id); }
|
||||
function count(status: FranchiseStatus) { return page.items.filter((item) => item.status === status).length; } function isOverdue(value: string | null) { return Boolean(value && new Date(value).getTime() < Date.now()); } function statusLabel(value: FranchiseStatus) { return statusOptions.find((item) => item.value === value)?.label || value; } function statusTag(value: FranchiseStatus) { return value === 'QUALIFIED' || value === 'CONVERTED' ? 'success' : value === 'REJECTED' ? 'info' : value === 'NEW' ? 'warning' : 'primary'; } function followTypeLabel(value: FranchiseFollowUpType) { return ({ CALL: '电话', WECHAT: '微信', MEETING: '面谈', NOTE: '备注', STATUS: '状态变化', ASSIGNMENT: '负责人变更' } as Record<FranchiseFollowUpType, string>)[value]; }
|
||||
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
|
||||
</script>
|
||||
@@ -233,6 +233,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
.franchise-page,
|
||||
.franchise-detail {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.franchise-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.franchise-metrics > span {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(31, 49, 72, .06);
|
||||
}
|
||||
|
||||
.franchise-metrics small {
|
||||
color: #66758a;
|
||||
}
|
||||
|
||||
.franchise-metrics strong {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.franchise-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 150px 180px auto;
|
||||
gap: 10px;
|
||||
padding: 0 18px 16px;
|
||||
}
|
||||
|
||||
.franchise-assignment {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.follow-up-form {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.follow-up-form h4,
|
||||
.franchise-detail h4,
|
||||
.follow-up-card p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.follow-up-form .el-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.follow-up-form .el-form-item:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.follow-up-card {
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.follow-up-card p {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.follow-up-card small,
|
||||
.overdue {
|
||||
color: #c73535;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.franchise-metrics,
|
||||
.franchise-filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.franchise-metrics,
|
||||
.franchise-filters,
|
||||
.follow-up-form .el-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.follow-up-form .el-form-item:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -518,6 +518,27 @@ export interface Advertisement extends AdvertisementInput {
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
|
||||
export type FranchiseFollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
|
||||
|
||||
export interface FranchiseApplication {
|
||||
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
|
||||
contactPhone: string; message: string; source: string; status: FranchiseStatus;
|
||||
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
|
||||
nextFollowUpAt: string | null; closedAt: string | null; createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FranchiseFollowUp {
|
||||
id: string; actorUserId: string; actorName: string; followUpType: FranchiseFollowUpType;
|
||||
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
|
||||
note: string; nextFollowUpAt: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
export interface FranchiseApplicationDetail {
|
||||
application: FranchiseApplication;
|
||||
followUps: FranchiseFollowUp[];
|
||||
}
|
||||
|
||||
export interface CleaningTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
|
||||
Reference in New Issue
Block a user