@@ -8,25 +8,15 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
path: '/m',
|
||||
label: '首页',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
path: '/m/visit/new',
|
||||
label: '拜访',
|
||||
icon: 'visit',
|
||||
},
|
||||
{
|
||||
path: '/m/note/new',
|
||||
label: '纪要',
|
||||
icon: 'note',
|
||||
},
|
||||
{ path: '/m', label: '首页', icon: 'home' },
|
||||
{ path: '/m/work', label: '工作', icon: 'work' },
|
||||
{ path: '/m/visit/new', label: '拜访', icon: 'visit' },
|
||||
{ path: '/m/note/new', label: '纪要', icon: 'note' },
|
||||
]
|
||||
|
||||
const activeTab = computed(() => {
|
||||
if (route.path === '/m') return '/m'
|
||||
if (route.path === '/m/work' || route.path.startsWith('/m/plans') || route.path.startsWith('/m/mini-biz') || route.path.startsWith('/m/key-visits') || route.path.startsWith('/m/leaves') || route.path.startsWith('/m/leave')) return '/m/work'
|
||||
if (route.path.includes('/visit')) return '/m/visit/new'
|
||||
if (route.path.includes('/note')) return '/m/note/new'
|
||||
return route.path
|
||||
@@ -76,6 +66,11 @@ const activeTab = computed(() => {
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
<!-- Work icon -->
|
||||
<svg v-else-if="tab.icon === 'work'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||
</svg>
|
||||
<!-- Visit icon -->
|
||||
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
|
||||
@@ -3,13 +3,16 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/index'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitLoading = ref(false)
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
|
||||
|
||||
@@ -24,6 +27,7 @@ const form = ref({
|
||||
category: '其他',
|
||||
content: '',
|
||||
time_range: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
function onTimeRangeChange(val: [string, string] | null) {
|
||||
@@ -36,6 +40,9 @@ function parseTimeRange(str: string): [string, string] | null {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
try { const res = await api.get('/users/', { params: { role: 'manager' } }); allManagers.value = res.data } catch (_) {}
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/daily-notes/${route.params.id}`)
|
||||
@@ -45,6 +52,7 @@ onMounted(async () => {
|
||||
category: n.category,
|
||||
content: n.content || '',
|
||||
time_range: n.time_range || '',
|
||||
manager_id: n.manager_id || auth.userId || '',
|
||||
}
|
||||
timeRangeValue.value = parseTimeRange(n.time_range)
|
||||
} catch (_) {}
|
||||
@@ -52,6 +60,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.note_date) { ElMessage.warning('请选择日期'); return }
|
||||
if (!form.value.content.trim()) { ElMessage.warning('请输入纪要内容'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
@@ -100,11 +109,18 @@ async function handleDelete() {
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">日期</span>
|
||||
<span class="form-label">日期 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">分类</span>
|
||||
@@ -143,7 +159,7 @@ async function handleDelete() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">工作内容 <span class="form-label-required">*</span></span>
|
||||
<span class="form-label">工作内容 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="今天做了什么..." />
|
||||
</el-form-item>
|
||||
@@ -273,4 +289,5 @@ async function handleDelete() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -99,38 +99,6 @@ onMounted(loadToday)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Secondary Quick Links ═══ -->
|
||||
<div class="quick-links">
|
||||
<button class="link-chip" @click="router.push('/m/work-plan/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
工作计划
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/mini-biz/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"></line>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||
</svg>
|
||||
商机跟单
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/key-visit/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
要客拜访
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/leaves')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
请假
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section Divider ═══ -->
|
||||
<div class="section-head" v-if="dailyNotes.length || visits.length">
|
||||
<span class="section-title">今日记录</span>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { keyVisitsApi } from '@/api/keyVisits'
|
||||
import { customersApi } from '@/api/customers'
|
||||
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 customers = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const urgencyLevels = ['一般', '重要', '紧急']
|
||||
const urgencyColors: Record<string, string> = {
|
||||
@@ -25,6 +30,7 @@ const form = ref({
|
||||
planned_date: '',
|
||||
planned_visitor: '',
|
||||
visit_target: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
function onVisitorsChange(val: string[]) {
|
||||
@@ -33,8 +39,23 @@ function onVisitorsChange(val: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCustomers(), loadUsers()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/key-visits/${route.params.id}`)
|
||||
const d = res.data
|
||||
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
|
||||
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
|
||||
}
|
||||
form.value = { customer_id: d.customer_id, urgency_level: d.urgency_level, description: d.description || '', progress_status: d.progress_status, planned_date: d.planned_date, planned_visitor: d.planned_visitor, visit_target: d.visit_target, manager_id: d.manager_id }
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
@@ -44,6 +65,28 @@ async function loadCustomers(q?: string) {
|
||||
} 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 loadUsers() {
|
||||
try {
|
||||
const res = await api.get('/users/')
|
||||
@@ -55,13 +98,27 @@ async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await keyVisitsApi.create(form.value)
|
||||
ElMessage.success('要客拜访计划已提交')
|
||||
router.push('/m')
|
||||
if (isEdit.value) {
|
||||
await keyVisitsApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await keyVisitsApi.create(form.value)
|
||||
ElMessage.success('要客拜访计划已提交')
|
||||
}
|
||||
router.push('/m/key-visits')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await keyVisitsApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m/key-visits')
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,17 +131,31 @@ async function handleSubmit() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">添加要客拜访</h2>
|
||||
<h2 class="form-title">{{ isEdit ? '编辑要客拜访' : '添加要客拜访' }}</h2>
|
||||
<span class="form-subtitle">KEY VISIT PLAN</span>
|
||||
</div>
|
||||
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">客户单位</span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
|
||||
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -118,7 +189,7 @@ async function handleSubmit() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">计划拜访时间</span></template>
|
||||
<el-input v-model="form.planned_date" placeholder="如: 2026-06-25" />
|
||||
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -142,10 +213,12 @@ async function handleSubmit() {
|
||||
<el-input v-model="form.visit_target" placeholder="如: 王局长、张处长" />
|
||||
</el-form-item>
|
||||
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
提交计划
|
||||
</button>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
{{ isEdit ? '保存修改' : '提交计划' }}
|
||||
</button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -231,4 +304,13 @@ async function handleSubmit() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
|
||||
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
|
||||
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { keyVisitsApi } from '@/api/keyVisits'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await keyVisitsApi.list(params)
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
const urgencyColors: Record<string, string> = { '一般': '#5B7FA5', '重要': '#C4934A', '紧急': '#B8472E' }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await keyVisitsApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">要客拜访</h2><span class="form-subtitle">KEY VISITS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/key-visit/new')">+ 新建要客</button>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂不要客记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/key-visit/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: urgencyColors[item.urgency_level] || '#5B7FA5' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-urgency" :style="{ color: urgencyColors[item.urgency_level] || '#5B7FA5' }">{{ item.urgency_level }}</span>
|
||||
<span class="card-status">{{ item.progress_status }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.description }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-urgency { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-status { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await miniBusinessApi.list(params)
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
const statusColors: Record<string, string> = { '跟进中': '#C4934A', '已签约': '#4A6741', '已流失': '#B8472E' }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">商机跟单</h2><span class="form-subtitle">MINI BUSINESS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/mini-biz/new')">+ 新建商机</button>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无商机记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/mini-biz/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: statusColors[item.status] || '#C4934A' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-status" :style="{ color: statusColors[item.status] || '#C4934A' }">{{ item.status }}</span>
|
||||
<span class="card-amount">{{ item.amount }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.product_type }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-status { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-amount { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import { customersApi } from '@/api/customers'
|
||||
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 customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
@@ -16,9 +22,28 @@ const form = ref({
|
||||
follow_up_detail: '',
|
||||
status: '跟进中',
|
||||
expected_revenue_date: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
onMounted(async () => {
|
||||
loadCustomers()
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/mini-business/${route.params.id}`)
|
||||
const d = res.data
|
||||
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
|
||||
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
|
||||
}
|
||||
form.value = { customer_id: d.customer_id, product_type: d.product_type, amount: d.amount, follow_up_detail: d.follow_up_detail || '', status: d.status, expected_revenue_date: d.expected_revenue_date, manager_id: d.manager_id }
|
||||
} catch (_) {}
|
||||
loadLogs()
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
@@ -29,17 +54,93 @@ async function loadCustomers(q?: string) {
|
||||
} 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 handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await miniBusinessApi.create(form.value)
|
||||
ElMessage.success('商机已提交')
|
||||
router.push('/m')
|
||||
if (isEdit.value) {
|
||||
await miniBusinessApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await miniBusinessApi.create(form.value)
|
||||
ElMessage.success('商机已提交')
|
||||
}
|
||||
router.push('/m/mini-biz')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m/mini-biz')
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── Follow-up logs ──
|
||||
const logs = ref<any[]>([])
|
||||
const logForm = ref({ log_date: '', method: '电话', content: '' })
|
||||
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
|
||||
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
|
||||
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
|
||||
const logLoading = ref(false)
|
||||
const logSaving = ref(false)
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await miniBusinessApi.getLogs(route.params.id as string)
|
||||
logs.value = res.data || []
|
||||
} catch (_) { logs.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCreateLog() {
|
||||
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
|
||||
logSaving.value = true
|
||||
try {
|
||||
await miniBusinessApi.createLog(route.params.id as string, logForm.value)
|
||||
ElMessage.success('跟进记录已添加')
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
finally { logSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteLog(logId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.deleteLog(logId)
|
||||
ElMessage.success('已删除')
|
||||
await loadLogs()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,17 +153,31 @@ async function handleSubmit() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">添加商机跟单</h2>
|
||||
<h2 class="form-title">{{ isEdit ? '编辑商机跟单' : '添加商机跟单' }}</h2>
|
||||
<span class="form-subtitle">BUSINESS OPPORTUNITY</span>
|
||||
</div>
|
||||
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">客户单位</span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
|
||||
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -95,10 +210,43 @@ async function handleSubmit() {
|
||||
<el-input v-model="form.expected_revenue_date" placeholder="如: 2026Q3" />
|
||||
</el-form-item>
|
||||
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
提交商机
|
||||
</button>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
{{ isEdit ? '保存修改' : '提交商机' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Follow-up Timeline (edit mode only) -->
|
||||
<div v-if="isEdit" class="log-section">
|
||||
<div class="section-head"><span class="section-title">跟进记录</span><span class="section-line"></span></div>
|
||||
<div v-loading="logLoading" class="log-list">
|
||||
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
|
||||
<div v-for="l in logs" :key="l.id" class="log-item">
|
||||
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
|
||||
<div class="log-body">
|
||||
<div class="log-header">
|
||||
<span class="log-date">{{ l.log_date }}</span>
|
||||
<span class="log-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 6px', fontSize: '11px' }">{{ l.method }}</span>
|
||||
<span class="log-author">{{ l.created_by_name }}</span>
|
||||
</div>
|
||||
<div class="log-content">{{ l.content }}</div>
|
||||
</div>
|
||||
<button class="log-del" @click="handleDeleteLog(l.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Log -->
|
||||
<div class="log-add">
|
||||
<div class="log-add-row">
|
||||
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:130px" />
|
||||
<div class="log-chips">
|
||||
<button v-for="m in logMethods" :key="m" type="button" class="log-chip-btn" :class="{ 'log-chip-btn--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
|
||||
<button class="log-submit" :disabled="logSaving" @click="handleCreateLog">{{ logSaving ? '添加中...' : '添加跟进' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,4 +315,46 @@ async function handleSubmit() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
|
||||
.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.submit-btn:hover { background: var(--ink-light); }
|
||||
.submit-btn:active { transform: scale(0.98); }
|
||||
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
|
||||
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
|
||||
/* ── Follow-up Log Section ── */
|
||||
.log-section { margin-top: 20px; border-top: 1px solid var(--warm-border); padding-top: 16px; }
|
||||
.section-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 18px; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.section-line { flex: 1; height: 2px; background: var(--gold); }
|
||||
.log-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; max-height: 260px; overflow-y: auto; }
|
||||
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 16px 0; }
|
||||
.log-item { display: flex; gap: 8px; align-items: flex-start; padding: 10px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.log-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; }
|
||||
.log-body { flex: 1; min-width: 0; }
|
||||
.log-header { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
|
||||
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
|
||||
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
|
||||
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.5; }
|
||||
.log-del { flex-shrink: 0; background: none; border: none; color: var(--warm-gray); cursor: pointer; font-size: 14px; padding: 2px; }
|
||||
.log-del:hover { color: var(--vermilion); }
|
||||
.log-chip { border-radius: 2px; white-space: nowrap; }
|
||||
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
|
||||
.log-add-row { display: flex; gap: 6px; align-items: center; }
|
||||
.log-chips { display: flex; gap: 3px; }
|
||||
.log-chip-btn { padding: 4px 8px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.log-chip-btn:hover { border-color: var(--ink); }
|
||||
.log-chip-btn--active { font-weight: 600; }
|
||||
.log-submit { width: 100%; margin-top: 8px; padding: 10px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 14px; letter-spacing: 0.06em; cursor: pointer; }
|
||||
.log-submit:hover { background: var(--ink-light); }
|
||||
.log-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
const catColors: Record<string, string> = {
|
||||
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
|
||||
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/daily-notes/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">历史纪要</h2><span class="form-subtitle">NOTE HISTORY</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无纪要记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/note/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: catColors[item.category] || '#7B7568' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-cat" :style="{ color: catColors[item.category] || '#7B7568' }">{{ item.category }}</span>
|
||||
<span class="card-date">{{ item.note_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.content || '暂无内容' }}</div>
|
||||
<div class="card-footer">
|
||||
<span v-if="item.time_range" class="card-time">{{ item.time_range }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-cat { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; }
|
||||
.card-time { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await api.delete(`/work-plans/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">工作计划</h2><span class="form-subtitle">WORK PLANS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/work-plan/new')">+ 新建计划</button>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/work-plan/${item.id}/edit`)">
|
||||
<div class="card-accent" style="background:#4A6741"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-status" :style="{ color: item.status === '已完成' ? '#4A6741' : item.status === '已取消' ? '#B8472E' : '#C4934A' }">{{ item.status }}</span>
|
||||
<span class="card-date">{{ item.plan_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.plan_content }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-status { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ const form = ref({
|
||||
customer_demand: '',
|
||||
companions: [] as string[],
|
||||
photos: [] as string[],
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
const timeRangeValue = ref<any>(null)
|
||||
@@ -67,6 +68,7 @@ onMounted(async () => {
|
||||
customer_demand: v.customer_demand || '',
|
||||
companions: (v.companions || []).map(String),
|
||||
photos: v.photos || [],
|
||||
manager_id: v.manager_id || auth.userId || '',
|
||||
}
|
||||
uploadedPhotos.value = v.photos || []
|
||||
// Parse time range back into picker
|
||||
@@ -128,7 +130,8 @@ async function handlePhotoUpload(event: Event) {
|
||||
try {
|
||||
// Compress before upload to reduce storage & transfer
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
const res = await uploadApi.uploadImage(compressed)
|
||||
const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
|
||||
await uploadApi.uploadFile(res.data.upload_url, compressed)
|
||||
uploadedPhotos.value.push(res.data.object_key)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
} catch (e: any) {
|
||||
@@ -223,7 +226,7 @@ async function handleDelete() {
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">客户单位</span>
|
||||
<span class="form-label">客户单位 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
@@ -234,16 +237,26 @@ async function handleDelete() {
|
||||
style="width:100%"
|
||||
>
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
新建客户「{{ customerSearch }}」
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:8px">
|
||||
<button type="button" class="quick-create-btn" @click="handleQuickCreate">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
新建客户「{{ customerSearch }}」
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -288,7 +301,7 @@ async function handleDelete() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">相关人员 <span class="form-label-hint">可输入外部人员</span></span>
|
||||
<span class="form-label">相关人员 <span class="form-label-hint">同访人周报中也会显示此记录</span></span>
|
||||
</template>
|
||||
<el-select v-model="form.companions" multiple filterable allow-create default-first-option 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" />
|
||||
@@ -493,6 +506,7 @@ async function handleDelete() {
|
||||
color: var(--vermilion);
|
||||
background: rgba(184,71,46,0.03);
|
||||
}
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
|
||||
/* ═══ Photo Area ═══ */
|
||||
.photo-area {
|
||||
@@ -645,4 +659,5 @@ async function handleDelete() {
|
||||
.method--电话.method-chip--active { background: #5B7FA5; border-color: #5B7FA5; color: #fff; }
|
||||
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
|
||||
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/visits/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
|
||||
const methodColors: Record<string, string> = { '上门': '#4A6741', '电话': '#5B7FA5', '微信': '#22c55e', '出差': '#C4934A' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">历史拜访</h2><span class="form-subtitle">VISIT HISTORY</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无拜访记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/visit/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: methodColors[item.visit_method] || '#7B7568' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-method" :style="{ color: methodColors[item.visit_method] || '#7B7568' }">{{ item.visit_method }}</span>
|
||||
<span class="card-date">{{ item.visit_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.communication_content || '暂无沟通内容' }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-method { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const counts = ref({ visits: 0, notes: 0, plans: 0, miniBiz: 0, keyVisits: 0, leaves: 0 })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [visits, notes, plans, mini, key, leaves] = await Promise.all([
|
||||
api.get('/visits/', { params: { page_size: 1 } }),
|
||||
api.get('/daily-notes/', { params: { page_size: 1 } }),
|
||||
api.get('/work-plans/'),
|
||||
api.get('/mini-business/'),
|
||||
api.get('/key-visits/'),
|
||||
api.get('/leaves/', { params: { page_size: 1 } }),
|
||||
])
|
||||
counts.value.visits = Array.isArray(visits.data) ? visits.data.length : (visits.data.total || (visits.data.items || []).length)
|
||||
counts.value.notes = Array.isArray(notes.data) ? notes.data.length : (notes.data.total || (notes.data.items || []).length)
|
||||
counts.value.plans = Array.isArray(plans.data) ? plans.data.length : (plans.data.items || []).length
|
||||
counts.value.miniBiz = Array.isArray(mini.data) ? mini.data.length : (mini.data.items || []).length
|
||||
counts.value.keyVisits = Array.isArray(key.data) ? key.data.length : (key.data.items || []).length
|
||||
counts.value.leaves = leaves.data.total || 0
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
const modules = [
|
||||
{ path: '/m/plans', label: '工作计划', icon: '📅', count: () => counts.value.plans, color: '#4A6741' },
|
||||
{ path: '/m/mini-biz', label: '商机跟单', icon: '💰', count: () => counts.value.miniBiz, color: '#C4934A' },
|
||||
{ path: '/m/key-visits', label: '要客拜访', icon: '⭐', count: () => counts.value.keyVisits, color: '#5B7FA5' },
|
||||
{ path: '/m/leaves', label: '请假管理', icon: '📋', count: () => counts.value.leaves, color: '#9CA3AF' },
|
||||
{ path: '/m/visits', label: '历史拜访', icon: '🕐', count: () => counts.value.visits, color: '#4A6741' },
|
||||
{ path: '/m/notes', label: '历史纪要', icon: '📝', count: () => counts.value.notes, color: '#1C3738' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="work-center">
|
||||
<header class="form-editorial-header">
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">工作中心</h2>
|
||||
<span class="form-subtitle">WORK CENTER</span>
|
||||
</div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<div class="module-grid">
|
||||
<div
|
||||
v-for="m in modules" :key="m.path"
|
||||
class="module-card"
|
||||
:style="{ borderTop: '3px solid ' + m.color }"
|
||||
@click="router.push(m.path)"
|
||||
>
|
||||
<span class="module-icon">{{ m.icon }}</span>
|
||||
<span class="module-label">{{ m.label }}</span>
|
||||
<span class="module-count" :style="{ color: m.color }">{{ m.count() }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.work-center { padding-bottom: 24px; }
|
||||
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 22px; flex-wrap: wrap; }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
|
||||
.module-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
|
||||
.module-card {
|
||||
background: var(--surface); border: 1px solid var(--warm-border);
|
||||
padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.module-card:active { transform: scale(0.97); }
|
||||
.module-card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.module-icon { font-size: 28px; }
|
||||
.module-label { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.module-count { font-family: var(--font-mono); font-size: 12px; }
|
||||
</style>
|
||||
@@ -1,23 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { workPlansApi } from '@/api/workPlans'
|
||||
import { customersApi } from '@/api/customers'
|
||||
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 customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
plan_content: '',
|
||||
plan_date: todayStr(),
|
||||
status: '计划中',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
onMounted(async () => {
|
||||
loadCustomers()
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/work-plans/${route.params.id}`)
|
||||
const d = res.data
|
||||
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
|
||||
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
|
||||
}
|
||||
form.value = { customer_id: d.customer_id, plan_content: d.plan_content || '', plan_date: d.plan_date, status: d.status, manager_id: d.manager_id }
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
@@ -28,17 +52,54 @@ async function loadCustomers(q?: string) {
|
||||
} 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 handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
if (!form.value.plan_date) { ElMessage.warning('请选择计划拜访时间'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await workPlansApi.create(form.value)
|
||||
ElMessage.success('计划已提交')
|
||||
router.push('/m')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/work-plans/${route.params.id}`, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await workPlansApi.create(form.value)
|
||||
ElMessage.success('计划已提交')
|
||||
}
|
||||
router.push('/m/plans')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await api.delete(`/work-plans/${route.params.id}`)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m/plans')
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,17 +112,31 @@ async function handleSubmit() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">添加工作计划</h2>
|
||||
<h2 class="form-title">{{ isEdit ? '编辑工作计划' : '添加工作计划' }}</h2>
|
||||
<span class="form-subtitle">WORK PLAN</span>
|
||||
</div>
|
||||
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">客户单位</span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
|
||||
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -71,14 +146,16 @@ async function handleSubmit() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">计划拜访时间</span></template>
|
||||
<template #label><span class="form-label">计划拜访时间 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
提交计划
|
||||
</button>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
{{ isEdit ? '保存修改' : '提交计划' }}
|
||||
</button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -147,4 +224,19 @@ async function handleSubmit() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
|
||||
.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.submit-btn:hover { background: var(--ink-light); }
|
||||
.submit-btn:active { transform: scale(0.98); }
|
||||
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
|
||||
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user