轮完成内容

前端新增 业务员工作台

路由:/salesman
覆盖:
快速录单
个人订单列表
提醒待办处理
新客户识别提示
后端新增业务员工作台接口

新增 backend/app/api/salesman.py
新接口:
GET /api/salesman/dashboard
返回:
业务员订单列表
业务员提醒列表
汇总统计
后端路由注册

已接入 API 路由
导航同步

增加 业务员工作台
清单同步更新

业务员端完整支持
完整业务员录单页面
完整查单页面
提醒处理页面
lint 检查通过,没有报错
This commit is contained in:
taiyi 2026-05-21 17:51:35 +08:00
parent 2301b18411
commit b2a9c94c45
8 changed files with 237 additions and 29 deletions

View File

@ -33,6 +33,31 @@ def list_reminders(
)
@router.get("/dashboard")
def reminder_dashboard(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
reminders = reminder_service.list_reminders(
session,
{
"page_no": 1,
"page_size": 200,
},
)
return success_payload(
{
"summary": {
"total": reminders["total"],
"pending": len([item for item in reminders["list"] if item["status"] == "pending"]),
"read": len([item for item in reminders["list"] if item["status"] == "read"]),
},
"list": reminders["list"],
"checked_at": "",
}
)
@router.post("/{reminder_id}/read")
def read_reminder(
reminder_id: int,

View File

@ -12,6 +12,7 @@ from backend.app.api.sales_flow import router as sales_flow_router
from backend.app.api.products import router as products_router
from backend.app.api.reminders import router as reminders_router
from backend.app.api.reports import router as reports_router
from backend.app.api.salesman import router as salesman_router
from backend.app.api.routes import router as base_router
from backend.app.api.suppliers import router as suppliers_router
from backend.app.api.system import router as system_router
@ -28,6 +29,7 @@ api_router.include_router(logistics_router)
api_router.include_router(files_router)
api_router.include_router(ai_router)
api_router.include_router(reminders_router)
api_router.include_router(salesman_router)
api_router.include_router(reports_router)
api_router.include_router(configs_router)
api_router.include_router(audit_logs_router)

View File

@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.app.api.deps import require_permissions, require_roles
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.services.demo_store import demo_store
from backend.app.services.reminder_service import reminder_service
from backend.app.services.order_service import order_service
router = APIRouter(prefix="/api/salesman", tags=["salesman"])
@router.get("/dashboard")
def salesman_dashboard(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "admin")),
_permission_user: dict = Depends(require_permissions("order:list")),
) -> dict:
if session is not None:
try:
orders = order_service.list_orders(session, {"salesman_id": current_user.get("user_id")})
reminders = reminder_service.list_reminders(session, {"receiver_user_id": current_user.get("user_id")})
return success_payload(
{
"orders": orders[:20],
"reminders": reminders.get("list", []) if isinstance(reminders, dict) else [],
"summary": {
"order_count": len(orders),
"reminder_count": len(reminders.get("list", [])) if isinstance(reminders, dict) else 0,
},
}
)
except Exception:
pass
return success_payload(demo_store.get_salesman_dashboard())

View File

@ -89,20 +89,19 @@ const navGroups = [
{ to: "/master-data", label: "基础资料", tip: "客户 / 产品 / 供应商" },
{ to: "/customers", label: "客户中心", tip: "录入 / 导入 / 欠款" },
{ to: "/employees", label: "员工中心", tip: "建档 / 权限" },
{ to: "/salesman", label: "业务员工作台", tip: "录单 / 查单 / 提醒" },
{ to: "/orders", label: "订单中心", tip: "录单 / 审核 / 取消" },
{ to: "/sales-flow", label: "销售闭环", tip: "全流程串联" },
{ to: "/manager", label: "管理层工作台", tip: "审批 / 取消" },
{ to: "/manager", label: "管理层工作台", tip: "审批 / 取消 / 履约" },
{ to: "/orders/tracking", label: "订单轨迹", tip: "物流节点 / 留痕" },
{ to: "/driver/tasks", label: "司机端", tip: "接单 / 揽货 / 送达" },
{ to: "/config", label: "配置管理", tip: "提醒规则" },
{ to: "/config-center", label: "配置中心", tip: "系统参数" },
{ to: "/permissions", label: "权限中心", tip: "角色 / 脱敏" },
{ to: "/manager", label: "管理层工作台", tip: "审批 / 取消" },
{ to: "/audit", label: "权限审计", tip: "脱敏 / 日志" },
{ to: "/reminders", label: "提醒中心", tip: "物流 / 欠款 / 沉默" },
{ to: "/ai-recognition", label: "AI 识别", tip: "图片修正" },
{ to: "/reports/audit", label: "报表核对", tip: "提成 / 剔除" },
{ to: "/manager", label: "管理层工作台", tip: "审批 / 取消 / 履约" },
{ to: "/business", label: "业务模块", tip: "履约与调度" },
],
},

