企迹(qiji) 政企周报管理系统 — v0.1

后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微
前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia

功能清单:
- 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录
- 双布局: 移动端(填报) + PC端(汇总管理)
- 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿
- 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD
- 客户档案: 备注/收支费用/联系人/归属分配/批量转移
- 客户导入导出 + 模板下载 + 搜索/分页/筛选
- 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度)
- 周报详情: 五 Tab + 按人/客户筛选 + 时间轴
- 用户管理 / 客户经理 PC 端工作台
- 企微: 催办/公告/定时提醒 / 时区修正
- Docker 部署配置

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:35:46 +08:00
parent 2a8225c31f
commit a1886074dd
97 changed files with 8830 additions and 0 deletions
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
const activeTab = ref('visits')
const loading = ref(false)
const visits = ref<any[]>([])
const workPlans = ref<any[]>([])
const miniBusiness = ref<any[]>([])
const dailyNotes = ref<any[]>([])
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
// Dialog
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogType = ref('')
const form = ref<any>({})
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
onMounted(async () => {
await Promise.all([loadAll(), loadCustomers()])
})
function typeLabel(t: string) {
const labels: Record<string, string> = { visit: '拜访记录', note: '今日纪要', plan: '工作计划', mini: '小微商机', key: '要客拜访' }
return labels[t] || ''
}
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadAll() {
loading.value = true
try {
const [v, w, m, d, k] = await Promise.all([
api.get('/visits/'), api.get('/work-plans/'),
api.get('/mini-business/'), api.get('/daily-notes/'),
api.get('/key-visits/'),
])
visits.value = v.data; workPlans.value = w.data
miniBusiness.value = m.data; dailyNotes.value = d.data
keyVisits.value = k.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate(type: string) {
dialogMode.value = 'create'; dialogType.value = type
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', communication_content: '', customer_demand: '' }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
else if (type === 'key') form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
dialogVisible.value = true
}
function openEdit(type: string, item: any) {
dialogMode.value = 'edit'; dialogType.value = type
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
const t = dialogType.value
const d = form.value
try {
if (dialogMode.value === 'create') {
switch (t) {
case 'visit': await api.post('/visits/', d); break
case 'note': await api.post('/daily-notes/', d); break
case 'plan': await api.post('/work-plans/', d); break
case 'mini': await api.post('/mini-business/', d); break
case 'key': await api.post('/key-visits/', d); break
}
} else {
switch (t) {
case 'visit': await api.put(`/visits/${d.id}`, d); break
case 'note': await api.put(`/daily-notes/${d.id}`, d); break
case 'plan': await api.put(`/work-plans/${d.id}`, d); break
case 'mini': await api.put(`/mini-business/${d.id}`, d); break
case 'key': await api.put(`/key-visits/${d.id}`, d); break
}
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadAll()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(type: string, id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
switch (type) {
case 'visit': await api.delete(`/visits/${id}`); break
case 'note': await api.delete(`/daily-notes/${id}`); break
case 'plan': await api.delete(`/work-plans/${id}`); break
case 'mini': await api.delete(`/mini-business/${id}`); break
case 'key': await api.delete(`/key-visits/${id}`); break
}
ElMessage.success('已删除')
await loadAll()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
const visitsByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const v of visits.value) { const d = v.visit_date; if (!g[d]) g[d] = []; g[d].push(v) }
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
})
const notesByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const n of dailyNotes.value) { const d = n.note_date; if (!g[d]) g[d] = []; g[d].push(n) }
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
})
</script>
<template>
<div class="workspace" v-loading="loading">
<h3>我的工作数据</h3>
<el-card>
<el-tabs v-model="activeTab">
<!-- 拜访记录 -->
<el-tab-pane label="拜访记录" name="visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('visit', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="!visits.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 今日纪要 -->
<el-tab-pane label="今日纪要" name="daily_notes">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('note', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="!dailyNotes.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 工作计划 -->
<el-tab-pane label="工作计划" name="work_plans">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
<el-table :data="workPlans" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('plan', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!workPlans.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 小微商机 -->
<el-tab-pane label="小微商机" name="mini_biz">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
<el-table :data="miniBusiness" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('mini', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!miniBusiness.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 要客拜访 -->
<el-tab-pane label="要客拜访" name="key_visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
<el-table :data="keyVisits" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="80"><template #default="{ row }"><el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag></template></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="progress_status" label="进展" width="80" />
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('key', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<!-- Customer selector for types that need it -->
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<!-- Date -->
<el-form-item v-if="dialogType === 'visit'" label="日期">
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期">
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<!-- Visit specific -->
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<el-radio-group v-model="form.visit_method">
<el-radio-button value="上门">上门</el-radio-button><el-radio-button value="电话">电话</el-radio-button><el-radio-button value="微信">微信</el-radio-button><el-radio-button value="出差">出差</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
</template>
<!-- Note specific -->
<template v-if="dialogType === 'note'">
<el-form-item label="分类">
<div style="display:flex;flex-wrap:wrap;gap:6px">
<el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
</template>
<!-- Plan specific -->
<template v-if="dialogType === 'plan'">
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
</template>
<!-- Mini biz specific -->
<template v-if="dialogType === 'mini'">
<el-form-item label="产品类型"><el-input v-model="form.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="跟进中" value="跟进中" /><el-option label="已签约" value="已签约" /><el-option label="已流失" value="已流失" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-input v-model="form.expected_revenue_date" /></el-form-item>
</template>
<!-- Key visit specific -->
<template v-if="dialogType === 'key'">
<el-form-item label="紧急重要度"><el-radio-group v-model="form.urgency_level"><el-radio-button value="一般">一般</el-radio-button><el-radio-button value="重要">重要</el-radio-button><el-radio-button value="紧急">紧急</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="form.progress_status"><el-option label="未开始" value="未开始" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-input v-model="form.planned_date" /></el-form-item>
<el-form-item label="计划拜访人"><el-input v-model="form.planned_visitor" /></el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.workspace h3 { margin: 0 0 16px; }
.date-group { margin-bottom: 20px; }
.date-group h4 { margin: 12px 0 8px; font-size: 14px; color: #303133; }
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
</style>