企迹(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:
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { visitsApi } from '@/api/visits'
|
||||
import { customersApi } from '@/api/customers'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitLoading = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
visit_date: todayStr(),
|
||||
visit_method: '上门',
|
||||
time_range: '',
|
||||
communication_content: '',
|
||||
customer_demand: '',
|
||||
companions: [] as string[],
|
||||
photos: [] as string[],
|
||||
})
|
||||
|
||||
const customers = ref<any[]>([])
|
||||
const managers = ref<any[]>([])
|
||||
const uploadedPhotos = ref<string[]>([])
|
||||
const photoPreviews = ref<string[]>([]) // blob URLs for local preview
|
||||
const uploading = ref(false)
|
||||
const customerSearch = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCustomers()
|
||||
try {
|
||||
const res = await api.get('/users/', { params: { role: 'manager' } })
|
||||
managers.value = res.data
|
||||
} catch (_) {}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await visitsApi.get(route.params.id as string)
|
||||
const v = res.data
|
||||
form.value = {
|
||||
customer_id: v.customer_id,
|
||||
visit_date: v.visit_date,
|
||||
visit_method: v.visit_method,
|
||||
time_range: v.time_range,
|
||||
communication_content: v.communication_content || '',
|
||||
customer_demand: v.customer_demand || '',
|
||||
companions: (v.companions || []).map(String),
|
||||
photos: v.photos || [],
|
||||
}
|
||||
uploadedPhotos.value = v.photos || []
|
||||
// Load photo previews for existing photos
|
||||
for (const key of (v.photos || [])) {
|
||||
try {
|
||||
const urlRes = await uploadApi.getDownloadUrl(key)
|
||||
photoPreviews.value.push(urlRes.data.download_url)
|
||||
} catch (_) { photoPreviews.value.push('') }
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
if (q) params.search = q
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) {
|
||||
await loadCustomers(query)
|
||||
} else {
|
||||
await loadCustomers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
if (uploadedPhotos.value.length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||||
|
||||
uploading.value = true
|
||||
for (const file of Array.from(target.files)) {
|
||||
if (uploadedPhotos.value.length >= 9) break
|
||||
// Show local preview immediately
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
photoPreviews.value.push(previewUrl)
|
||||
try {
|
||||
const res = await uploadApi.getPresignedUrl(file.name, file.type || 'image/jpeg')
|
||||
await uploadApi.uploadFile(res.data.upload_url, file)
|
||||
uploadedPhotos.value.push(res.data.object_key)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
} catch (e: any) {
|
||||
// Remove preview on failure
|
||||
const idx = photoPreviews.value.indexOf(previewUrl)
|
||||
if (idx >= 0) photoPreviews.value.splice(idx, 1)
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
ElMessage.error('上传失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
uploading.value = false
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function removePhoto(index: number) {
|
||||
// Clean up preview URL
|
||||
if (index < photoPreviews.value.length) {
|
||||
URL.revokeObjectURL(photoPreviews.value[index])
|
||||
photoPreviews.value.splice(index, 1)
|
||||
}
|
||||
uploadedPhotos.value.splice(index, 1)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await visitsApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('记录已更新')
|
||||
} else {
|
||||
await visitsApi.create(form.value)
|
||||
ElMessage.success('拜访记录已提交')
|
||||
}
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此拜访记录?', '确认', { type: 'warning' })
|
||||
deleting.value = true
|
||||
await visitsApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
} finally { deleting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visit-form">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>{{ isEdit ? '编辑拜访记录' : '今日拜访' }}</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="handleCustomerSearch"
|
||||
placeholder="搜索/选择客户"
|
||||
style="width:100%"
|
||||
popper-class="customer-select-popper"
|
||||
>
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:6px">
|
||||
<el-button size="small" type="success" @click="handleQuickCreate">
|
||||
+ 新建客户「{{ customerSearch }}」
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拜访日期">
|
||||
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<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" placeholder="如 9:00-10:00" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="同访人员">
|
||||
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="沟通内容">
|
||||
<el-input v-model="form.communication_content" type="textarea" :rows="4" placeholder="本次拜访沟通内容..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="客户需求">
|
||||
<el-input v-model="form.customer_demand" type="textarea" :rows="2" placeholder="客户提出的需求..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拜访照片 (最多9张)">
|
||||
<div class="photo-area">
|
||||
<div v-for="(key, idx) in uploadedPhotos" :key="key" class="photo-item">
|
||||
<img :src="photoPreviews[idx]" class="photo-thumb" v-if="photoPreviews[idx]" />
|
||||
<span class="photo-label">照片{{ idx + 1 }}</span>
|
||||
<el-button text size="small" type="danger" @click="removePhoto(idx)">移除</el-button>
|
||||
</div>
|
||||
<label v-if="uploadedPhotos.length < 9" class="upload-btn">
|
||||
<el-button :loading="uploading" type="primary" plain>+ 拍照/选图</el-button>
|
||||
<input type="file" accept="image/*" multiple @change="handlePhotoUpload" class="file-input-hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div style="margin-top:24px; display:flex; gap:12px">
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="flex:1">
|
||||
提交
|
||||
</el-button>
|
||||
<el-button v-if="isEdit" type="danger" size="large" :loading="deleting" @click="handleDelete">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visit-form { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
.photo-area { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.photo-item { display: flex; align-items: center; gap: 6px; background: #f5f7fa; padding: 4px 10px; border-radius: 8px; }
|
||||
.photo-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
|
||||
.photo-label { font-size: 12px; }
|
||||
.upload-btn { cursor: pointer; position: relative; display: inline-block; }
|
||||
.upload-btn .file-input-hidden {
|
||||
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
|
||||
opacity: 0; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user