@@ -0,0 +1,156 @@
|
||||
# 协同拜访记录去重 — 设计方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前机制:客户经理 A 创建拜访记录并选择协同人员 B 时,后端为 B 生成一条空内容的草稿副本。B 填写后,同一客户、同一天出现两条独立拜访记录,导致周报重复展示、亮灯表重复计数。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 周报中同一客户同一次拜访合并展示为一张卡片
|
||||
2. 亮灯表不重复计数
|
||||
3. 每个参与人的填报进度不受影响(各自计入)
|
||||
4. 每个参与人能独立编辑自己的副本
|
||||
5. 历史数据兼容,不做强制迁移
|
||||
|
||||
## 方案概要
|
||||
|
||||
### 1. 数据库:新增 `visit_group_id`
|
||||
|
||||
```sql
|
||||
ALTER TABLE visits ADD COLUMN visit_group_id UUID;
|
||||
CREATE INDEX ix_visits_visit_group_id ON visits(visit_group_id);
|
||||
```
|
||||
|
||||
- 单人拜访:`visit_group_id = NULL`
|
||||
- 多人协同拜访:同一次拜访的所有记录共享同一个 `visit_group_id`
|
||||
|
||||
### 2. 后端:POST /visits/ 改造
|
||||
|
||||
**原逻辑:** 为每个协同人生成一条空内容草稿(`communication_content=""`, `photos=[]`, `companions=[]`)
|
||||
|
||||
**新逻辑:** 为每个协同人生成一条完整副本:
|
||||
|
||||
| 副本字段 | 值 | 说明 |
|
||||
|----------|-----|------|
|
||||
| `customer_id` | 同主记录 | |
|
||||
| `visit_date` | 同主记录 | |
|
||||
| `visit_method` | 同主记录 | |
|
||||
| `time_range` | 同主记录 | |
|
||||
| `visitor_name` | 同主记录 | 实际拜访人不变 |
|
||||
| `visitor_phone` | 同主记录 | |
|
||||
| `communication_content` | 原内容 + `(协同XXX)` | 如:`了解项目进度...(协同韦佶秀)` |
|
||||
| `customer_demand` | 同主记录 | |
|
||||
| `companions` | `[主记录创建人]` | 互换:协同人看到主访人 |
|
||||
| `companion_names` | 同主记录 | |
|
||||
| `photos` | 同主记录 | |
|
||||
| `manager_id` | 协同人的 UUID | 归属到协同人名下 |
|
||||
| `visit_group_id` | 同主记录 | **同一 UUID,关联所有副本** |
|
||||
|
||||
**前端表现(创建人韦佶秀视角):**
|
||||
|
||||
```
|
||||
相关人员选择了 [韦伦]
|
||||
→ 保存后韦伦收到一条完整拜访记录
|
||||
→ 韦伦记录内容末尾自动追加"(协同韦佶秀)"
|
||||
```
|
||||
|
||||
### 3. 后端:PUT /visits/{id} 不变
|
||||
|
||||
协同人编辑自己的副本时不影响主记录,各自独立。
|
||||
|
||||
### 4. 仪表盘统计:按 visit_group_id 去重
|
||||
|
||||
`get_dashboard_stats` 中的 `week_visits` 改为:
|
||||
|
||||
```python
|
||||
# 原:count 所有 visit 行
|
||||
select count(distinct coalesce(visit_group_id, id))
|
||||
from visits
|
||||
where visit_date between monday and sunday
|
||||
```
|
||||
|
||||
每个 visit_group_id 只算一次,跳过 NULL(单人拜访用 id 做 fallback key)。
|
||||
|
||||
### 5. 填报进度:不变
|
||||
|
||||
`get_reporting_progress` 按 `manager_id` 分组计数,每个参与人各算一次,不受去重影响。
|
||||
|
||||
### 6. 亮灯表:按 (customer_id, visit_date, visit_group_id) 去重
|
||||
|
||||
同一客户同一天同一次拜访只亮一盏灯。
|
||||
|
||||
### 7. 周报展示:按 visit_group_id 合并
|
||||
|
||||
`get_weekly_report` 查询时,同一个 `visit_group_id` 的记录合并成一条输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_name": "污水处理厂",
|
||||
"visit_date": "2026-07-30",
|
||||
"visit_method": "上门",
|
||||
"time_range": "09:00-10:30",
|
||||
"visit_group_id": "abc-123",
|
||||
"participants": [
|
||||
{
|
||||
"manager_name": "韦佶秀",
|
||||
"manager_id": "uuid-1",
|
||||
"role": "primary",
|
||||
"communication_content": "了解污水处理新项目...",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
},
|
||||
{
|
||||
"manager_name": "韦伦",
|
||||
"manager_id": "uuid-2",
|
||||
"role": "companion",
|
||||
"communication_content": "了解污水处理新项目...(协同韦佶秀)",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**前端周报卡片展示:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 污水处理厂 2026-07-30 上门 │
|
||||
│ 09:00-10:30 │
|
||||
│ │
|
||||
│ ▸ 韦佶秀(主访) │
|
||||
│ 了解污水处理新项目建设进度,客户计划新增... │
|
||||
│ 客户需求:专线扩容至100M,新增视频监控... │
|
||||
│ 📷 2张 │
|
||||
│ │
|
||||
│ ▸ 韦伦(协同) │
|
||||
│ 了解污水处理新项目建设进度...(协同韦佶秀) │
|
||||
│ 客户需求:专线扩容至100M... │
|
||||
│ 📷 2张 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8. 前端:协同记录编辑提示
|
||||
|
||||
协同人打开自己的副本编辑时,沟通内容区域上方显示一行提示:
|
||||
|
||||
> 💡 此记录为协同韦佶秀拜访「污水处理厂」的副本。你可以补充或修改内容,不会影响主记录。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 层 | 文件 | 改动 |
|
||||
|----|------|------|
|
||||
| DB | migration | `ALTER TABLE visits ADD visit_group_id UUID` |
|
||||
| 后端 | `models/visit.py` | 新增 `visit_group_id` 字段 |
|
||||
| 后端 | `schemas/visit.py` | `VisitOut` 新增 `visit_group_id` |
|
||||
| 后端 | `api/visits.py` POST | 副本改为完整复制 + `(协同XXX)` 后缀 + 互换 companions + 设置 visit_group_id |
|
||||
| 后端 | `services/dashboard.py` | `get_dashboard_stats` 的 week_visits 改为 distinct 去重 |
|
||||
| 后端 | `services/dashboard.py` | `get_weekly_report` 合并同 visit_group_id 记录 |
|
||||
| 后端 | `services/light_board.py` | 亮灯计数去重 |
|
||||
| 前端 | 周报 `WeeklyReport.vue` | 拜访卡片支持 participants 展开/折叠 |
|
||||
| 前端 | 移动端 `VisitForm.vue` | 协同记录编辑时显示提示条 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 历史数据 `visit_group_id = NULL`,行为与现在完全一致
|
||||
- 前端 `participants` 字段回退:单 participant 时展示与现在无差异
|
||||
@@ -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 {
|
||||
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')
|
||||
}
|
||||
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>
|
||||
|
||||
<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 {
|
||||
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')
|
||||
}
|
||||
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>
|
||||
|
||||
<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,9 +237,10 @@ async function handleDelete() {
|
||||
style="width:100%"
|
||||
>
|
||||
<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:8px">
|
||||
<button type="button" class="quick-create-btn" @click="handleQuickCreate">
|
||||
<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>
|
||||
@@ -244,6 +248,15 @@ async function handleDelete() {
|
||||
新建客户「{{ 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>
|
||||
</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 {
|
||||
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')
|
||||
}
|
||||
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>
|
||||
|
||||
<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