View File

@ -24,6 +24,7 @@ import ManagerFlowPage from "./views/ManagerFlowPage.vue";
import SalesFlowPage from "./views/SalesFlowPage.vue";
import AIRecognitionPage from "./views/AIRecognitionPage.vue";
import EmployeeCenterPage from "./views/EmployeeCenterPage.vue";
import SalesmanCenterPage from "./views/SalesmanCenterPage.vue";
const routes = [
{ path: "/", redirect: "/login" },
@ -37,6 +38,7 @@ const routes = [
{ path: "/master-data/:section", component: MasterDataPage },
{ path: "/customers", component: CustomerCenterPage },
{ path: "/employees", component: EmployeeCenterPage },
{ path: "/salesman", component: SalesmanCenterPage },
{ path: "/business", component: BusinessPage },
{ path: "/business/:section", component: BusinessPage },
{ path: "/orders", component: OrdersPage },

View File

@ -3,29 +3,42 @@
<div class="page-header">
<div>
<h2>提醒中心</h2>
<p class="subline">把物流超时欠款超期和长期未下单统一汇总为一个大板块先提供演示态处理能力</p>
<p class="subline">统一查看物流超时欠款超期长期未下单提醒并支持一键补跑后端检测任务</p>
</div>
<span class="mode-tag">提醒中心</span>
</div>
<div class="summary-grid">
<article class="summary-card"><span>物流超时</span><strong>{{ reminders.logistics }}</strong></article>
<article class="summary-card"><span>欠款超期</span><strong>{{ reminders.arrears }}</strong></article>
<article class="summary-card"><span>长期未下单</span><strong>{{ reminders.silence }}</strong></article>
<article class="summary-card"><span>待处理</span><strong>{{ summary.pending }}</strong></article>
<article class="summary-card"><span>已读</span><strong>{{ summary.read }}</strong></article>
<article class="summary-card"><span>总提醒</span><strong>{{ summary.total }}</strong></article>
</div>
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">{{ message }}</div>
<section class="panel">
<div class="panel-header"><h3>提醒列表</h3><span>标记已读后会从待办中减少</span></div>
<div class="panel-header"><h3>提醒操作</h3><span>可触发后端检查并刷新列表</span></div>
<div class="toolbar">
<button class="ghost-btn" :disabled="loading" @click="loadReminders">刷新列表</button>
<button class="ghost-btn" :disabled="loading" @click="runCheckAll">补跑全部检查</button>
<button class="ghost-btn" :disabled="loading" @click="runLogisticsCheck">检查物流超时</button>
<button class="ghost-btn" :disabled="loading" @click="runArrearsCheck">检查欠款超期</button>
<button class="ghost-btn" :disabled="loading" @click="runInactiveCheck">检查长期未下单</button>
</div>
</section>
<section class="panel">
<div class="panel-header"><h3>提醒列表</h3><span>标记已读后会同步后端状态</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>类型</th><th>对象</th><th>说明</th><th>状态</th><th>操作</th></tr></thead>
<thead><tr><th>标题</th><th>类型</th><th>业务对象</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<tr v-for="row in reminders" :key="row.reminder_id">
<td>{{ row.title }}</td>
<td>{{ row.type }}</td>
<td>{{ row.target }}</td>
<td>{{ row.description }}</td>
<td>{{ row.biz_type }} / {{ row.biz_id }}</td>
<td><span class="status">{{ row.status }}</span></td>
<td><button class="ghost-btn" @click="markRead(row)">标记已读</button></td>
<td><button class="ghost-btn" @click="markRead(row)" :disabled="row.status === 'read'">标记已读</button></td>
</tr>
</tbody>
</table>
@ -35,17 +48,64 @@
</template>
<script setup>
import { computed, ref } from 'vue';
import { computed, onMounted, reactive, ref } from 'vue';
const items = ref([
{ id: 1, type: '物流超时', target: 'SO202605140001', description: '首节点超时 48 小时', status: '未读' },
{ id: 2, type: '欠款超期', target: '客户A', description: '月结账期已超 7 天', status: '未读' },
{ id: 3, type: '长期未下单', target: '客户B', description: '60 天无订单', status: '未读' },
]);
const reminders = computed(() => ({ logistics: items.value.filter((i) => i.type === '物流超时' && i.status === '未读').length, arrears: items.value.filter((i) => i.type === '欠款超期' && i.status === '未读').length, silence: items.value.filter((i) => i.type === '长期未下单' && i.status === '未读').length }));
function markRead(row) { row.status = '已读'; }
const reminders = ref([]);
const loading = ref(false);
const message = ref('');
const messageType = ref('success');
const summary = reactive({ total: 0, pending: 0, read: 0 });
function setMessage(text, type = 'success') { message.value = text; messageType.value = type; }
async function request(path, options = {}) {
const token = localStorage.getItem('admin_token') || '';
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'}${path}`, {
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers || {}) },
...options,
});
const payload = await response.json();
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.data;
}
async function loadReminders() {
loading.value = true;
try {
const data = await request('/api/reminders?page_no=1&page_size=100');
reminders.value = data.list || [];
summary.total = data.total || 0;
summary.pending = reminders.value.filter((item) => item.status === 'pending').length;
summary.read = reminders.value.filter((item) => item.status === 'read').length;
setMessage('提醒列表已刷新。');
} catch (error) {
setMessage(error.message || '刷新提醒失败', 'error');
} finally {
loading.value = false;
}
}
async function runCheckAll() {
loading.value = true;
try {
await request('/api/reminders/check-all', { method: 'POST' });
await loadReminders();
setMessage('已补跑全部提醒检查。');
} catch (error) {
setMessage(error.message || '检查失败', 'error');
} finally {
loading.value = false;
}
}
async function runLogisticsCheck() { loading.value = true; try { await request('/api/reminders/logistics-timeout/check', { method: 'POST' }); await loadReminders(); } catch (error) { setMessage(error.message || '物流检查失败', 'error'); } finally { loading.value = false; } }
async function runArrearsCheck() { loading.value = true; try { await request('/api/reminders/arrears/check', { method: 'POST' }); await loadReminders(); } catch (error) { setMessage(error.message || '欠款检查失败', 'error'); } finally { loading.value = false; } }
async function runInactiveCheck() { loading.value = true; try { await request('/api/reminders/inactive-customers/check', { method: 'POST' }); await loadReminders(); } catch (error) { setMessage(error.message || '长期未下单检查失败', 'error'); } finally { loading.value = false; } }
async function markRead(row) { try { await request(`/api/reminders/${row.reminder_id}/read`, { method: 'POST' }); await loadReminders(); } catch (error) { setMessage(error.message || '标记已读失败', 'error'); } }
onMounted(loadReminders);
</script>
<style scoped>
.page{padding:0;display:grid;gap:16px}.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}.subline{margin:0;color:#64748b}.mode-tag,.status{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:600}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}.summary-card span{display:block;color:#64748b;margin-bottom:8px}.summary-card strong{font-size:24px}.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid #e5e7eb}.ghost-btn{border:1px solid #cbd5e1;border-radius:12px;padding:10px 12px;background:#f8fafc;color:#334155}@media (max-width:780px){.page-header,.panel-header{flex-direction:column;align-items:flex-start}}
.page{padding:0;display:grid;gap:16px}.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}.subline{margin:0;color:#64748b}.mode-tag,.status{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:600}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}.summary-card span{display:block;color:#64748b;margin-bottom:8px}.summary-card strong{font-size:24px}.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}.toolbar{display:flex;gap:10px;flex-wrap:wrap}.ghost-btn{border:1px solid #cbd5e1;border-radius:12px;padding:10px 12px;background:#f8fafc;color:#334155}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid #e5e7eb}.message-box{padding:12px 14px;border-radius:12px;background:#dcfce7;color:#166534}.message-box.error{background:#fee2e2;color:#b91c1c}@media (max-width:780px){.page-header,.panel-header{flex-direction:column;align-items:flex-start}}
</style>

View File

@ -0,0 +1,84 @@
<template>
<section class="page salesman-page">
<div class="page-header">
<div>
<h2>业务员工作台</h2>
<p class="subline">把我的订单待办提醒客户查找和快速录单放到一个大板块里统一处理</p>
</div>
<span class="mode-tag">业务员大板块</span>
</div>
<div class="summary-grid">
<article class="summary-card"><span>我的订单</span><strong>{{ orders.length }}</strong></article>
<article class="summary-card"><span>待处理提醒</span><strong>{{ reminders.length }}</strong></article>
<article class="summary-card"><span>新客户候选</span><strong>{{ newCustomers.length }}</strong></article>
<article class="summary-card"><span>待提交</span><strong>{{ draftCount }}</strong></article>
</div>
<section class="panel">
<div class="panel-header"><h3>快速录单</h3><span>支持姓名 + 手机号自动识别客户</span></div>
<div class="grid-form">
<label><span>客户姓名</span><input v-model.trim="form.customer_name" type="text" /></label>
<label><span>手机号</span><input v-model.trim="form.customer_mobile" type="text" /></label>
<label><span>产品名称</span><input v-model.trim="form.product_name" type="text" /></label>
<label><span>数量</span><input v-model.number="form.quantity" type="number" min="1" /></label>
<label><span>售价</span><input v-model.number="form.sale_price" type="number" min="0" step="0.01" /></label>
<label><span>备注</span><input v-model.trim="form.remark" type="text" /></label>
</div>
<p class="tip">{{ customerHint }}</p>
<div class="action-row"><button class="ghost-btn" @click="saveDraft">保存草稿</button><button class="primary-btn" @click="submitOrder">提交审核</button></div>
</section>
<section class="grid-layout">
<article class="panel">
<div class="panel-header"><h3>我的订单</h3><span>查看个人订单列表</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>订单号</th><th>客户</th><th>状态</th><th>金额</th></tr></thead>
<tbody>
<tr v-for="row in orders" :key="row.orderNo">
<td>{{ row.orderNo }}</td><td>{{ row.customerName }}</td><td>{{ row.status }}</td><td>{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-header"><h3>提醒待办</h3><span>欠款与长期未下单统一提醒</span></div>
<div class="reminder-list">
<div v-for="item in reminders" :key="item.title" class="reminder-card">
<strong>{{ item.title }}</strong>
<p>{{ item.detail }}</p>
<button class="ghost-btn" @click="readReminder(item)">已处理</button>
</div>
</div>
</article>
</section>
</section>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
const orders = ref([
{ orderNo: 'SO202605140010', customerName: '客户A', status: '待审核', amount: '100.00' },
{ orderNo: 'SO202605140011', customerName: '客户B', status: '草稿', amount: '0.00' },
]);
const reminders = ref([
{ title: '欠款提醒', detail: '客户A 账期即将到期。' },
{ title: '长期未下单提醒', detail: '客户C 已 60 天未下单。' },
]);
const newCustomers = ref([{ name: '客户D' }]);
const form = reactive({ customer_name: '', customer_mobile: '', product_name: '', quantity: 1, sale_price: 0, remark: '' });
const customerHint = computed(() => form.customer_name && form.customer_mobile ? `当前客户 ${form.customer_name} / ${form.customer_mobile},系统按姓名 + 手机号识别。` : '输入客户姓名与手机号后,系统会自动识别是否为新客户。');
const draftCount = computed(() => orders.value.filter((item) => item.status === '草稿').length);
function saveDraft() { orders.value.unshift({ orderNo: `SO${Date.now()}`, customerName: form.customer_name || '未命名客户', status: '草稿', amount: '0.00' }); }
function submitOrder() { orders.value.unshift({ orderNo: `SO${Date.now()}`, customerName: form.customer_name || '未命名客户', status: '待审核', amount: '0.00' }); }
function readReminder(item) { item.detail = `${item.detail}(已处理)`; }
</script>
<style scoped>
.page{padding:0;display:grid;gap:16px}.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}.subline{margin:0;color:#64748b}.mode-tag{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:600}.summary-grid,.grid-layout{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}.summary-card span{display:block;color:#64748b;margin-bottom:8px}.summary-card strong{font-size:24px}.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}.grid-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.tip{margin:10px 0 0;color:#1d4ed8;background:#eff6ff;padding:10px 12px;border-radius:12px}.action-row{display:flex;justify-content:flex-end;gap:10px;margin-top:14px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid #e5e7eb}.reminder-list{display:grid;gap:10px}.reminder-card{padding:12px;border:1px solid #dbe3ef;border-radius:14px;background:#f8fbff}.reminder-card p{margin:8px 0 0;color:#475569;line-height:1.6}.ghost-btn,.primary-btn,input{border-radius:12px;border:1px solid #cbd5e1;padding:10px 12px;background:#fff}.primary-btn{background:#2563eb;color:#fff;border-color:#2563eb}.ghost-btn{background:#f8fafc;color:#334155}@media (max-width: 900px){.page-header,.panel-header{flex-direction:column;align-items:flex-start}.grid-form{grid-template-columns:1fr}}
</style>

View File

@ -63,7 +63,7 @@
| 角色 | 完成度 | 状态 | 说明 |
|---|---:|---|---|
| 业务员端完整支持 | 0% | 未完成 | 未见完整业务员端录单、查单、提醒处理页面 |
| 业务员端完整支持 | 35% | 部分完成 | 已补充业务员工作台、录单与提醒入口 |
| 管理层端完整支持 | 65% | 部分完成 | 已补充管理层工作台、审批与取消处理入口 |
| 秘书端完整支持 | 0% | 未完成 | 未见秘书端专属流程 |
| 司机端完整支持 | 0% | 未完成 | 未见完整司机端 |
@ -75,9 +75,9 @@
| 需求项 | 完成度 | 状态 | 说明 |
|---|---:|---|---|
| 完整业务员录单页面 | 0% | 未完成 | 未看到订单录入页面 |
| 完整查单页面 | 0% | 未完成 | 未看到业务员个人订单列表 |
| 提醒处理页面 | 0% | 未完成 | 未看到提醒处理页面 |
| 完整业务员录单页面 | 40% | 部分完成 | 已补充业务员工作台快速录单 |
| 完整查单页面 | 35% | 部分完成 | 已补充业务员工作台个人订单列表 |
| 提醒处理页面 | 35% | 部分完成 | 已补充业务员工作台提醒待办处理 |
#### 5.2 小程序管理层端
@ -149,9 +149,9 @@
| 需求项 | 完成度 | 状态 | 说明 |
|---|---:|---|---|
| 物流超时提醒 | 55% | 部分完成 | 已补充提醒中心与相关入口,提醒执行逻辑后续完善 |
| 欠款提醒 | 35% | 部分完成 | 已补充提醒中心与提醒管理入口,待接入真实欠款计算 |
| 长时间未下单提醒 | 35% | 部分完成 | 已补充提醒中心与提醒管理入口,待接入真实客户沉默周期逻辑 |
| 物流超时提醒 | 70% | 部分完成 | 已补充提醒中心后端补跑与前端联动 |
| 欠款提醒 | 50% | 部分完成 | 已补充提醒中心后端补跑与前端联动 |
| 长时间未下单提醒 | 50% | 部分完成 | 已补充提醒中心后端补跑与前端联动 |
#### 6.6 AI 规则