企迹(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
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const managers = ref<any[]>([])
const selectedUserIds = ref<string[]>([])
const remindMessage = ref('')
const announcementContent = ref('')
const remindLoading = ref(false)
const announceLoading = ref(false)
const dailyCheckLoading = ref(false)
// Import
const importDialogVisible = ref(false)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(null)
const importLoading = ref(false)
const importResult = ref<any>(null)
onMounted(async () => {
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
} catch (_) {}
})
async function handleRemind() {
if (!selectedUserIds.value.length) { ElMessage.warning('请选择要提醒的人员'); return }
remindLoading.value = true
try {
const res = await api.post('/wecom/remind', {
user_ids: selectedUserIds.value,
message: remindMessage.value || undefined,
})
ElMessage.success(`已发送提醒给 ${res.data.sent_to}`)
} catch (e: any) {
ElMessage.error('发送失败')
} finally {
remindLoading.value = false
}
}
async function handleAnnouncement() {
if (!announcementContent.value) { ElMessage.warning('请输入公告内容'); return }
announceLoading.value = true
try {
const res = await api.post('/wecom/announcement', {
content: announcementContent.value,
})
ElMessage.success(`公告已推送给 ${res.data.sent_to}`)
announcementContent.value = ''
} catch (e: any) {
ElMessage.error('推送失败')
} finally {
announceLoading.value = false
}
}
async function handleDailyCheck() {
dailyCheckLoading.value = true
try {
const res = await api.post('/wecom/trigger-daily-check')
ElMessage.success(`已执行:${res.data.reported}/${res.data.total_managers} 人已填报`)
} catch (e: any) {
ElMessage.error('执行失败')
} finally {
dailyCheckLoading.value = false
}
}
function handleImportFile(e: Event) {
const target = e.target as HTMLInputElement
if (target.files?.[0]) {
importFile.value = target.files[0]
}
}
async function handleImportPreview() {
if (!importFile.value) return
importLoading.value = true
const formData = new FormData()
formData.append('file', importFile.value)
try {
const res = await api.post('/import/weekly-report', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
importPreview.value = res.data.preview
importResult.value = res.data
} catch (e: any) {
ElMessage.error('解析失败')
} finally {
importLoading.value = false
}
}
</script>
<template>
<div class="settings-page">
<h3>系统设置</h3>
<!-- Manual Remind -->
<el-card class="setting-card">
<template #header>📢 手动催办</template>
<el-form>
<el-form-item label="选择人员">
<el-select v-model="selectedUserIds" multiple placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="提醒内容">
<el-input v-model="remindMessage" type="textarea" :rows="2" placeholder="可选,留空使用默认提醒语" />
</el-form-item>
<el-button type="primary" :loading="remindLoading" @click="handleRemind">发送催办</el-button>
</el-form>
</el-card>
<!-- Announcement -->
<el-card class="setting-card">
<template #header>📣 全员公告</template>
<el-form>
<el-form-item label="公告内容">
<el-input v-model="announcementContent" type="textarea" :rows="3" placeholder="输入公告内容..." />
</el-form-item>
<el-button type="warning" :loading="announceLoading" @click="handleAnnouncement">推送公告</el-button>
</el-form>
</el-card>
<!-- Daily Check -->
<el-card class="setting-card">
<template #header> 填报检查</template>
<p>手动触发今日填报检查通常每日 18:00 自动执行</p>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
</el-card>
<!-- Import -->
<el-card class="setting-card">
<template #header>📥 Excel 数据导入</template>
<el-form>
<el-form-item label="上传旧周报 Excel">
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
</el-form-item>
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">
预览并导入
</el-button>
</el-form>
<!-- Preview Results -->
<div v-if="importPreview" style="margin-top:16px">
<h4>文件预览</h4>
<el-table :data="Object.entries(importPreview)" stripe>
<el-table-column prop="0" label="Sheet" />
<el-table-column prop="1.row_count" label="数据行数" />
<el-table-column label="表头">
<template #default="{ row }">
{{ (row[1] as any).headers.join(', ') }}
</template>
</el-table-column>
</el-table>
</div>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }}
</el-alert>
</div>
</el-card>
</div>
</template>
<style scoped>
.settings-page h3 { margin: 0 0 20px; }
.setting-card { margin-bottom: 16px; }
</style>