xuyunhui 2 недель назад
Родитель
Сommit
1088eb526d

+ 351 - 0
src/api/contract.ts

@@ -0,0 +1,351 @@
+import type {
+  Contract,
+  ContractFormData,
+  ContractSearchParams,
+  PaymentPlan,
+  PaymentRecord
+} from '@/types/contract'
+import { delay, generateId } from '@/utils'
+
+const now = new Date().toISOString()
+
+const mockContracts: Contract[] = [
+  {
+    id: 'ct_001',
+    code: 'HT-2026-001',
+    name: '2026年度清洁服务合同',
+    customerId: 'cust_001',
+    customerName: '阿里巴巴科技有限公司',
+    projectId: 'proj_001',
+    projectName: '阿里园区日常保洁',
+    type: 'annual',
+    amount: 580000,
+    paidAmount: 348000,
+    paymentMethod: 'installment',
+    startDate: '2026-01-01',
+    endDate: '2026-12-31',
+    signDate: '2025-12-25',
+    serviceContent: '园区日常保洁、垃圾清运、卫生间清洁、公共区域消毒等',
+    status: 'active',
+    paymentPlans: [
+      { id: 'pp_001', contractId: 'ct_001', phase: 1, amount: 145000, plannedDate: '2026-01-10', actualDate: '2026-01-12', actualAmount: 145000, status: 'paid' },
+      { id: 'pp_002', contractId: 'ct_001', phase: 2, amount: 145000, plannedDate: '2026-04-10', actualDate: '2026-04-15', actualAmount: 145000, status: 'paid' },
+      { id: 'pp_003', contractId: 'ct_001', phase: 3, amount: 145000, plannedDate: '2026-07-10', status: 'unpaid' },
+      { id: 'pp_004', contractId: 'ct_001', phase: 4, amount: 145000, plannedDate: '2026-10-10', status: 'unpaid' }
+    ],
+    attachments: [{ id: 'att_001', name: '合同扫描件.pdf', url: '', size: 2048, createTime: now }],
+    createTime: '2025-12-25T10:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_002',
+    code: 'HT-2026-002',
+    name: '季度保洁合同',
+    customerId: 'cust_005',
+    customerName: '万科地产杭州分公司',
+    projectId: 'proj_002',
+    projectName: '万科中心保洁',
+    type: 'quarterly',
+    amount: 120000,
+    paidAmount: 60000,
+    paymentMethod: 'installment',
+    startDate: '2026-04-01',
+    endDate: '2026-06-30',
+    signDate: '2026-03-28',
+    serviceContent: '写字楼公共区域保洁、地毯清洗、玻璃清洁',
+    status: 'active',
+    paymentPlans: [
+      { id: 'pp_005', contractId: 'ct_002', phase: 1, amount: 60000, plannedDate: '2026-04-05', actualDate: '2026-04-08', actualAmount: 60000, status: 'paid' },
+      { id: 'pp_006', contractId: 'ct_002', phase: 2, amount: 60000, plannedDate: '2026-06-05', status: 'unpaid' }
+    ],
+    attachments: [],
+    createTime: '2026-03-28T09:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_003',
+    code: 'HT-2026-003',
+    name: '单次深度清洁',
+    customerId: 'cust_002',
+    customerName: '张三',
+    type: 'single',
+    amount: 3500,
+    paidAmount: 3500,
+    paymentMethod: 'lump_sum',
+    startDate: '2026-06-15',
+    endDate: '2026-06-15',
+    signDate: '2026-06-14',
+    serviceContent: '家庭全屋深度清洁、油烟机清洗',
+    status: 'completed',
+    paymentPlans: [
+      { id: 'pp_007', contractId: 'ct_003', phase: 1, amount: 3500, plannedDate: '2026-06-15', actualDate: '2026-06-15', actualAmount: 3500, status: 'paid' }
+    ],
+    attachments: [],
+    createTime: '2026-06-14T10:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_004',
+    code: 'HT-2026-004',
+    name: '年度服务合同',
+    customerId: 'cust_007',
+    customerName: '浙江大学后勤集团',
+    projectId: 'proj_003',
+    projectName: '浙大校区保洁',
+    type: 'annual',
+    amount: 780000,
+    paidAmount: 234000,
+    paymentMethod: 'quarterly',
+    startDate: '2026-01-01',
+    endDate: '2026-12-31',
+    signDate: '2025-12-20',
+    serviceContent: '校园教学楼、宿舍区、食堂周边保洁及垃圾清运',
+    status: 'active',
+    paymentPlans: [
+      { id: 'pp_008', contractId: 'ct_004', phase: 1, amount: 195000, plannedDate: '2026-01-05', actualDate: '2026-01-08', actualAmount: 195000, status: 'paid' },
+      { id: 'pp_009', contractId: 'ct_004', phase: 2, amount: 195000, plannedDate: '2026-04-05', actualAmount: 39000, status: 'partpaid' },
+      { id: 'pp_010', contractId: 'ct_004', phase: 3, amount: 195000, plannedDate: '2026-07-05', status: 'unpaid' },
+      { id: 'pp_011', contractId: 'ct_004', phase: 4, amount: 195000, plannedDate: '2026-10-05', status: 'unpaid' }
+    ],
+    attachments: [{ id: 'att_002', name: '招标文件.pdf', url: '', size: 5120, createTime: now }],
+    createTime: '2025-12-20T11:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_005',
+    code: 'HT-2026-005',
+    name: '月度保洁服务',
+    customerId: 'cust_008',
+    customerName: '网易网络有限公司',
+    projectId: 'proj_004',
+    projectName: '网易园区保洁',
+    type: 'monthly',
+    amount: 15000,
+    paidAmount: 0,
+    paymentMethod: 'monthly',
+    startDate: '2026-07-01',
+    endDate: '2026-07-31',
+    signDate: '2026-06-30',
+    serviceContent: '月度保洁服务,待双方签订后执行',
+    status: 'pending',
+    paymentPlans: [
+      { id: 'pp_012', contractId: 'ct_005', phase: 1, amount: 15000, plannedDate: '2026-07-05', status: 'unpaid' }
+    ],
+    attachments: [],
+    createTime: '2026-06-30T14:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_006',
+    code: 'HT-2026-006',
+    name: '季度合同',
+    customerId: 'cust_009',
+    customerName: '绿城物业服务集团',
+    projectId: 'proj_005',
+    projectName: '绿城项目保洁',
+    type: 'quarterly',
+    amount: 140000,
+    paidAmount: 35000,
+    paymentMethod: 'installment',
+    startDate: '2026-05-01',
+    endDate: '2026-07-31',
+    signDate: '2026-04-28',
+    serviceContent: '住宅小区公共区域保洁、绿化养护配合',
+    status: 'expiring',
+    paymentPlans: [
+      { id: 'pp_013', contractId: 'ct_006', phase: 1, amount: 35000, plannedDate: '2026-05-05', actualDate: '2026-05-10', actualAmount: 35000, status: 'paid' },
+      { id: 'pp_014', contractId: 'ct_006', phase: 2, amount: 35000, plannedDate: '2026-06-05', status: 'unpaid' },
+      { id: 'pp_015', contractId: 'ct_006', phase: 3, amount: 35000, plannedDate: '2026-07-05', status: 'unpaid' },
+      { id: 'pp_016', contractId: 'ct_006', phase: 4, amount: 35000, plannedDate: '2026-07-25', status: 'unpaid' }
+    ],
+    attachments: [],
+    createTime: '2026-04-28T09:30:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_007',
+    code: 'HT-2026-007',
+    name: '年度合同',
+    customerId: 'cust_003',
+    customerName: '杭州市政府后勤部',
+    projectId: 'proj_006',
+    projectName: '市政府后勤保洁',
+    type: 'annual',
+    amount: 1200000,
+    paidAmount: 960000,
+    paymentMethod: 'quarterly',
+    startDate: '2026-01-01',
+    endDate: '2026-12-31',
+    signDate: '2025-12-15',
+    serviceContent: '市政府办公楼日常保洁、会议保障、专项清洁',
+    status: 'active',
+    paymentPlans: [
+      { id: 'pp_017', contractId: 'ct_007', phase: 1, amount: 300000, plannedDate: '2026-01-05', actualDate: '2026-01-06', actualAmount: 300000, status: 'paid' },
+      { id: 'pp_018', contractId: 'ct_007', phase: 2, amount: 300000, plannedDate: '2026-04-05', actualDate: '2026-04-07', actualAmount: 300000, status: 'paid' },
+      { id: 'pp_019', contractId: 'ct_007', phase: 3, amount: 300000, plannedDate: '2026-07-05', actualDate: '2026-07-06', actualAmount: 300000, status: 'paid' },
+      { id: 'pp_020', contractId: 'ct_007', phase: 4, amount: 300000, plannedDate: '2026-10-05', status: 'unpaid' }
+    ],
+    attachments: [{ id: 'att_003', name: '政府采购合同.pdf', url: '', size: 8192, createTime: now }],
+    createTime: '2025-12-15T10:00:00',
+    updateTime: now
+  },
+  {
+    id: 'ct_008',
+    code: 'HT-2026-008',
+    name: '单次服务',
+    customerId: 'cust_006',
+    customerName: '李四',
+    type: 'single',
+    amount: 6000,
+    paidAmount: 6000,
+    paymentMethod: 'lump_sum',
+    startDate: '2026-06-20',
+    endDate: '2026-06-20',
+    signDate: '2026-06-18',
+    serviceContent: '搬家后全屋清洁、窗帘清洗',
+    status: 'completed',
+    paymentPlans: [
+      { id: 'pp_021', contractId: 'ct_008', phase: 1, amount: 6000, plannedDate: '2026-06-20', actualDate: '2026-06-20', actualAmount: 6000, status: 'paid' }
+    ],
+    attachments: [],
+    createTime: '2026-06-18T16:00:00',
+    updateTime: now
+  }
+]
+
+const mockPaymentRecords: PaymentRecord[] = [
+  { id: 'pr_001', contractId: 'ct_001', planId: 'pp_001', amount: 145000, payDate: '2026-01-12', payer: '阿里巴巴科技有限公司', receiver: '清道夫', createTime: '2026-01-12T10:00:00' },
+  { id: 'pr_002', contractId: 'ct_001', planId: 'pp_002', amount: 145000, payDate: '2026-04-15', payer: '阿里巴巴科技有限公司', receiver: '清道夫', createTime: '2026-04-15T10:00:00' },
+  { id: 'pr_003', contractId: 'ct_002', planId: 'pp_005', amount: 60000, payDate: '2026-04-08', payer: '万科地产杭州分公司', receiver: '清道夫', createTime: '2026-04-08T10:00:00' },
+  { id: 'pr_004', contractId: 'ct_003', planId: 'pp_007', amount: 3500, payDate: '2026-06-15', payer: '张三', receiver: '清道夫', createTime: '2026-06-15T10:00:00' },
+  { id: 'pr_005', contractId: 'ct_004', planId: 'pp_008', amount: 195000, payDate: '2026-01-08', payer: '浙江大学', receiver: '清道夫', createTime: '2026-01-08T10:00:00' },
+  { id: 'pr_006', contractId: 'ct_004', planId: 'pp_009', amount: 39000, payDate: '2026-04-10', payer: '浙江大学', receiver: '清道夫', createTime: '2026-04-10T10:00:00' },
+  { id: 'pr_007', contractId: 'ct_006', planId: 'pp_013', amount: 35000, payDate: '2026-05-10', payer: '绿城物业服务集团', receiver: '清道夫', createTime: '2026-05-10T10:00:00' },
+  { id: 'pr_008', contractId: 'ct_007', planId: 'pp_017', amount: 300000, payDate: '2026-01-06', payer: '杭州市政府后勤部', receiver: '清道夫', createTime: '2026-01-06T10:00:00' },
+  { id: 'pr_009', contractId: 'ct_007', planId: 'pp_018', amount: 300000, payDate: '2026-04-07', payer: '杭州市政府后勤部', receiver: '清道夫', createTime: '2026-04-07T10:00:00' },
+  { id: 'pr_010', contractId: 'ct_007', planId: 'pp_019', amount: 300000, payDate: '2026-07-06', payer: '杭州市政府后勤部', receiver: '清道夫', createTime: '2026-07-06T10:00:00' },
+  { id: 'pr_011', contractId: 'ct_008', planId: 'pp_021', amount: 6000, payDate: '2026-06-20', payer: '李四', receiver: '清道夫', createTime: '2026-06-20T10:00:00' }
+]
+
+export async function getContractList(params: ContractSearchParams = {}) {
+  await delay(300)
+  let list = [...mockContracts]
+  if (params.keyword) {
+    const kw = params.keyword.trim().toLowerCase()
+    list = list.filter(item =>
+      item.name.toLowerCase().includes(kw) ||
+      item.code.toLowerCase().includes(kw) ||
+      item.customerName.toLowerCase().includes(kw)
+    )
+  }
+  if (params.status) {
+    list = list.filter(item => item.status === params.status)
+  }
+  if (params.customerId) {
+    list = list.filter(item => item.customerId === params.customerId)
+  }
+  const pageNum = params.pageNum || 1
+  const pageSize = params.pageSize || 10
+  const total = list.length
+  const start = (pageNum - 1) * pageSize
+  const data = list.slice(start, start + pageSize)
+  return Promise.resolve({
+    list: data,
+    total,
+    pageNum,
+    pageSize,
+    pages: Math.ceil(total / pageSize)
+  })
+}
+
+export async function getContractDetail(id: string) {
+  await delay(200)
+  const item = mockContracts.find(c => c.id === id)
+  return Promise.resolve(item ? { ...item } : null)
+}
+
+export async function getContractStats() {
+  await delay(200)
+  const totalAmount = mockContracts.reduce((sum, c) => sum + c.amount, 0)
+  const active = mockContracts.filter(c => c.status === 'active').length
+  const pending = mockContracts.filter(c => {
+    const unpaid = c.paymentPlans.filter(p => p.status === 'unpaid' || p.status === 'overdue')
+    return unpaid.length > 0 && c.status !== 'completed'
+  }).length
+  const expiring = mockContracts.filter(c => c.status === 'expiring').length
+  return Promise.resolve({ totalAmount, active, pending, expiring })
+}
+
+export async function createContract(data: ContractFormData) {
+  await delay(400)
+  const newContract: Contract = {
+    ...data,
+    id: generateId('ct'),
+    customerName: '',
+    paidAmount: data.paymentPlans.reduce((sum, p) => sum + (p.actualAmount || 0), 0),
+    attachments: [],
+    createTime: new Date().toISOString(),
+    updateTime: new Date().toISOString()
+  }
+  mockContracts.unshift(newContract)
+  return Promise.resolve(newContract)
+}
+
+export async function updateContract(data: ContractFormData & { id: string }) {
+  await delay(400)
+  const index = mockContracts.findIndex(c => c.id === data.id)
+  if (index === -1) return Promise.reject(new Error('合同不存在'))
+  mockContracts[index] = {
+    ...mockContracts[index],
+    ...data,
+    paidAmount: data.paymentPlans.reduce((sum, p) => sum + (p.actualAmount || 0), 0),
+    updateTime: new Date().toISOString()
+  }
+  return Promise.resolve(mockContracts[index])
+}
+
+export async function deleteContract(id: string) {
+  await delay(300)
+  const index = mockContracts.findIndex(c => c.id === id)
+  if (index === -1) return Promise.reject(new Error('合同不存在'))
+  mockContracts.splice(index, 1)
+  return Promise.resolve(true)
+}
+
+export async function getPaymentPlans(contractId: string) {
+  await delay(200)
+  const contract = mockContracts.find(c => c.id === contractId)
+  return Promise.resolve(contract ? [...contract.paymentPlans] : [])
+}
+
+export async function markPayment(contractId: string, planId: string, data: { actualDate: string; actualAmount: number }) {
+  await delay(300)
+  const contract = mockContracts.find(c => c.id === contractId)
+  if (!contract) return Promise.reject(new Error('合同不存在'))
+  const plan = contract.paymentPlans.find(p => p.id === planId)
+  if (!plan) return Promise.reject(new Error('付款计划不存在'))
+  plan.actualDate = data.actualDate
+  plan.actualAmount = data.actualAmount
+  plan.status = data.actualAmount >= plan.amount ? 'paid' : data.actualAmount > 0 ? 'partpaid' : 'unpaid'
+  contract.paidAmount = contract.paymentPlans.reduce((sum, p) => sum + (p.actualAmount || 0), 0)
+  contract.updateTime = new Date().toISOString()
+  mockPaymentRecords.push({
+    id: generateId('pr'),
+    contractId,
+    planId,
+    amount: data.actualAmount,
+    payDate: data.actualDate,
+    createTime: new Date().toISOString()
+  })
+  return Promise.resolve(plan)
+}
+
+export async function getPaymentRecords(contractId: string) {
+  await delay(200)
+  return Promise.resolve(mockPaymentRecords.filter(r => r.contractId === contractId))
+}
+
+export function getContractProgress(contract: Contract): number {
+  if (!contract.amount) return 0
+  return Math.min(100, Math.round((contract.paidAmount / contract.amount) * 100))
+}

+ 396 - 0
src/api/customer.ts

@@ -0,0 +1,396 @@
+import type {
+  Customer,
+  CustomerFormData,
+  CustomerSearchParams,
+  FollowUpRecord
+} from '@/types/customer'
+import { delay, generateId } from '@/utils'
+
+const now = new Date().toISOString()
+
+const mockCustomers: Customer[] = [
+  {
+    id: 'cust_001',
+    name: '阿里巴巴科技有限公司',
+    type: 'enterprise',
+    level: 'A',
+    industry: '互联网',
+    scale: '10000人以上',
+    source: '官网咨询',
+    address: '浙江省杭州市余杭区文一西路969号',
+    status: 'active',
+    contacts: [
+      { id: 'c1', name: '陈总', phone: '13800138001', role: '采购总监', isPrimary: true },
+      { id: 'c2', name: '刘经理', phone: '13800138002', role: '行政经理', isPrimary: false }
+    ],
+    finance: {
+      bankName: '中国工商银行杭州分行',
+      bankAccount: '6222 0000 0000 0001',
+      taxNo: '91330100MA0000001A',
+      invoiceTitle: '阿里巴巴科技有限公司'
+    },
+    projectCount: 3,
+    contractAmount: 580000,
+    followUpCount: 5,
+    lastFollowUpTime: '2026-07-08T10:30:00',
+    createTime: '2025-03-15T09:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_002',
+    name: '张三',
+    type: 'personal',
+    level: 'C',
+    industry: '个体经营',
+    scale: '个人',
+    source: '朋友介绍',
+    address: '浙江省杭州市西湖区XX小区5幢1201',
+    status: 'active',
+    contacts: [
+      { id: 'c3', name: '张三', phone: '13900139001', role: '本人', isPrimary: true }
+    ],
+    projectCount: 1,
+    contractAmount: 3500,
+    followUpCount: 2,
+    lastFollowUpTime: '2026-07-05T16:00:00',
+    createTime: '2026-04-20T14:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_003',
+    name: '杭州市政府后勤部',
+    type: 'government',
+    level: 'A',
+    industry: '政府机关',
+    scale: '大型机构',
+    source: '招投标',
+    address: '浙江省杭州市上城区解放东路18号',
+    status: 'active',
+    contacts: [
+      { id: 'c4', name: '王主任', phone: '13700137001', role: '后勤主任', isPrimary: true },
+      { id: 'c5', name: '赵科长', phone: '13700137002', role: '采购科长', isPrimary: false }
+    ],
+    finance: {
+      bankName: '国库支付中心',
+      taxNo: '11330100000000001A',
+      invoiceTitle: '杭州市人民政府'
+    },
+    projectCount: 5,
+    contractAmount: 1200000,
+    followUpCount: 8,
+    lastFollowUpTime: '2026-07-09T11:20:00',
+    createTime: '2024-12-10T10:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_004',
+    name: '腾讯科技',
+    type: 'enterprise',
+    level: 'A',
+    industry: '互联网',
+    scale: '10000人以上',
+    source: '行业峰会',
+    address: '广东省深圳市南山区科技园',
+    status: 'active',
+    contacts: [
+      { id: 'c6', name: '马经理', phone: '13600136001', role: '行政负责人', isPrimary: true }
+    ],
+    finance: {
+      bankName: '招商银行深圳分行',
+      taxNo: '91440300MA0000002B',
+      invoiceTitle: '腾讯科技(深圳)有限公司'
+    },
+    projectCount: 2,
+    contractAmount: 360000,
+    followUpCount: 3,
+    lastFollowUpTime: '2026-07-07T09:45:00',
+    createTime: '2025-06-18T11:30:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_005',
+    name: '万科地产杭州分公司',
+    type: 'enterprise',
+    level: 'B',
+    industry: '房地产',
+    scale: '1000-9999人',
+    source: '老客户推荐',
+    address: '浙江省杭州市拱墅区莫干山路',
+    status: 'active',
+    contacts: [
+      { id: 'c7', name: '孙经理', phone: '13500135001', role: '物业经理', isPrimary: true },
+      { id: 'c8', name: '周主管', phone: '13500135002', role: '保洁主管', isPrimary: false }
+    ],
+    projectCount: 4,
+    contractAmount: 420000,
+    followUpCount: 4,
+    lastFollowUpTime: '2026-07-06T14:30:00',
+    createTime: '2025-01-08T09:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_006',
+    name: '李四',
+    type: 'personal',
+    level: 'B',
+    industry: '自由职业',
+    scale: '个人',
+    source: '地推活动',
+    address: '浙江省杭州市滨江区XX公寓',
+    status: 'active',
+    contacts: [
+      { id: 'c9', name: '李四', phone: '13400134001', role: '本人', isPrimary: true }
+    ],
+    projectCount: 1,
+    contractAmount: 6000,
+    followUpCount: 1,
+    lastFollowUpTime: '2026-06-28T10:00:00',
+    createTime: '2026-05-12T15:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_007',
+    name: '浙江大学后勤集团',
+    type: 'government',
+    level: 'A',
+    industry: '教育科研',
+    scale: '大型机构',
+    source: '招投标',
+    address: '浙江省杭州市西湖区余杭塘路148号',
+    status: 'active',
+    contacts: [
+      { id: 'c10', name: '吴主任', phone: '13300133001', role: '后勤主任', isPrimary: true },
+      { id: 'c11', name: '郑老师', phone: '13300133002', role: '采购经办', isPrimary: false }
+    ],
+    finance: {
+      bankName: '中国农业银行杭州浙大支行',
+      taxNo: '12330000470000001A',
+      invoiceTitle: '浙江大学'
+    },
+    projectCount: 6,
+    contractAmount: 780000,
+    followUpCount: 6,
+    lastFollowUpTime: '2026-07-09T15:10:00',
+    createTime: '2024-09-01T08:30:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_008',
+    name: '网易网络有限公司',
+    type: 'enterprise',
+    level: 'A',
+    industry: '互联网',
+    scale: '5000-9999人',
+    source: '官网咨询',
+    address: '浙江省杭州市滨江区网商路599号',
+    status: 'active',
+    contacts: [
+      { id: 'c12', name: '丁经理', phone: '13200132001', role: '行政经理', isPrimary: true }
+    ],
+    finance: {
+      bankName: '招商银行杭州分行',
+      taxNo: '91330100MA0000003C',
+      invoiceTitle: '网易(杭州)网络有限公司'
+    },
+    projectCount: 2,
+    contractAmount: 180000,
+    followUpCount: 3,
+    lastFollowUpTime: '2026-07-04T11:00:00',
+    createTime: '2025-08-25T10:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_009',
+    name: '绿城物业服务集团',
+    type: 'enterprise',
+    level: 'B',
+    industry: '物业服务',
+    scale: '1000-9999人',
+    source: '展会',
+    address: '浙江省杭州市西湖区杭大路1号',
+    status: 'active',
+    contacts: [
+      { id: 'c13', name: '钱总', phone: '13100131001', role: '运营总监', isPrimary: true },
+      { id: 'c14', name: '冯经理', phone: '13100131002', role: '项目经理', isPrimary: false }
+    ],
+    finance: {
+      bankName: '中国银行浙江省分行',
+      taxNo: '91330000720000001D',
+      invoiceTitle: '绿城物业服务集团有限公司'
+    },
+    projectCount: 4,
+    contractAmount: 560000,
+    followUpCount: 4,
+    lastFollowUpTime: '2026-07-03T16:40:00',
+    createTime: '2025-02-28T09:00:00',
+    updateTime: now
+  },
+  {
+    id: 'cust_010',
+    name: '王五',
+    type: 'personal',
+    level: 'D',
+    industry: '个体经营',
+    scale: '个人',
+    source: '网络广告',
+    address: '浙江省杭州市萧山区XX街道',
+    status: 'inactive',
+    contacts: [
+      { id: 'c15', name: '王五', phone: '13000130001', role: '本人', isPrimary: true }
+    ],
+    projectCount: 0,
+    contractAmount: 0,
+    followUpCount: 1,
+    lastFollowUpTime: '2026-05-20T09:00:00',
+    createTime: '2026-05-18T10:00:00',
+    updateTime: now
+  }
+]
+
+const mockFollowUps: FollowUpRecord[] = [
+  { id: 'fu_001', customerId: 'cust_001', type: 'phone', content: '沟通年度续约事宜,客户意向较高', intent: 'high', nextPlan: '发送报价单', nextTime: '2026-07-10T14:00:00', operatorName: '张销售', createTime: '2026-07-08T10:30:00' },
+  { id: 'fu_002', customerId: 'cust_001', type: 'visit', content: '拜访陈总,确认服务内容', intent: 'high', nextPlan: '安排合同签署', operatorName: '李销售', createTime: '2026-07-01T15:00:00' },
+  { id: 'fu_003', customerId: 'cust_002', type: 'wechat', content: '确认单次服务时间', intent: 'medium', nextPlan: '下单派工', operatorName: '张销售', createTime: '2026-07-05T16:00:00' },
+  { id: 'fu_004', customerId: 'cust_003', type: 'visit', content: '汇报半年服务情况,获得好评', intent: 'high', nextPlan: '准备续签方案', operatorName: '王销售', createTime: '2026-07-09T11:20:00' },
+  { id: 'fu_005', customerId: 'cust_004', type: 'email', content: '发送年度服务方案', intent: 'medium', nextPlan: '电话回访', operatorName: '张销售', createTime: '2026-07-07T09:45:00' },
+  { id: 'fu_006', customerId: 'cust_005', type: 'phone', content: '季度合同执行情况沟通', intent: 'high', nextPlan: '安排巡检', operatorName: '李销售', createTime: '2026-07-06T14:30:00' },
+  { id: 'fu_007', customerId: 'cust_006', type: 'wechat', content: '确认服务效果,推荐包年方案', intent: 'medium', nextPlan: '一周后回访', operatorName: '张销售', createTime: '2026-06-28T10:00:00' },
+  { id: 'fu_008', customerId: 'cust_007', type: 'visit', content: '暑期清洁保障方案沟通', intent: 'high', nextPlan: '提交标书', operatorName: '王销售', createTime: '2026-07-09T15:10:00' },
+  { id: 'fu_009', customerId: 'cust_008', type: 'phone', content: '月度保洁服务续费提醒', intent: 'high', nextPlan: '确认付款', operatorName: '张销售', createTime: '2026-07-04T11:00:00' },
+  { id: 'fu_010', customerId: 'cust_009', type: 'visit', content: '新项目合作洽谈', intent: 'medium', nextPlan: '提交方案', operatorName: '李销售', createTime: '2026-07-03T16:40:00' },
+  { id: 'fu_011', customerId: 'cust_010', type: 'phone', content: '了解服务需求,客户暂无明确意向', intent: 'low', nextPlan: '两周后回访', operatorName: '张销售', createTime: '2026-05-20T09:00:00' }
+]
+
+export async function getCustomerList(params: CustomerSearchParams = {}) {
+  await delay(300)
+  let list = [...mockCustomers]
+  if (params.keyword) {
+    const kw = params.keyword.trim().toLowerCase()
+    list = list.filter(item =>
+      item.name.toLowerCase().includes(kw) ||
+      item.contacts.some(c => c.name.includes(kw) || c.phone.includes(kw))
+    )
+  }
+  if (params.type) {
+    list = list.filter(item => item.type === params.type)
+  }
+  if (params.level) {
+    list = list.filter(item => item.level === params.level)
+  }
+  if (params.status) {
+    list = list.filter(item => item.status === params.status)
+  }
+  const pageNum = params.pageNum || 1
+  const pageSize = params.pageSize || 10
+  const total = list.length
+  const start = (pageNum - 1) * pageSize
+  const data = list.slice(start, start + pageSize)
+  return Promise.resolve({
+    list: data,
+    total,
+    pageNum,
+    pageSize,
+    pages: Math.ceil(total / pageSize)
+  })
+}
+
+export async function getCustomerDetail(id: string) {
+  await delay(200)
+  const item = mockCustomers.find(c => c.id === id)
+  return Promise.resolve(item ? { ...item } : null)
+}
+
+export async function getCustomerStats() {
+  await delay(200)
+  const total = mockCustomers.length
+  const thisMonth = mockCustomers.filter(c => c.createTime.startsWith('2026-07')).length
+  const key = mockCustomers.filter(c => c.level === 'A').length
+  const pending = mockCustomers.filter(c => {
+    if (!c.lastFollowUpTime) return true
+    const days = (Date.now() - new Date(c.lastFollowUpTime).getTime()) / (1000 * 60 * 60 * 24)
+    return days > 7
+  }).length
+  return Promise.resolve({ total, thisMonth, key, pending })
+}
+
+export async function createCustomer(data: CustomerFormData) {
+  await delay(400)
+  const newCustomer: Customer = {
+    ...data,
+    id: generateId('cust'),
+    projectCount: 0,
+    contractAmount: 0,
+    followUpCount: 0,
+    createTime: new Date().toISOString(),
+    updateTime: new Date().toISOString()
+  }
+  mockCustomers.unshift(newCustomer)
+  return Promise.resolve(newCustomer)
+}
+
+export async function updateCustomer(data: CustomerFormData & { id: string }) {
+  await delay(400)
+  const index = mockCustomers.findIndex(c => c.id === data.id)
+  if (index === -1) return Promise.reject(new Error('客户不存在'))
+  mockCustomers[index] = {
+    ...mockCustomers[index],
+    ...data,
+    updateTime: new Date().toISOString()
+  }
+  return Promise.resolve(mockCustomers[index])
+}
+
+export async function deleteCustomer(id: string) {
+  await delay(300)
+  const index = mockCustomers.findIndex(c => c.id === id)
+  if (index === -1) return Promise.reject(new Error('客户不存在'))
+  mockCustomers.splice(index, 1)
+  return Promise.resolve(true)
+}
+
+export async function getFollowUpList(customerId?: string) {
+  await delay(200)
+  let list = [...mockFollowUps]
+  if (customerId) {
+    list = list.filter(f => f.customerId === customerId)
+  }
+  list.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
+  return Promise.resolve(list)
+}
+
+export async function createFollowUp(data: Omit<FollowUpRecord, 'id' | 'createTime'>) {
+  await delay(300)
+  const record: FollowUpRecord = {
+    ...data,
+    id: generateId('fu'),
+    createTime: new Date().toISOString()
+  }
+  mockFollowUps.unshift(record)
+  const customer = mockCustomers.find(c => c.id === data.customerId)
+  if (customer) {
+    customer.followUpCount += 1
+    customer.lastFollowUpTime = record.createTime
+    customer.updateTime = record.createTime
+  }
+  return Promise.resolve(record)
+}
+
+export async function deleteFollowUp(id: string) {
+  await delay(200)
+  const index = mockFollowUps.findIndex(f => f.id === id)
+  if (index === -1) return Promise.reject(new Error('跟进记录不存在'))
+  const record = mockFollowUps[index]
+  mockFollowUps.splice(index, 1)
+  const customer = mockCustomers.find(c => c.id === record.customerId)
+  if (customer && customer.followUpCount > 0) {
+    customer.followUpCount -= 1
+  }
+  return Promise.resolve(true)
+}
+
+export async function getCustomerOptions() {
+  await delay(150)
+  return Promise.resolve(
+    mockCustomers.map(c => ({ label: c.name, value: c.id, type: c.type }))
+  )
+}

+ 140 - 0
src/components/common/ConstructionFlowBar.vue

@@ -0,0 +1,140 @@
+<template>
+  <view class="construction-flow-bar">
+    <view class="flow-line">
+      <view class="flow-line-fill" :style="{ width: progressWidth + '%' }" />
+    </view>
+    <view
+      v-for="(step, index) in steps"
+      :key="index"
+      class="flow-step"
+      :class="{
+        completed: index < currentStep,
+        active: index === currentStep
+      }"
+    >
+      <view class="step-dot">
+        <text v-if="index < currentStep" class="uni-icons uniui-checkmarkempty text-white text-xs"></text>
+        <text v-else class="text-xs">{{ index + 1 }}</text>
+      </view>
+      <text class="step-name">{{ step }}</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+interface Props {
+  steps: string[]
+  currentStep: number
+}
+
+const props = defineProps<Props>()
+
+const progressWidth = computed(() => {
+  if (props.steps.length <= 1) return 0
+  return (props.currentStep / (props.steps.length - 1)) * 100
+})
+</script>
+
+<style scoped>
+.construction-flow-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 10px;
+  background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
+  border-radius: 12px;
+  margin-bottom: 16px;
+  position: relative;
+  overflow: hidden;
+}
+
+.construction-flow-bar::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: repeating-linear-gradient(
+    90deg,
+    transparent,
+    transparent 10px,
+    rgba(16, 185, 129, 0.03) 10px,
+    rgba(16, 185, 129, 0.03) 20px
+  );
+}
+
+.flow-line {
+  position: absolute;
+  top: 30px;
+  left: 10%;
+  right: 10%;
+  height: 2px;
+  background: #e5e7eb;
+  z-index: 0;
+}
+
+.flow-line-fill {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 100%;
+  background: #10b981;
+  transition: width 0.5s ease;
+}
+
+.flow-step {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  z-index: 1;
+  flex: 1;
+}
+
+.step-dot {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  margin-bottom: 6px;
+  transition: all 0.3s;
+  background: #e5e7eb;
+  color: #9ca3af;
+}
+
+.flow-step.completed .step-dot {
+  background: #10b981;
+  color: white;
+}
+
+.flow-step.active .step-dot {
+  background: #059669;
+  color: white;
+  box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
+  animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.1); }
+}
+
+.step-name {
+  font-size: 11px;
+  color: #9ca3af;
+  text-align: center;
+  white-space: nowrap;
+}
+
+.flow-step.completed .step-name,
+.flow-step.active .step-name {
+  color: #059669;
+  font-weight: 500;
+}
+</style>

+ 46 - 0
src/components/common/GradientStatCard.vue

@@ -0,0 +1,46 @@
+<template>
+  <view
+    class="gradient-stat-card flex flex-col items-center justify-center p-4 rounded-xl text-white"
+    :style="{ background: gradient }"
+    @click="onClick"
+  >
+    <text class="stat-number text-white block">{{ value }}</text>
+    <text class="text-white mt-1 block text-xs" style="opacity: 0.8;">{{ label }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  value: string | number
+  label: string
+  gradient: string
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  (e: 'click'): void
+}>()
+
+function onClick() {
+  emit('click')
+}
+</script>
+
+<style scoped>
+.gradient-stat-card {
+  transition: transform 0.15s ease, opacity 0.15s ease;
+  min-height: 90px;
+}
+
+.gradient-stat-card:active {
+  transform: scale(0.98);
+  opacity: 0.9;
+}
+
+.stat-number {
+  font-size: 24px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+</style>

+ 104 - 0
src/components/common/PhotoUploader.vue

@@ -0,0 +1,104 @@
+<template>
+  <view class="photo-grid">
+    <view
+      v-for="(photo, index) in photos"
+      :key="index"
+      class="photo-item"
+      :class="{ uploaded: photo.url }"
+      @click="onPreview(index)"
+    >
+      <image v-if="photo.url" :src="photo.url" mode="aspectFill" class="photo-image" />
+      <view v-else class="flex flex-col items-center justify-center">
+        <text class="uni-icons uniui-camera-filled text-2xl text-gray-400"></text>
+        <text class="text-xs text-gray-400 mt-1">{{ photo.label || '拍照' }}</text>
+      </view>
+      <view
+        v-if="photo.url"
+        class="delete-photo"
+        @click.stop="onDelete(index)"
+      >
+        <text class="uni-icons uniui-closeempty text-white text-xs"></text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+export interface PhotoItem {
+  url?: string
+  label?: string
+}
+
+interface Props {
+  photos: PhotoItem[]
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  (e: 'preview', index: number): void
+  (e: 'delete', index: number): void
+}>()
+
+function onPreview(index: number) {
+  if (!props.photos[index].url) return
+  emit('preview', index)
+}
+
+function onDelete(index: number) {
+  emit('delete', index)
+}
+</script>
+
+<style scoped>
+.photo-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.photo-item {
+  aspect-ratio: 1;
+  background: #f9fafb;
+  border-radius: 8px;
+  border: 2px dashed #d1d5db;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  position: relative;
+  transition: all 0.2s;
+}
+
+.photo-item:active {
+  transform: scale(0.95);
+  border-color: #10b981;
+  background: #f0fdf4;
+}
+
+.photo-item.uploaded {
+  border-style: solid;
+  border-color: #10b981;
+  background: #f0fdf4;
+}
+
+.photo-image {
+  width: 100%;
+  height: 100%;
+  border-radius: 6px;
+}
+
+.delete-photo {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  width: 20px;
+  height: 20px;
+  background: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 116 - 0
src/components/common/ProgressBar.vue

@@ -0,0 +1,116 @@
+<template>
+  <view class="progress-container">
+    <view class="progress-track">
+      <view class="progress-steps">
+        <view
+          v-for="(step, index) in steps"
+          :key="index"
+          class="step-dot"
+          :class="{
+            completed: index < currentStep,
+            active: index === currentStep
+          }"
+        >
+          <text v-if="index < currentStep" class="uni-icons uniui-checkmarkempty text-white text-xs"></text>
+          <text v-else class="text-xs">{{ index + 1 }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="flex justify-between mt-2">
+      <text
+        v-for="(step, index) in steps"
+        :key="index"
+        class="step-name"
+        :class="{
+          'text-primary': index <= currentStep,
+          'text-gray-400': index > currentStep
+        }"
+      >
+        {{ step }}
+      </text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  steps: string[]
+  currentStep: number
+}
+
+defineProps<Props>()
+</script>
+
+<style scoped>
+.progress-container {
+  padding: 0 8px;
+}
+
+.progress-track {
+  position: relative;
+  padding: 0 10px;
+}
+
+.progress-track::before {
+  content: '';
+  position: absolute;
+  top: 14px;
+  left: 24px;
+  right: 24px;
+  height: 3px;
+  background: #e5e7eb;
+  border-radius: 2px;
+}
+
+.progress-steps {
+  display: flex;
+  justify-content: space-between;
+  position: relative;
+  z-index: 1;
+}
+
+.step-dot {
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  background: #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  color: #9ca3af;
+  transition: all 0.3s;
+}
+
+.step-dot.completed {
+  background: #3b82f6;
+  color: white;
+}
+
+.step-dot.active {
+  background: #059669;
+  color: white;
+  box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
+  animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.1); }
+}
+
+.step-name {
+  font-size: 11px;
+  text-align: center;
+  white-space: nowrap;
+}
+
+.text-primary {
+  color: #059669;
+  font-weight: 500;
+}
+
+.text-gray-400 {
+  color: #9ca3af;
+}
+</style>

+ 60 - 0
src/components/common/QuickEntry.vue

@@ -0,0 +1,60 @@
+<template>
+  <view
+    class="quick-entry flex flex-col items-center py-4 px-2 rounded-xl"
+    :class="{ disabled: props.disabled }"
+    :style="{ backgroundColor: props.disabled ? '#f3f4f6' : '#eff6ff' }"
+    @click="onClick"
+  >
+    <view
+      class="w-12 h-12 rounded-full flex items-center justify-center mb-2"
+      :style="{ backgroundColor: props.disabled ? '#e5e7eb' : '#dbeafe' }"
+    >
+      <text
+        class="uni-icons text-xl"
+        :class="props.icon"
+        :style="{ color: props.disabled ? '#9ca3af' : '#2563eb' }"
+      ></text>
+    </view>
+    <text
+      class="text-xs text-center font-medium"
+      :style="{ color: props.disabled ? '#9ca3af' : '#2563eb' }"
+    >
+      {{ label }}
+    </text>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  label: string
+  icon: string
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false
+})
+
+const emit = defineEmits<{
+  (e: 'click'): void
+}>()
+
+function onClick() {
+  if (props.disabled) return
+  emit('click')
+}
+</script>
+
+<style scoped>
+.quick-entry {
+  transition: transform 0.15s ease;
+}
+
+.quick-entry:not(.disabled):active {
+  transform: scale(0.95);
+}
+
+.quick-entry.disabled {
+  opacity: 0.5;
+}
+</style>

+ 150 - 0
src/components/common/Timeline.vue

@@ -0,0 +1,150 @@
+<template>
+  <view class="timeline">
+    <view
+      v-for="(record, index) in records"
+      :key="index"
+      class="timeline-record"
+      :class="{ current: record.isCurrent }"
+    >
+      <view
+        class="timeline-avatar"
+        :class="record.role"
+      >
+        <text class="uni-icons text-white text-sm" :class="getRoleIcon(record.role)"></text>
+      </view>
+      <view class="timeline-content">
+        <view class="flex justify-between items-start">
+          <text class="timeline-action">{{ record.action }}</text>
+          <text class="timeline-time">{{ record.time }}</text>
+        </view>
+        <text v-if="record.operator" class="timeline-operator">{{ record.operator }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+export interface TimelineRecord {
+  action: string
+  time: string
+  operator?: string
+  role: 'sales' | 'dispatch' | 'worker' | 'system'
+  isCurrent?: boolean
+}
+
+interface Props {
+  records: TimelineRecord[]
+}
+
+defineProps<Props>()
+
+function getRoleIcon(role: string) {
+  const map: Record<string, string> = {
+    sales: 'uniui-person-filled',
+    dispatch: 'uniui-settings-filled',
+    worker: 'uniui-gear-filled',
+    system: 'uniui-info-filled'
+  }
+  return map[role] || 'uniui-person-filled'
+}
+</script>
+
+<style scoped>
+.timeline {
+  padding: 0 4px;
+}
+
+.timeline-record {
+  display: flex;
+  padding: 12px 0;
+  position: relative;
+}
+
+.timeline-record:not(:last-child)::after {
+  content: '';
+  position: absolute;
+  left: 18px;
+  top: 44px;
+  bottom: 0;
+  width: 2px;
+  background: #e5e7eb;
+}
+
+.timeline-record:last-child::after {
+  display: none;
+}
+
+.timeline-avatar {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12px;
+  flex-shrink: 0;
+}
+
+.timeline-avatar.sales {
+  background: #3b82f6;
+}
+
+.timeline-avatar.dispatch {
+  background: #f97316;
+}
+
+.timeline-avatar.worker {
+  background: #10b981;
+}
+
+.timeline-avatar.system {
+  background: #6b7280;
+}
+
+.timeline-record.current {
+  background: #eff6ff;
+  border-radius: 8px;
+  margin: 0 4px;
+  padding: 12px 8px;
+  border-left: 3px solid #3b82f6;
+}
+
+.timeline-record.current::after {
+  display: none;
+}
+
+.timeline-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.timeline-time {
+  font-size: 12px;
+  color: #999;
+  flex-shrink: 0;
+  margin-left: 8px;
+}
+
+.timeline-action {
+  font-size: 14px;
+  color: #333;
+  line-height: 1.5;
+  word-break: break-all;
+}
+
+.timeline-operator {
+  font-size: 12px;
+  color: #666;
+  margin-top: 4px;
+}
+
+.timeline-record.current .timeline-time {
+  color: #3b82f6;
+  font-weight: 500;
+}
+
+.timeline-record.current .timeline-action {
+  color: #1e40af;
+  font-weight: 500;
+}
+</style>

+ 275 - 0
src/pages/contract/contract.vue

@@ -0,0 +1,275 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar title="合同管理" :gradient="true" />
+
+    <!-- 统计卡片 -->
+    <view class="px-4 pt-4">
+      <view class="grid grid-cols-4 gap-2">
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-blue-600">{{ stats.totalAmountLabel }}</text>
+          <text class="text-xs text-gray-500 block mt-1">合同总额</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-green-600">{{ stats.active }}</text>
+          <text class="text-xs text-gray-500 block mt-1">执行中</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-orange-600">{{ stats.pending }}</text>
+          <text class="text-xs text-gray-500 block mt-1">待收款</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-red-600">{{ stats.expiring }}</text>
+          <text class="text-xs text-gray-500 block mt-1">即将到期</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 搜索栏 -->
+    <view class="px-4 py-3">
+      <view class="bg-white rounded-xl flex items-center px-3 py-2 shadow-sm">
+        <text class="uni-icons uniui-search text-gray-400 mr-2"></text>
+        <input
+          v-model="searchKey"
+          class="flex-1 text-sm"
+          type="text"
+          placeholder="搜索合同名称/编号/客户"
+          confirm-type="search"
+          @confirm="onSearch"
+        />
+        <text v-if="searchKey" class="uni-icons uniui-close text-gray-400 p-1" @click="clearSearch"></text>
+      </view>
+    </view>
+
+    <!-- 状态筛选 -->
+    <view class="px-4 pb-2">
+      <scroll-view scroll-x class="hide-scrollbar">
+        <view class="flex items-center">
+          <text
+            v-for="tab in statusTabs"
+            :key="tab.value"
+            class="filter-tab mr-2"
+            :class="{ active: currentStatus === tab.value }"
+            @click="changeStatus(tab.value)"
+          >
+            {{ tab.label }}
+          </text>
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 合同列表 -->
+    <scroll-view scroll-y class="px-4" style="height: calc(100vh - 280px);" @scrolltolower="loadMore">
+      <view v-if="loading && list.length === 0" class="py-20 text-center">
+        <text class="text-gray-400">加载中...</text>
+      </view>
+      <view v-else-if="list.length === 0" class="py-10">
+        <EmptyState message="暂无合同" />
+      </view>
+      <view v-else class="gap-y-3">
+        <view
+          v-for="item in list"
+          :key="item.id"
+          class="card p-4 clickable"
+          @click="goDetail(item.id)"
+        >
+          <view class="flex items-start justify-between mb-3">
+            <view class="min-w-0 flex-1">
+              <text class="text-base font-semibold text-gray-800 truncate block">{{ item.name }}</text>
+              <text class="text-xs text-gray-400 mt-1 block">{{ item.code }}</text>
+            </view>
+            <text class="tag flex-shrink-0 ml-2" :class="statusClass(item.status)">{{ statusText(item.status) }}</text>
+          </view>
+
+          <view class="flex items-center py-2 border-t border-gray-100">
+            <text class="uni-icons uniui-person-filled text-gray-400 mr-1 text-sm"></text>
+            <text class="text-sm text-gray-600">{{ item.customerName }}</text>
+          </view>
+
+          <view class="flex items-center justify-between py-2 border-t border-gray-100">
+            <text class="text-sm text-gray-500">合同金额</text>
+            <text class="text-base font-bold text-primary">¥{{ item.amount.toLocaleString() }}</text>
+          </view>
+
+          <view class="flex items-center justify-between py-2 border-t border-gray-100">
+            <text class="text-sm text-gray-500">服务周期</text>
+            <text class="text-sm text-gray-600">{{ item.startDate }} ~ {{ item.endDate }}</text>
+          </view>
+
+          <view class="mt-3">
+            <view class="flex items-center justify-between mb-1">
+              <text class="text-xs text-gray-500">付款进度</text>
+              <text class="text-xs font-medium text-primary">{{ progress(item) }}%</text>
+            </view>
+            <view class="progress-bar-track">
+              <view class="progress-bar-fill" :style="{ width: `${progress(item)}%` }"></view>
+            </view>
+          </view>
+        </view>
+
+        <view v-if="loadingMore" class="py-4 text-center">
+          <text class="text-xs text-gray-400">加载更多...</text>
+        </view>
+        <view v-else-if="!hasMore" class="py-4 text-center">
+          <text class="text-xs text-gray-400">没有更多了</text>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 悬浮新增按钮 -->
+    <view class="fixed right-5 bottom-24 z-30">
+      <view class="w-14 h-14 rounded-full bg-primary shadow-lg flex items-center justify-center clickable" @click="goAdd">
+        <text class="uni-icons uniui-plusempty text-white text-2xl"></text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { getContractList, getContractStats } from '@/api/contract'
+import type { Contract, ContractStatus } from '@/types/contract'
+
+const list = ref<Contract[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const pageNum = ref(1)
+const pageSize = 10
+const hasMore = ref(true)
+const searchKey = ref('')
+const currentStatus = ref<ContractStatus | ''>('')
+
+const stats = ref({ totalAmount: 0, active: 0, pending: 0, expiring: 0, totalAmountLabel: '0' })
+
+const statusTabs = [
+  { label: '全部', value: '' },
+  { label: '待签订', value: 'pending' },
+  { label: '执行中', value: 'active' },
+  { label: '即将到期', value: 'expiring' },
+  { label: '已完成', value: 'completed' }
+]
+
+function statusClass(status: ContractStatus) {
+  const map: Record<ContractStatus, string> = {
+    pending: 'tag-warning',
+    active: 'tag-success',
+    expiring: 'tag-warning',
+    completed: 'tag-gray',
+    terminated: 'tag-danger'
+  }
+  return map[status] || 'tag-gray'
+}
+
+function statusText(status: ContractStatus) {
+  const map: Record<ContractStatus, string> = {
+    pending: '待签订',
+    active: '执行中',
+    expiring: '即将到期',
+    completed: '已完成',
+    terminated: '已终止'
+  }
+  return map[status] || status
+}
+
+function progress(item: Contract) {
+  if (!item.amount) return 0
+  return Math.min(100, Math.round((item.paidAmount / item.amount) * 100))
+}
+
+async function fetchStats() {
+  const res = await getContractStats()
+  stats.value = {
+    ...res,
+    totalAmountLabel: formatAmount(res.totalAmount)
+  }
+}
+
+function formatAmount(amount: number) {
+  if (amount >= 10000) {
+    return `${(amount / 10000).toFixed(1)}万`
+  }
+  return amount.toLocaleString()
+}
+
+async function fetchList(reset = false) {
+  if (reset) {
+    pageNum.value = 1
+    hasMore.value = true
+  }
+  if (pageNum.value === 1) {
+    loading.value = true
+  }
+  try {
+    const res = await getContractList({
+      keyword: searchKey.value,
+      status: currentStatus.value || undefined,
+      pageNum: pageNum.value,
+      pageSize
+    })
+    if (reset) {
+      list.value = res.list
+    } else {
+      list.value.push(...res.list)
+    }
+    hasMore.value = res.list.length === pageSize && list.value.length < res.total
+  } finally {
+    loading.value = false
+    loadingMore.value = false
+  }
+}
+
+function onSearch() {
+  fetchList(true)
+}
+
+function clearSearch() {
+  searchKey.value = ''
+  fetchList(true)
+}
+
+function changeStatus(status: string) {
+  currentStatus.value = status as ContractStatus | ''
+  fetchList(true)
+}
+
+function loadMore() {
+  if (!hasMore.value || loadingMore.value) return
+  loadingMore.value = true
+  pageNum.value += 1
+  fetchList()
+}
+
+function goDetail(id: string) {
+  uni.navigateTo({ url: `/subPackages/pages-contract/contractDetail?id=${id}` })
+}
+
+function goAdd() {
+  uni.navigateTo({ url: '/subPackages/pages-contract/contractForm' })
+}
+
+onMounted(() => {
+  fetchStats()
+  fetchList(true)
+})
+
+onShow(() => {
+  fetchStats()
+  fetchList(true)
+})
+
+onPullDownRefresh(async () => {
+  await fetchStats()
+  await fetchList(true)
+  uni.stopPullDownRefresh()
+})
+</script>
+
+<style scoped>
+.stat-card {
+  background: #fff;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+</style>

+ 293 - 0
src/pages/customer/customer.vue

@@ -0,0 +1,293 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar title="客户管理" :gradient="true" />
+
+    <!-- 统计卡片 -->
+    <view class="px-4 pt-4">
+      <view class="grid grid-cols-4 gap-2">
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-blue-600">{{ stats.total }}</text>
+          <text class="text-xs text-gray-500 block mt-1">总客户</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-green-600">{{ stats.thisMonth }}</text>
+          <text class="text-xs text-gray-500 block mt-1">本月新增</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-orange-600">{{ stats.key }}</text>
+          <text class="text-xs text-gray-500 block mt-1">重点客户</text>
+        </view>
+        <view class="stat-card text-center p-3">
+          <text class="stat-number text-red-600">{{ stats.pending }}</text>
+          <text class="text-xs text-gray-500 block mt-1">待跟进</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 搜索栏 -->
+    <view class="px-4 py-3">
+      <view class="bg-white rounded-xl flex items-center px-3 py-2 shadow-sm">
+        <text class="uni-icons uniui-search text-gray-400 mr-2"></text>
+        <input
+          v-model="searchKey"
+          class="flex-1 text-sm"
+          type="text"
+          placeholder="搜索客户名称/联系人/手机号"
+          confirm-type="search"
+          @confirm="onSearch"
+        />
+        <text v-if="searchKey" class="uni-icons uniui-close text-gray-400 p-1" @click="clearSearch"></text>
+      </view>
+    </view>
+
+    <!-- 筛选标签 -->
+    <view class="px-4 pb-2">
+      <scroll-view scroll-x class="hide-scrollbar">
+        <view class="flex items-center">
+          <text
+            v-for="tab in typeTabs"
+            :key="tab.value"
+            class="filter-tab mr-2"
+            :class="{ active: currentType === tab.value }"
+            @click="changeType(tab.value)"
+          >
+            {{ tab.label }}
+          </text>
+          <text class="text-gray-300 mx-1">|</text>
+          <text
+            v-for="tab in levelTabs"
+            :key="tab.value"
+            class="filter-tab mx-2"
+            :class="{ active: currentLevel === tab.value }"
+            @click="changeLevel(tab.value)"
+          >
+            {{ tab.label }}
+          </text>
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 客户列表 -->
+    <scroll-view scroll-y class="px-4" style="height: calc(100vh - 280px);" @scrolltolower="loadMore">
+      <view v-if="loading && list.length === 0" class="py-20 text-center">
+        <text class="text-gray-400">加载中...</text>
+      </view>
+      <view v-else-if="list.length === 0" class="py-10">
+        <EmptyState message="暂无客户" />
+      </view>
+      <view v-else class="gap-y-3">
+        <view
+          v-for="item in list"
+          :key="item.id"
+          class="card p-4 clickable"
+          @click="goDetail(item.id)"
+        >
+          <view class="flex items-start justify-between mb-3">
+            <view class="flex items-center min-w-0">
+              <view class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3 flex-shrink-0">
+                <text class="uni-icons uniui-person-filled text-primary"></text>
+              </view>
+              <view class="min-w-0">
+                <text class="text-base font-semibold text-gray-800 truncate block">{{ item.name }}</text>
+                <text class="text-xs text-gray-400 mt-1 block">{{ formatDate(item.createTime, 'YYYY-MM-DD') }} 录入</text>
+              </view>
+            </view>
+            <view class="flex flex-col items-end flex-shrink-0 ml-2">
+              <text class="tag" :class="levelClass(item.level)">{{ item.level }}级</text>
+              <text class="tag mt-1" :class="typeClass(item.type)">{{ typeText(item.type) }}</text>
+            </view>
+          </view>
+
+          <view class="flex items-center justify-between py-2 border-t border-gray-100">
+            <view class="flex items-center">
+              <text class="uni-icons uniui-person text-gray-400 mr-1 text-sm"></text>
+              <text class="text-sm text-gray-600">{{ primaryContact(item)?.name || '-' }}</text>
+            </view>
+            <text class="text-sm text-gray-600">{{ primaryContact(item)?.phone || '-' }}</text>
+          </view>
+
+          <view class="flex items-center justify-between py-2 border-t border-gray-100">
+            <view class="flex items-center">
+              <text class="uni-icons uniui-folder-add-filled text-gray-400 mr-1 text-sm"></text>
+              <text class="text-sm text-gray-600">关联项目 {{ item.projectCount }} 个</text>
+            </view>
+            <text class="text-sm font-semibold text-primary">¥{{ item.contractAmount.toLocaleString() }}</text>
+          </view>
+        </view>
+
+        <view v-if="loadingMore" class="py-4 text-center">
+          <text class="text-xs text-gray-400">加载更多...</text>
+        </view>
+        <view v-else-if="!hasMore" class="py-4 text-center">
+          <text class="text-xs text-gray-400">没有更多了</text>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 悬浮新增按钮 -->
+    <view class="fixed right-5 bottom-24 z-30">
+      <view class="w-14 h-14 rounded-full bg-primary shadow-lg flex items-center justify-center clickable" @click="goAdd">
+        <text class="uni-icons uniui-plusempty text-white text-2xl"></text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { formatDate } from '@/utils'
+import { getCustomerList, getCustomerStats } from '@/api/customer'
+import type { Customer, CustomerType, CustomerLevel } from '@/types/customer'
+
+const list = ref<Customer[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const pageNum = ref(1)
+const pageSize = 10
+const hasMore = ref(true)
+const searchKey = ref('')
+const currentType = ref<CustomerType | ''>('')
+const currentLevel = ref<CustomerLevel | ''>('')
+
+const stats = ref({ total: 0, thisMonth: 0, key: 0, pending: 0 })
+
+const typeTabs = [
+  { label: '全部', value: '' },
+  { label: '企业', value: 'enterprise' },
+  { label: '个人', value: 'personal' },
+  { label: '政府', value: 'government' }
+]
+
+const levelTabs = [
+  { label: '全部等级', value: '' },
+  { label: 'A级', value: 'A' },
+  { label: 'B级', value: 'B' },
+  { label: 'C级', value: 'C' },
+  { label: 'D级', value: 'D' }
+]
+
+function levelClass(level: CustomerLevel) {
+  const map: Record<string, string> = {
+    A: 'tag-primary',
+    B: 'tag-success',
+    C: 'tag-warning',
+    D: 'tag-gray'
+  }
+  return map[level] || 'tag-gray'
+}
+
+function typeClass(type: CustomerType) {
+  const map: Record<string, string> = {
+    enterprise: 'tag-success',
+    personal: 'tag-warning',
+    government: 'tag-primary'
+  }
+  return map[type] || 'tag-gray'
+}
+
+function typeText(type: CustomerType) {
+  const map: Record<string, string> = {
+    enterprise: '企业',
+    personal: '个人',
+    government: '政府'
+  }
+  return map[type] || type
+}
+
+function primaryContact(item: Customer) {
+  return item.contacts.find(c => c.isPrimary) || item.contacts[0]
+}
+
+async function fetchStats() {
+  stats.value = await getCustomerStats()
+}
+
+async function fetchList(reset = false) {
+  if (reset) {
+    pageNum.value = 1
+    hasMore.value = true
+  }
+  if (pageNum.value === 1) {
+    loading.value = true
+  }
+  try {
+    const res = await getCustomerList({
+      keyword: searchKey.value,
+      type: currentType.value || undefined,
+      level: currentLevel.value || undefined,
+      pageNum: pageNum.value,
+      pageSize
+    })
+    if (reset) {
+      list.value = res.list
+    } else {
+      list.value.push(...res.list)
+    }
+    hasMore.value = res.list.length === pageSize && list.value.length < res.total
+  } finally {
+    loading.value = false
+    loadingMore.value = false
+  }
+}
+
+function onSearch() {
+  fetchList(true)
+}
+
+function clearSearch() {
+  searchKey.value = ''
+  fetchList(true)
+}
+
+function changeType(type: string) {
+  currentType.value = type as CustomerType | ''
+  fetchList(true)
+}
+
+function changeLevel(level: string) {
+  currentLevel.value = level as CustomerLevel | ''
+  fetchList(true)
+}
+
+function loadMore() {
+  if (!hasMore.value || loadingMore.value) return
+  loadingMore.value = true
+  pageNum.value += 1
+  fetchList()
+}
+
+function goDetail(id: string) {
+  uni.navigateTo({ url: `/subPackages/pages-customer/customerDetail?id=${id}` })
+}
+
+function goAdd() {
+  uni.navigateTo({ url: '/subPackages/pages-customer/customerForm' })
+}
+
+onMounted(() => {
+  fetchStats()
+  fetchList(true)
+})
+
+onShow(() => {
+  fetchStats()
+  fetchList(true)
+})
+
+onPullDownRefresh(async () => {
+  await fetchStats()
+  await fetchList(true)
+  uni.stopPullDownRefresh()
+})
+</script>
+
+<style scoped>
+.stat-card {
+  background: #fff;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+</style>

+ 356 - 0
src/subPackages/pages-contract/contractDetail.vue

@@ -0,0 +1,356 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar title="合同详情" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else-if="!contract" class="px-4 py-6">
+      <EmptyState message="合同不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4">
+      <!-- 合同信息头部 -->
+      <view class="card p-4">
+        <view class="flex items-start justify-between mb-3">
+          <view class="min-w-0 flex-1">
+            <text class="text-lg font-bold text-gray-800 truncate block">{{ contract.name }}</text>
+            <text class="text-xs text-gray-400 mt-1 block">{{ contract.code }}</text>
+          </view>
+          <text class="tag flex-shrink-0 ml-2" :class="statusClass">{{ statusText }}</text>
+        </view>
+
+        <view class="grid grid-cols-2 gap-4 mt-4">
+          <view class="text-center p-3 bg-blue-50 rounded-xl">
+            <text class="text-xl font-bold text-primary">¥{{ contract.amount.toLocaleString() }}</text>
+            <text class="text-xs text-gray-500 block mt-1">合同金额</text>
+          </view>
+          <view class="text-center p-3 bg-green-50 rounded-xl">
+            <text class="text-xl font-bold text-green-600">¥{{ contract.paidAmount.toLocaleString() }}</text>
+            <text class="text-xs text-gray-500 block mt-1">已付款</text>
+          </view>
+        </view>
+
+        <view class="mt-4">
+          <view class="flex items-center justify-between mb-1">
+            <text class="text-xs text-gray-500">付款进度 {{ progress }}%</text>
+            <text class="text-xs text-gray-500">待付 ¥{{ (contract.amount - contract.paidAmount).toLocaleString() }}</text>
+          </view>
+          <view class="progress-bar-track">
+            <view class="progress-bar-fill" :style="{ width: `${progress}%` }"></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- Tab 切换 -->
+      <view class="card p-0 overflow-hidden mt-4">
+        <view class="flex items-center border-b border-gray-100">
+          <view
+            v-for="tab in tabs"
+            :key="tab.value"
+            class="flex-1 py-3 text-center text-sm font-medium relative"
+            :class="currentTab === tab.value ? 'text-primary' : 'text-gray-500'"
+            @click="currentTab = tab.value"
+          >
+            {{ tab.label }}
+            <view v-if="currentTab === tab.value" class="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-primary rounded-full"></view>
+          </view>
+        </view>
+
+        <!-- 合同信息 -->
+        <view v-if="currentTab === 'info'" class="p-4">
+          <view class="detail-section-title mb-3">
+            <text class="uni-icons uniui-file-filled text-primary mr-2"></text>
+            <text>基本信息</text>
+          </view>
+
+          <uni-list :border="false">
+            <uni-list-item title="合同编号" :right-text="contract.code" />
+            <uni-list-item title="合同名称" :right-text="contract.name" />
+            <uni-list-item title="合同类型" :right-text="typeText" />
+            <uni-list-item title="客户名称" :right-text="contract.customerName" />
+            <uni-list-item title="关联项目" :right-text="contract.projectName || '-'" />
+            <uni-list-item title="签订日期" :right-text="contract.signDate || '-'" />
+            <uni-list-item title="服务开始" :right-text="contract.startDate" />
+            <uni-list-item title="服务结束" :right-text="contract.endDate" />
+          </uni-list>
+
+          <view class="detail-section-title mt-4 mb-3">
+            <text class="uni-icons uniui-list-filled text-primary mr-2"></text>
+            <text>服务内容</text>
+          </view>
+
+          <view class="bg-gray-50 rounded-xl p-3">
+            <text class="text-sm text-gray-700 leading-relaxed">{{ contract.serviceContent || '暂无服务内容' }}</text>
+          </view>
+
+          <view class="detail-section-title mt-4 mb-3">
+            <text class="uni-icons uniui-attach text-primary mr-2"></text>
+            <text>附件</text>
+          </view>
+
+          <view v-if="contract.attachments.length === 0" class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂无附件</text>
+          </view>
+
+          <view v-else class="gap-y-2">
+            <view
+              v-for="att in contract.attachments"
+              :key="att.id"
+              class="flex items-center p-3 bg-gray-50 rounded-xl"
+            >
+              <text class="uni-icons uniui-file text-primary mr-2"></text>
+              <view class="flex-1 min-w-0">
+                <text class="text-sm text-gray-700 truncate">{{ att.name }}</text>
+                <text class="text-xs text-gray-400">{{ formatSize(att.size) }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 付款计划 -->
+        <view v-if="currentTab === 'payment'" class="p-4">
+          <view class="flex items-center justify-between mb-4">
+            <view class="detail-section-title mb-0">
+              <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+              <text>付款计划</text>
+            </view>
+            <text class="text-sm text-primary" @click="goPaymentRecord">查看记录 ></text>
+          </view>
+
+          <view v-if="contract.paymentPlans.length === 0" class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂无付款计划</text>
+          </view>
+
+          <view v-else class="gap-y-3">
+            <view
+              v-for="plan in contract.paymentPlans"
+              :key="plan.id"
+              class="bg-gray-50 rounded-xl p-4"
+              :class="{ 'bg-green-50': plan.status === 'paid', 'bg-red-50': plan.status === 'overdue' }"
+            >
+              <view class="flex items-start justify-between">
+                <view>
+                  <text class="text-sm font-semibold text-gray-800">第 {{ plan.phase }} 期</text>
+                  <text class="text-xs text-gray-400 mt-1">应付款: {{ plan.plannedDate }}</text>
+                </view>
+                <view class="text-right">
+                  <text class="text-lg font-bold text-gray-800">¥{{ plan.amount.toLocaleString() }}</text>
+                  <text class="tag mt-1" :class="paymentStatusClass(plan.status)">{{ paymentStatusText(plan.status) }}</text>
+                </view>
+              </view>
+
+              <view v-if="plan.actualDate" class="mt-3 pt-3 border-t border-gray-200 border-dashed">
+                <view class="flex items-center justify-between">
+                  <text class="text-sm text-gray-600">实际付款: {{ plan.actualDate }}</text>
+                  <text class="text-sm font-semibold text-green-600">¥{{ (plan.actualAmount || 0).toLocaleString() }}</text>
+                </view>
+              </view>
+
+              <view v-if="plan.status === 'unpaid' || plan.status === 'overdue'" class="mt-3">
+                <button class="w-full py-2 bg-primary text-white rounded-lg text-sm" @click="handleMarkPayment(plan)">
+                  标记付款
+                </button>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 执行进度 -->
+        <view v-if="currentTab === 'progress'" class="p-4">
+          <view class="detail-section-title mb-3">
+            <text class="uni-icons uniui-gear-filled text-primary mr-2"></text>
+            <text>关联项目</text>
+          </view>
+
+          <view v-if="contract.projectName" class="card p-4">
+            <view class="flex items-center justify-between">
+              <view>
+                <text class="text-base font-semibold text-gray-800">{{ contract.projectName }}</text>
+                <text class="text-sm text-gray-400 mt-1">项目编号: {{ contract.projectId || '-' }}</text>
+              </view>
+              <text class="uni-icons uniui-arrowright text-gray-400"></text>
+            </view>
+          </view>
+
+          <view v-else class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂未关联项目</text>
+          </view>
+
+          <view class="detail-section-title mt-4 mb-3">
+            <text class="uni-icons uniui-time-filled text-primary mr-2"></text>
+            <text>执行时间线</text>
+          </view>
+
+          <view class="relative pl-4">
+            <view class="absolute left-2 top-0 bottom-0 w-px bg-gray-200"></view>
+            <view class="relative mb-3">
+              <view class="absolute -left-4 top-1 w-2 h-2 rounded-full bg-green-500"></view>
+              <view class="ml-2">
+                <text class="text-sm text-gray-700">合同签订</text>
+                <text class="text-xs text-gray-400">{{ contract.signDate || contract.startDate }}</text>
+              </view>
+            </view>
+            <view class="relative mb-3">
+              <view class="absolute -left-4 top-1 w-2 h-2 rounded-full bg-blue-500"></view>
+              <view class="ml-2">
+                <text class="text-sm text-gray-700">服务开始</text>
+                <text class="text-xs text-gray-400">{{ contract.startDate }}</text>
+              </view>
+            </view>
+            <view class="relative">
+              <view class="absolute -left-4 top-1 w-2 h-2 rounded-full"
+                :class="contract.status === 'completed' ? 'bg-green-500' : 'bg-gray-300'">
+              </view>
+              <view class="ml-2">
+                <text class="text-sm text-gray-700">服务结束</text>
+                <text class="text-xs text-gray-400">{{ contract.endDate }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-4 py-3 safe-bottom flex gap-3">
+      <button class="flex-1 py-2 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium" @click="goEdit">
+        编辑
+      </button>
+      <button class="flex-1 py-2 btn-primary rounded-xl text-sm font-medium" @click="goPaymentRecord">
+        付款记录
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { formatDate, showToast } from '@/utils'
+import { getContractDetail, markPayment as apiMarkPayment } from '@/api/contract'
+import type { Contract, ContractStatus, PaymentStatus } from '@/types/contract'
+
+const contractId = ref('')
+const contract = ref<Contract | null>(null)
+const loading = ref(false)
+const currentTab = ref('info')
+
+const tabs = [
+  { label: '合同信息', value: 'info' },
+  { label: '付款计划', value: 'payment' },
+  { label: '执行进度', value: 'progress' }
+]
+
+const progress = computed(() => {
+  if (!contract.value || !contract.value.amount) return 0
+  return Math.min(100, Math.round((contract.value.paidAmount / contract.value.amount) * 100))
+})
+
+const statusClass = computed(() => {
+  const map: Record<ContractStatus, string> = {
+    pending: 'tag-warning',
+    active: 'tag-success',
+    expiring: 'tag-warning',
+    completed: 'tag-gray',
+    terminated: 'tag-danger'
+  }
+  return contract.value ? map[contract.value.status] : 'tag-gray'
+})
+
+const statusText = computed(() => {
+  const map: Record<ContractStatus, string> = {
+    pending: '待签订',
+    active: '执行中',
+    expiring: '即将到期',
+    completed: '已完成',
+    terminated: '已终止'
+  }
+  return contract.value ? map[contract.value.status] : ''
+})
+
+const typeText = computed(() => {
+  const map: Record<string, string> = {
+    annual: '包年',
+    quarterly: '季度',
+    monthly: '月付',
+    single: '单次'
+  }
+  return contract.value ? map[contract.value.type] : ''
+})
+
+function paymentStatusClass(status: PaymentStatus) {
+  const map: Record<PaymentStatus, string> = {
+    unpaid: 'tag-warning',
+    partpaid: 'tag-primary',
+    paid: 'tag-success',
+    overdue: 'tag-danger'
+  }
+  return map[status] || 'tag-gray'
+}
+
+function paymentStatusText(status: PaymentStatus) {
+  const map: Record<PaymentStatus, string> = {
+    unpaid: '未付款',
+    partpaid: '部分付款',
+    paid: '已付款',
+    overdue: '已逾期'
+  }
+  return map[status] || status
+}
+
+function formatSize(size?: number) {
+  if (!size) return '-'
+  if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
+  return `${(size / 1024).toFixed(1)} KB`
+}
+
+async function fetchDetail() {
+  if (!contractId.value) return
+  loading.value = true
+  try {
+    const res = await getContractDetail(contractId.value)
+    contract.value = res
+  } finally {
+    loading.value = false
+  }
+}
+
+async function handleMarkPayment(plan: any) {
+  uni.showModal({
+    title: '标记付款',
+    content: `确认第 ${plan.phase} 期付款已完成?`,
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await apiMarkPayment(contractId.value, plan.id, {
+            actualDate: new Date().toISOString().split('T')[0],
+            actualAmount: plan.amount
+          })
+          showToast('标记成功')
+          await fetchDetail()
+        } catch (e: any) {
+          showToast(e.message || '标记失败')
+        }
+      }
+    }
+  })
+}
+
+function goPaymentRecord() {
+  uni.navigateTo({ url: `/subPackages/pages-contract/paymentRecord?id=${contractId.value}` })
+}
+
+function goEdit() {
+  uni.navigateTo({ url: `/subPackages/pages-contract/contractForm?id=${contractId.value}` })
+}
+
+onMounted(async () => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  contractId.value = currentPage.options?.id || ''
+  await fetchDetail()
+})
+</script>

+ 379 - 0
src/subPackages/pages-contract/contractForm.vue

@@ -0,0 +1,379 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar :title="isEdit ? '编辑合同' : '新增合同'" show-back />
+
+    <view class="px-4 py-4">
+      <!-- 基本信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-0">
+          <text class="uni-icons uniui-file-filled text-primary mr-2"></text>
+          <text>基本信息</text>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">合同编号 <text class="text-red-500">*</text></text>
+          <input v-model="form.code" class="form-input" placeholder="请输入合同编号" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">合同名称 <text class="text-red-500">*</text></text>
+          <input v-model="form.name" class="form-input" placeholder="请输入合同名称" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">选择客户 <text class="text-red-500">*</text></text>
+          <picker :range="customerOptions" :value="customerIndex" range-key="label" @change="onCustomerChange">
+            <view class="form-select">
+              <text :class="form.customerId ? 'text-gray-800' : 'text-gray-400'">{{ customerLabel }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">合同类型 <text class="text-red-500">*</text></text>
+          <picker :range="typeOptions" :value="typeIndex" range-key="label" @change="onTypeChange">
+            <view class="form-select">
+              <text :class="form.type ? 'text-gray-800' : 'text-gray-400'">{{ typeLabel }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">合同金额 <text class="text-red-500">*</text></text>
+          <view class="flex items-center bg-gray-50 rounded-lg px-3">
+            <text class="text-gray-500 mr-1">¥</text>
+            <input v-model.number="form.amount" class="flex-1 py-3 text-gray-800" type="digit" placeholder="请输入合同金额" />
+          </view>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">付款方式 <text class="text-red-500">*</text></text>
+          <picker :range="paymentOptions" :value="paymentIndex" range-key="label" @change="onPaymentChange">
+            <view class="form-select">
+              <text :class="form.paymentMethod ? 'text-gray-800' : 'text-gray-400'">{{ paymentLabel }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">签订日期</text>
+          <picker mode="date" :value="form.signDate" @change="onSignDateChange">
+            <view class="form-select">
+              <text :class="form.signDate ? 'text-gray-800' : 'text-gray-400'">{{ form.signDate || '请选择签订日期' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">服务开始 <text class="text-red-500">*</text></text>
+          <picker mode="date" :value="form.startDate" @change="onStartDateChange">
+            <view class="form-select">
+              <text :class="form.startDate ? 'text-gray-800' : 'text-gray-400'">{{ form.startDate || '请选择服务开始日期' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">服务结束 <text class="text-red-500">*</text></text>
+          <picker mode="date" :value="form.endDate" @change="onEndDateChange">
+            <view class="form-select">
+              <text :class="form.endDate ? 'text-gray-800' : 'text-gray-400'">{{ form.endDate || '请选择服务结束日期' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+      </view>
+
+      <!-- 付款计划 -->
+      <view class="card p-4 mt-4">
+        <view class="flex items-center justify-between mb-4">
+          <view class="detail-section-title mb-0">
+            <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+            <text>付款计划</text>
+          </view>
+          <text class="text-sm text-primary" @click="addPaymentPlan">+ 添加期次</text>
+        </view>
+
+        <view v-if="form.paymentPlans.length === 0" class="py-4 text-center bg-gray-50 rounded-xl">
+          <text class="text-sm text-gray-400">暂无付款计划,点击添加</text>
+        </view>
+
+        <view v-for="(plan, index) in form.paymentPlans" :key="index" class="bg-gray-50 rounded-xl p-3 mb-3">
+          <view class="flex items-center justify-between mb-2">
+            <text class="text-sm font-medium text-gray-700">第 {{ plan.phase }} 期</text>
+            <text class="text-xs text-red-500" @click="removePaymentPlan(index)">删除</text>
+          </view>
+
+          <view class="form-group">
+            <text class="text-sm text-gray-500 mb-1">金额</text>
+            <view class="flex items-center bg-white rounded-lg px-3">
+              <text class="text-gray-500 mr-1">¥</text>
+              <input v-model.number="plan.amount" class="flex-1 py-2 text-gray-800" type="digit" placeholder="金额" />
+            </view>
+          </view>
+
+          <view class="form-group mb-0">
+            <text class="text-sm text-gray-500 mb-1">应付款日期</text>
+            <picker mode="date" :value="plan.plannedDate" @change="(e: any) => onPlanDateChange(index, e)">
+              <view class="bg-white rounded-lg px-3 py-2 flex items-center justify-between">
+                <text :class="plan.plannedDate ? 'text-gray-800' : 'text-gray-400'">{{ plan.plannedDate || '请选择' }}</text>
+                <text class="uni-icons uniui-arrowdown text-gray-400"></text>
+              </view>
+            </picker>
+          </view>
+        </view>
+      </view>
+
+      <!-- 服务内容 -->
+      <view class="card p-4 mt-4">
+        <view class="detail-section-title mb-0">
+          <text class="uni-icons uniui-list-filled text-primary mr-2"></text>
+          <text>服务内容</text>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">服务内容</text>
+          <textarea v-model="form.serviceContent" class="form-textarea" placeholder="请输入服务内容" />
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">备注</text>
+          <textarea v-model="form.remark" class="form-textarea" placeholder="请输入备注信息" />
+        </view>
+      </view>
+
+      <!-- 保存按钮 -->
+      <view class="pt-6">
+        <button class="w-full py-4 btn-primary rounded-2xl text-base font-semibold" @click="submit">
+          保存
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import TopBar from '@/components/common/TopBar.vue'
+import { generateId, showToast } from '@/utils'
+import { getContractDetail, createContract, updateContract } from '@/api/contract'
+import { getCustomerOptions } from '@/api/customer'
+import type { ContractFormData, ContractType, PaymentMethod } from '@/types/contract'
+
+const contractId = ref('')
+const isEdit = computed(() => !!contractId.value)
+
+const form = ref<ContractFormData>({
+  code: '',
+  name: '',
+  customerId: '',
+  type: 'annual',
+  amount: 0,
+  paymentMethod: 'installment',
+  startDate: '',
+  endDate: '',
+  signDate: '',
+  serviceContent: '',
+  remark: '',
+  status: 'pending',
+  paymentPlans: []
+})
+
+const customerOptions = ref<{ label: string; value: string }[]>([])
+
+const typeOptions = [
+  { label: '包年', value: 'annual' },
+  { label: '季度', value: 'quarterly' },
+  { label: '月付', value: 'monthly' },
+  { label: '单次', value: 'single' }
+]
+
+const paymentOptions = [
+  { label: '一次性付清', value: 'lump_sum' },
+  { label: '分期付款', value: 'installment' },
+  { label: '按月付款', value: 'monthly' },
+  { label: '按季度付款', value: 'quarterly' }
+]
+
+const customerIndex = computed(() => customerOptions.value.findIndex(o => o.value === form.value.customerId))
+const typeIndex = computed(() => typeOptions.findIndex(o => o.value === form.value.type))
+const paymentIndex = computed(() => paymentOptions.findIndex(o => o.value === form.value.paymentMethod))
+
+const customerLabel = computed(() => customerOptions.value.find(o => o.value === form.value.customerId)?.label || '请选择客户')
+const typeLabel = computed(() => typeOptions.find(o => o.value === form.value.type)?.label || '请选择')
+const paymentLabel = computed(() => paymentOptions.find(o => o.value === form.value.paymentMethod)?.label || '请选择')
+
+function onCustomerChange(e: any) {
+  form.value.customerId = customerOptions.value[e.detail.value].value
+}
+
+function onTypeChange(e: any) {
+  form.value.type = typeOptions[e.detail.value].value as ContractType
+  generatePaymentPlans()
+}
+
+function onPaymentChange(e: any) {
+  form.value.paymentMethod = paymentOptions[e.detail.value].value as PaymentMethod
+  generatePaymentPlans()
+}
+
+function onSignDateChange(e: any) {
+  form.value.signDate = e.detail.value
+}
+
+function onStartDateChange(e: any) {
+  form.value.startDate = e.detail.value
+}
+
+function onEndDateChange(e: any) {
+  form.value.endDate = e.detail.value
+}
+
+function onPlanDateChange(index: number, e: any) {
+  form.value.paymentPlans[index].plannedDate = e.detail.value
+}
+
+function generatePaymentPlans() {
+  // 根据付款方式自动生成默认付款计划
+  const method = form.value.paymentMethod
+  const amount = form.value.amount
+  if (!amount || !form.value.startDate || !form.value.endDate) return
+
+  const plans = []
+  const start = new Date(form.value.startDate)
+  const end = new Date(form.value.endDate)
+  const months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()) + 1
+
+  if (method === 'lump_sum') {
+    plans.push({ id: generateId('pp'), phase: 1, amount, plannedDate: form.value.startDate, status: 'unpaid' as const })
+  } else if (method === 'monthly') {
+    const monthlyAmount = Math.round(amount / months)
+    for (let i = 0; i < months; i++) {
+      const d = new Date(start.getFullYear(), start.getMonth() + i, 5)
+      plans.push({ id: generateId('pp'), phase: i + 1, amount: monthlyAmount, plannedDate: d.toISOString().split('T')[0], status: 'unpaid' as const })
+    }
+  } else if (method === 'quarterly') {
+    const quarters = Math.ceil(months / 3)
+    const quarterAmount = Math.round(amount / quarters)
+    for (let i = 0; i < quarters; i++) {
+      const d = new Date(start.getFullYear(), start.getMonth() + i * 3, 5)
+      plans.push({ id: generateId('pp'), phase: i + 1, amount: quarterAmount, plannedDate: d.toISOString().split('T')[0], status: 'unpaid' as const })
+    }
+  } else if (method === 'installment') {
+    // 默认分2-4期
+    const phases = Math.min(4, Math.max(2, Math.ceil(months / 3)))
+    const phaseAmount = Math.round(amount / phases)
+    for (let i = 0; i < phases; i++) {
+      const d = new Date(start.getFullYear(), start.getMonth() + i * Math.ceil(months / phases), 5)
+      plans.push({ id: generateId('pp'), phase: i + 1, amount: phaseAmount, plannedDate: d.toISOString().split('T')[0], status: 'unpaid' as const })
+    }
+  }
+
+  // 调整最后一期金额,确保总和正确
+  if (plans.length > 0) {
+    const total = plans.reduce((sum, p) => sum + p.amount, 0)
+    if (total !== amount) {
+      plans[plans.length - 1].amount += amount - total
+    }
+  }
+
+  form.value.paymentPlans = plans
+}
+
+function addPaymentPlan() {
+  const nextPhase = form.value.paymentPlans.length + 1
+  form.value.paymentPlans.push({
+    id: generateId('pp'),
+    phase: nextPhase,
+    amount: 0,
+    plannedDate: '',
+    status: 'unpaid'
+  })
+}
+
+function removePaymentPlan(index: number) {
+  form.value.paymentPlans.splice(index, 1)
+  // 重新编号
+  form.value.paymentPlans.forEach((p, i) => p.phase = i + 1)
+}
+
+function validate(): boolean {
+  if (!form.value.code.trim()) { showToast('请输入合同编号'); return false }
+  if (!form.value.name.trim()) { showToast('请输入合同名称'); return false }
+  if (!form.value.customerId) { showToast('请选择客户'); return false }
+  if (!form.value.amount || form.value.amount <= 0) { showToast('请输入合同金额'); return false }
+  if (!form.value.startDate) { showToast('请选择服务开始日期'); return false }
+  if (!form.value.endDate) { showToast('请选择服务结束日期'); return false }
+  if (form.value.paymentPlans.length === 0) { showToast('请添加付款计划'); return false }
+  for (const p of form.value.paymentPlans) {
+    if (!p.amount || p.amount <= 0) { showToast('请输入付款金额'); return false }
+    if (!p.plannedDate) { showToast('请选择应付款日期'); return false }
+  }
+  return true
+}
+
+async function submit() {
+  if (!validate()) return
+  try {
+    if (isEdit.value) {
+      await updateContract({ ...form.value, id: contractId.value })
+      showToast('更新成功')
+    } else {
+      await createContract(form.value)
+      showToast('创建成功')
+    }
+    setTimeout(() => uni.navigateBack(), 800)
+  } catch (e: any) {
+    showToast(e.message || '保存失败')
+  }
+}
+
+onMounted(async () => {
+  // 加载客户选项
+  customerOptions.value = await getCustomerOptions()
+
+  // 编辑模式加载数据
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  contractId.value = currentPage.options?.id || ''
+  if (contractId.value) {
+    const detail = await getContractDetail(contractId.value)
+    if (detail) {
+      form.value = {
+        code: detail.code,
+        name: detail.name,
+        customerId: detail.customerId,
+        type: detail.type,
+        amount: detail.amount,
+        paymentMethod: detail.paymentMethod,
+        startDate: detail.startDate,
+        endDate: detail.endDate,
+        signDate: detail.signDate || '',
+        serviceContent: detail.serviceContent || '',
+        remark: detail.remark || '',
+        status: detail.status,
+        paymentPlans: detail.paymentPlans.map(p => ({
+          id: p.id,
+          phase: p.phase,
+          amount: p.amount,
+          plannedDate: p.plannedDate,
+          status: p.status
+        }))
+      }
+    }
+  }
+})
+</script>
+
+<style scoped>
+.detail-section-title {
+  border-bottom: none;
+  padding-left: 0;
+  padding-right: 0;
+}
+</style>

+ 291 - 0
src/subPackages/pages-contract/paymentRecord.vue

@@ -0,0 +1,291 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar title="付款记录" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else-if="!contract" class="px-4 py-6">
+      <EmptyState message="合同不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4">
+      <!-- 合同基本信息 -->
+      <view class="card p-4">
+        <view class="flex items-start justify-between">
+          <view class="min-w-0 flex-1">
+            <text class="text-base font-semibold text-gray-800 truncate">{{ contract.name }}</text>
+            <text class="text-xs text-gray-400 mt-1">{{ contract.code }}</text>
+          </view>
+          <text class="tag flex-shrink-0 ml-2" :class="statusClass">{{ statusText }}</text>
+        </view>
+
+        <view class="grid grid-cols-2 gap-3 mt-4">
+          <view class="text-center p-2 bg-blue-50 rounded-lg">
+            <text class="text-lg font-bold text-primary">¥{{ contract.amount.toLocaleString() }}</text>
+            <text class="text-xs text-gray-500 block">合同金额</text>
+          </view>
+          <view class="text-center p-2 bg-green-50 rounded-lg">
+            <text class="text-lg font-bold text-green-600">¥{{ contract.paidAmount.toLocaleString() }}</text>
+            <text class="text-xs text-gray-500 block">已付款</text>
+          </view>
+        </view>
+
+        <view class="mt-3">
+          <view class="flex items-center justify-between mb-1">
+            <text class="text-xs text-gray-500">付款进度 {{ progress }}%</text>
+            <text class="text-xs text-gray-500">待付 ¥{{ unpaidAmount.toLocaleString() }}</text>
+          </view>
+          <view class="progress-bar-track">
+            <view class="progress-bar-fill" :style="{ width: `${progress}%` }"></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 付款记录列表 -->
+      <view class="card p-4 mt-4">
+        <view class="detail-section-title mb-3">
+          <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+          <text>付款计划</text>
+        </view>
+
+        <view v-if="paymentPlans.length === 0" class="py-4 text-center">
+          <text class="text-sm text-gray-400">暂无付款计划</text>
+        </view>
+
+        <view v-else class="gap-y-3">
+          <view
+            v-for="plan in paymentPlans"
+            :key="plan.id"
+            class="rounded-xl p-4 border border-gray-100"
+            :class="planStatusBg(plan.status)"
+          >
+            <view class="flex items-start justify-between">
+              <view>
+                <view class="flex items-center">
+                  <text class="text-sm font-semibold text-gray-800">第 {{ plan.phase }} 期</text>
+                  <text class="tag ml-2" :class="paymentStatusClass(plan.status)">{{ paymentStatusText(plan.status) }}</text>
+                </view>
+                <text class="text-xs text-gray-400 mt-1">应付款日期: {{ plan.plannedDate }}</text>
+              </view>
+              <view class="text-right">
+                <text class="text-lg font-bold text-gray-800">¥{{ plan.amount.toLocaleString() }}</text>
+              </view>
+            </view>
+
+            <view v-if="plan.actualDate" class="mt-3 pt-3 border-t border-gray-200 border-dashed">
+              <view class="flex items-center justify-between">
+                <view>
+                  <text class="text-sm text-gray-600">实际付款: {{ plan.actualDate }}</text>
+                  <text v-if="plan.actualAmount && plan.actualAmount !== plan.amount" class="text-xs text-orange-500 mt-1">
+                    实付: ¥{{ plan.actualAmount.toLocaleString() }} (差额: ¥{{ (plan.amount - plan.actualAmount).toLocaleString() }})
+                  </text>
+                </view>
+                <text class="text-sm font-semibold text-green-600">¥{{ (plan.actualAmount || 0).toLocaleString() }}</text>
+              </view>
+            </view>
+
+            <view v-if="plan.status === 'unpaid' || plan.status === 'overdue'" class="mt-3">
+              <button class="w-full py-2 btn-primary rounded-lg text-sm" @click="showMarkPayment(plan)">
+                标记付款
+              </button>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 历史付款记录 -->
+      <view v-if="paymentRecords.length > 0" class="card p-4 mt-4">
+        <view class="detail-section-title mb-3">
+          <text class="uni-icons uniui-time-filled text-primary mr-2"></text>
+          <text>历史付款记录</text>
+        </view>
+
+        <view class="gap-y-3">
+          <view
+            v-for="record in paymentRecords"
+            :key="record.id"
+            class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0"
+          >
+            <view>
+              <text class="text-sm text-gray-800">第 {{ getPlanPhase(record.planId) }} 期付款</text>
+              <text class="text-xs text-gray-400 mt-1">{{ formatDate(record.payDate) }} · {{ record.payer || '-' }}</text>
+            </view>
+            <text class="text-base font-semibold text-green-600">+¥{{ record.amount.toLocaleString() }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 标记付款弹窗 -->
+    <view v-if="showModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center px-8" @click="showModal = false">
+      <view class="bg-white w-full rounded-2xl p-5" @click.stop>
+        <text class="text-lg font-bold text-gray-800 block mb-4">标记付款</text>
+
+        <view class="form-group">
+          <text class="form-label">期次</text>
+          <text class="text-gray-800">第 {{ selectedPlan?.phase }} 期 · ¥{{ selectedPlan?.amount.toLocaleString() }}</text>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">实际付款金额 <text class="text-red-500">*</text></text>
+          <view class="flex items-center bg-gray-50 rounded-lg px-3">
+            <text class="text-gray-500 mr-1">¥</text>
+            <input v-model.number="markForm.amount" class="flex-1 py-3 text-gray-800" type="digit" placeholder="请输入实际付款金额" />
+          </view>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">实际付款日期 <text class="text-red-500">*</text></text>
+          <picker mode="date" :value="markForm.date" @change="onDateChange">
+            <view class="form-select">
+              <text :class="markForm.date ? 'text-gray-800' : 'text-gray-400'">{{ markForm.date || '请选择付款日期' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="flex gap-3 pt-4">
+          <button class="flex-1 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium" @click="showModal = false">
+            取消
+          </button>
+          <button class="flex-1 py-3 btn-primary rounded-xl text-sm font-medium" @click="submitMarkPayment">
+            确认
+          </button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { formatDate, showToast } from '@/utils'
+import { getContractDetail, getPaymentPlans, markPayment, getPaymentRecords } from '@/api/contract'
+import type { Contract, PaymentPlan, PaymentRecord, PaymentStatus } from '@/types/contract'
+
+const contractId = ref('')
+const contract = ref<Contract | null>(null)
+const paymentPlans = ref<PaymentPlan[]>([])
+const paymentRecords = ref<PaymentRecord[]>([])
+const loading = ref(false)
+const showModal = ref(false)
+const selectedPlan = ref<PaymentPlan | null>(null)
+
+const markForm = ref({ amount: 0, date: '' })
+
+const progress = computed(() => {
+  if (!contract.value || !contract.value.amount) return 0
+  return Math.min(100, Math.round((contract.value.paidAmount / contract.value.amount) * 100))
+})
+
+const unpaidAmount = computed(() => {
+  if (!contract.value) return 0
+  return contract.value.amount - contract.value.paidAmount
+})
+
+const statusClass = computed(() => {
+  const map: Record<string, string> = {
+    pending: 'tag-warning', active: 'tag-success', expiring: 'tag-warning',
+    completed: 'tag-gray', terminated: 'tag-danger'
+  }
+  return contract.value ? map[contract.value.status] : 'tag-gray'
+})
+
+const statusText = computed(() => {
+  const map: Record<string, string> = {
+    pending: '待签订', active: '执行中', expiring: '即将到期',
+    completed: '已完成', terminated: '已终止'
+  }
+  return contract.value ? map[contract.value.status] : ''
+})
+
+function paymentStatusClass(status: PaymentStatus) {
+  const map: Record<PaymentStatus, string> = {
+    unpaid: 'tag-warning', partpaid: 'tag-primary', paid: 'tag-success', overdue: 'tag-danger'
+  }
+  return map[status] || 'tag-gray'
+}
+
+function paymentStatusText(status: PaymentStatus) {
+  const map: Record<PaymentStatus, string> = {
+    unpaid: '未付款', partpaid: '部分付款', paid: '已付款', overdue: '已逾期'
+  }
+  return map[status] || status
+}
+
+function planStatusBg(status: PaymentStatus) {
+  const map: Record<PaymentStatus, string> = {
+    paid: 'bg-green-50',
+    partpaid: 'bg-orange-50',
+    unpaid: 'bg-white',
+    overdue: 'bg-red-50'
+  }
+  return map[status] || 'bg-white'
+}
+
+function getPlanPhase(planId: string) {
+  const plan = paymentPlans.value.find(p => p.id === planId)
+  return plan ? plan.phase : '-'
+}
+
+function showMarkPayment(plan: PaymentPlan) {
+  selectedPlan.value = plan
+  markForm.value = { amount: plan.amount, date: new Date().toISOString().split('T')[0] }
+  showModal.value = true
+}
+
+function onDateChange(e: any) {
+  markForm.value.date = e.detail.value
+}
+
+async function submitMarkPayment() {
+  if (!selectedPlan.value) return
+  if (!markForm.value.amount || markForm.value.amount <= 0) {
+    showToast('请输入付款金额')
+    return
+  }
+  if (!markForm.value.date) {
+    showToast('请选择付款日期')
+    return
+  }
+  try {
+    await markPayment(contractId.value, selectedPlan.value.id, {
+      actualDate: markForm.value.date,
+      actualAmount: markForm.value.amount
+    })
+    showToast('标记成功')
+    showModal.value = false
+    await fetchData()
+  } catch (e: any) {
+    showToast(e.message || '标记失败')
+  }
+}
+
+async function fetchData() {
+  if (!contractId.value) return
+  loading.value = true
+  try {
+    const [detail, plans, records] = await Promise.all([
+      getContractDetail(contractId.value),
+      getPaymentPlans(contractId.value),
+      getPaymentRecords(contractId.value)
+    ])
+    contract.value = detail
+    paymentPlans.value = plans
+    paymentRecords.value = records
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  contractId.value = currentPage.options?.id || ''
+  await fetchData()
+})
+</script>

+ 339 - 0
src/subPackages/pages-customer/customerDetail.vue

@@ -0,0 +1,339 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar title="客户详情" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else-if="!customer" class="px-4 py-6">
+      <EmptyState message="客户不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4 gap-y-4">
+      <!-- 顶部客户信息 -->
+      <view class="card p-4">
+        <view class="flex items-start justify-between">
+          <view class="flex items-center">
+            <view class="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center mr-4">
+              <text class="text-white text-xl font-bold">{{ customer.name.slice(0, 1) }}</text>
+            </view>
+            <view>
+              <text class="text-lg font-bold text-gray-800">{{ customer.name }}</text>
+              <view class="flex items-center mt-2">
+                <text class="tag mr-2" :class="levelClass">{{ customer.level }}级</text>
+                <text class="tag" :class="typeClass">{{ typeText }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="text-right">
+            <text class="text-xs text-gray-400">合同总额</text>
+            <text class="text-lg font-bold text-primary">¥{{ customer.contractAmount.toLocaleString() }}</text>
+          </view>
+        </view>
+
+        <view class="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-gray-100">
+          <view class="text-center">
+            <text class="text-base font-semibold text-gray-800">{{ customer.projectCount }}</text>
+            <text class="text-xs text-gray-400 block">关联项目</text>
+          </view>
+          <view class="text-center border-l border-gray-100">
+            <text class="text-base font-semibold text-gray-800">{{ customer.followUpCount }}</text>
+            <text class="text-xs text-gray-400 block">跟进记录</text>
+          </view>
+          <view class="text-center border-l border-gray-100">
+            <text class="text-base font-semibold text-gray-800">{{ formatDate(customer.createTime, 'YYYY-MM-DD') }}</text>
+            <text class="text-xs text-gray-400 block">录入时间</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- Tab 切换 -->
+      <view class="card p-0 overflow-hidden">
+        <view class="flex items-center border-b border-gray-100">
+          <view
+            v-for="tab in tabs"
+            :key="tab.value"
+            class="flex-1 py-3 text-center text-sm font-medium relative"
+            :class="currentTab === tab.value ? 'text-primary' : 'text-gray-500'"
+            @click="currentTab = tab.value"
+          >
+            {{ tab.label }}
+            <view v-if="currentTab === tab.value" class="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-primary rounded-full"></view>
+          </view>
+        </view>
+
+        <!-- 基本信息 -->
+        <view v-if="currentTab === 'basic'" class="p-4">
+          <view class="detail-section-title mb-3">
+            <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
+            <text>客户资料</text>
+          </view>
+
+          <uni-list :border="false">
+            <uni-list-item title="客户名称" :right-text="customer.name" />
+            <uni-list-item title="客户类型" :right-text="typeText" />
+            <uni-list-item title="客户等级" :right-text="`${customer.level}级`" />
+            <uni-list-item title="所属行业" :right-text="customer.industry || '-'" />
+            <uni-list-item title="企业规模" :right-text="customer.scale || '-'" />
+            <uni-list-item title="客户来源" :right-text="customer.source || '-'" />
+            <uni-list-item title="详细地址" :right-text="customer.address || '-'" />
+            <uni-list-item title="备注" :right-text="customer.remark || '-'" />
+          </uni-list>
+
+          <view class="detail-section-title mt-4 mb-3">
+            <text class="uni-icons uniui-phone-filled text-primary mr-2"></text>
+            <text>联系人</text>
+          </view>
+
+          <view v-if="customer.contacts.length === 0" class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂无联系人</text>
+          </view>
+
+          <view
+            v-for="contact in customer.contacts"
+            :key="contact.id"
+            class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0"
+          >
+            <view class="flex items-center">
+              <view class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3">
+                <text class="uni-icons uniui-person text-primary"></text>
+              </view>
+              <view>
+                <text class="text-sm font-medium text-gray-800">{{ contact.name }} <text v-if="contact.isPrimary" class="tag tag-primary text-xs">主要</text></text>
+                <text class="text-xs text-gray-400 block">{{ contact.role || '联系人' }}</text>
+              </view>
+            </view>
+            <view class="flex items-center" @click.stop="callPhone(contact.phone)">
+              <text class="text-sm text-primary mr-2">{{ contact.phone }}</text>
+              <view class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
+                <text class="uni-icons uniui-phone-filled text-primary text-sm"></text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 跟进记录 -->
+        <view v-else-if="currentTab === 'follow'" class="p-4">
+          <view v-if="followUps.length === 0" class="py-10">
+            <EmptyState message="暂无跟进记录" />
+          </view>
+
+          <view v-else class="relative pl-4">
+            <view class="absolute left-0 top-2 bottom-2 w-0.5 bg-gray-200"></view>
+            <view
+              v-for="(item, index) in followUps"
+              :key="item.id"
+              class="relative pb-5"
+            >
+              <view class="absolute -left-4 top-1 w-2 h-2 rounded-full bg-primary"></view>
+              <view class="card p-3 mb-0">
+                <view class="flex items-center justify-between mb-2">
+                  <text class="text-sm font-semibold text-gray-800">{{ followTypeText(item.type) }}</text>
+                  <text class="tag" :class="intentClass(item.intent)">{{ intentText(item.intent) }}</text>
+                </view>
+                <text class="text-sm text-gray-600 block">{{ item.content }}</text>
+                <text v-if="item.nextPlan" class="text-xs text-gray-400 mt-2 block">下一步:{{ item.nextPlan }}</text>
+                <view class="flex items-center justify-between mt-2">
+                  <text class="text-xs text-gray-400">{{ item.operatorName || '销售' }} · {{ formatDate(item.createTime, 'MM-DD HH:mm') }}</text>
+                </view>
+              </view>
+            </view>
+          </view>
+
+          <view class="pt-4">
+            <button class="w-full py-3 btn-primary rounded-xl text-sm font-medium" @click="goFollowUp">
+              <text class="uni-icons uniui-plusempty mr-1"></text>
+              快速跟进
+            </button>
+          </view>
+        </view>
+
+        <!-- 关联业务 -->
+        <view v-else-if="currentTab === 'business'" class="p-4">
+          <view class="detail-section-title mb-3">
+            <text class="uni-icons uniui-folder-add-filled text-primary mr-2"></text>
+            <text>关联项目</text>
+          </view>
+
+          <view v-if="relatedProjects.length === 0" class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂无关联项目</text>
+          </view>
+
+          <view
+            v-for="p in relatedProjects"
+            :key="p.id"
+            class="card p-3 mb-3 clickable"
+            @click="goProject(p.id)"
+          >
+            <view class="flex items-center justify-between">
+              <text class="text-sm font-medium text-gray-800">{{ p.name }}</text>
+              <text class="tag tag-primary">{{ p.status }}</text>
+            </view>
+            <text class="text-xs text-gray-400 mt-1 block">{{ p.address }}</text>
+          </view>
+
+          <view class="detail-section-title mt-4 mb-3">
+            <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+            <text>关联合同</text>
+          </view>
+
+          <view v-if="relatedContracts.length === 0" class="py-4 text-center">
+            <text class="text-sm text-gray-400">暂无关联合同</text>
+          </view>
+
+          <view
+            v-for="c in relatedContracts"
+            :key="c.id"
+            class="card p-3 mb-3 clickable"
+            @click="goContract(c.id)"
+          >
+            <view class="flex items-center justify-between">
+              <text class="text-sm font-medium text-gray-800">{{ c.name }}</text>
+              <text class="tag tag-success">¥{{ c.amount.toLocaleString() }}</text>
+            </view>
+            <view class="flex items-center justify-between mt-2">
+              <text class="text-xs text-gray-400">{{ c.code }}</text>
+              <text class="text-xs text-gray-400">{{ c.startDate }} ~ {{ c.endDate }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view v-if="customer" class="fixed left-0 right-0 bottom-0 bg-white border-t border-gray-100 px-4 py-3 safe-bottom z-30">
+      <view class="max-w-md mx-auto flex items-center justify-between gap-3">
+        <button class="flex-1 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium" @click="goEdit">
+          <text class="uni-icons uniui-compose mr-1"></text>
+          编辑
+        </button>
+        <button class="flex-1 py-3 btn-primary rounded-xl text-sm font-medium" @click="goFollowUp">
+          <text class="uni-icons uniui-chatbubble mr-1"></text>
+          跟进
+        </button>
+        <button class="flex-1 py-3 bg-blue-50 text-primary rounded-xl text-sm font-medium" @click="callPrimary">
+          <text class="uni-icons uniui-phone-filled mr-1"></text>
+          电话
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { formatDate } from '@/utils'
+import { getCustomerDetail, getFollowUpList } from '@/api/customer'
+import { getContractList } from '@/api/contract'
+import type { Customer, CustomerType, FollowUpRecord, FollowUpType, FollowUpIntent } from '@/types/customer'
+
+const customer = ref<Customer | null>(null)
+const followUps = ref<FollowUpRecord[]>([])
+const relatedContracts = ref<any[]>([])
+const relatedProjects = ref<any[]>([])
+const loading = ref(false)
+const currentTab = ref('basic')
+
+const tabs = [
+  { label: '基本信息', value: 'basic' },
+  { label: '跟进记录', value: 'follow' },
+  { label: '关联业务', value: 'business' }
+]
+
+const levelClass = computed(() => {
+  const map: Record<string, string> = { A: 'tag-primary', B: 'tag-success', C: 'tag-warning', D: 'tag-gray' }
+  return map[customer.value?.level || 'D'] || 'tag-gray'
+})
+
+const typeClass = computed(() => {
+  const map: Record<string, string> = { enterprise: 'tag-success', personal: 'tag-warning', government: 'tag-primary' }
+  return map[customer.value?.type || ''] || 'tag-gray'
+})
+
+const typeText = computed(() => {
+  const map: Record<string, string> = { enterprise: '企业', personal: '个人', government: '政府' }
+  return map[customer.value?.type || ''] || '-'
+})
+
+function followTypeText(type: FollowUpType) {
+  const map: Record<string, string> = { phone: '电话跟进', visit: '上门拜访', wechat: '微信沟通', email: '邮件往来', other: '其他' }
+  return map[type] || type
+}
+
+function intentClass(intent: FollowUpIntent) {
+  const map: Record<string, string> = { high: 'tag-primary', medium: 'tag-warning', low: 'tag-gray' }
+  return map[intent] || 'tag-gray'
+}
+
+function intentText(intent: FollowUpIntent) {
+  const map: Record<string, string> = { high: '高意向', medium: '中意向', low: '低意向' }
+  return map[intent] || intent
+}
+
+function callPhone(phone?: string) {
+  if (!phone) return
+  uni.makePhoneCall({ phoneNumber: phone })
+}
+
+function callPrimary() {
+  const phone = customer.value?.contacts.find(c => c.isPrimary)?.phone || customer.value?.contacts[0]?.phone
+  if (phone) {
+    uni.makePhoneCall({ phoneNumber: phone })
+  } else {
+    uni.showToast({ title: '暂无联系电话', icon: 'none' })
+  }
+}
+
+function goEdit() {
+  uni.navigateTo({ url: `/subPackages/pages-customer/customerForm?id=${customer.value?.id}` })
+}
+
+function goFollowUp() {
+  uni.navigateTo({ url: `/subPackages/pages-customer/followUpList?id=${customer.value?.id}` })
+}
+
+function goProject(id: string) {
+  uni.navigateTo({ url: `/subPackages/pages-common/projectDetail?id=${id}` })
+}
+
+function goContract(id: string) {
+  uni.navigateTo({ url: `/subPackages/pages-contract/contractDetail?id=${id}` })
+}
+
+async function loadData(id: string) {
+  loading.value = true
+  try {
+    const [detail, follows, contracts] = await Promise.all([
+      getCustomerDetail(id),
+      getFollowUpList(id),
+      getContractList({ customerId: id, pageSize: 100 })
+    ])
+    customer.value = detail
+    followUps.value = follows
+    relatedContracts.value = contracts.list
+    relatedProjects.value = [] // mock
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  const id = currentPage.options?.id
+  if (id) loadData(id)
+})
+
+onShow(() => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  const id = currentPage.options?.id
+  if (id && customer.value?.id !== id) {
+    loadData(id)
+  }
+})
+</script>

+ 310 - 0
src/subPackages/pages-customer/customerForm.vue

@@ -0,0 +1,310 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar :title="isEdit ? '编辑客户' : '新增客户'" show-back />
+
+    <view class="px-4 py-4">
+      <!-- 基本信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-0">
+          <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
+          <text>基本信息</text>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">客户名称 <text class="text-red-500">*</text></text>
+          <input v-model="form.name" class="form-input" placeholder="请输入客户名称" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">客户类型 <text class="text-red-500">*</text></text>
+          <picker :range="typeOptions" :value="typeIndex" range-key="label" @change="onTypeChange">
+            <view class="form-select">
+              <text :class="form.type ? 'text-gray-800' : 'text-gray-400'">{{ typeText }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">客户等级 <text class="text-red-500">*</text></text>
+          <picker :range="levelOptions" :value="levelIndex" range-key="label" @change="onLevelChange">
+            <view class="form-select">
+              <text :class="form.level ? 'text-gray-800' : 'text-gray-400'">{{ levelText }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">所属行业</text>
+          <input v-model="form.industry" class="form-input" placeholder="请输入所属行业" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">企业规模</text>
+          <input v-model="form.scale" class="form-input" placeholder="请输入企业规模" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">客户来源</text>
+          <input v-model="form.source" class="form-input" placeholder="请输入客户来源" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">详细地址</text>
+          <input v-model="form.address" class="form-input" placeholder="请输入详细地址" />
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">客户状态</text>
+          <picker :range="statusOptions" :value="statusIndex" range-key="label" @change="onStatusChange">
+            <view class="form-select">
+              <text :class="form.status ? 'text-gray-800' : 'text-gray-400'">{{ statusText }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+      </view>
+
+      <!-- 联系人 -->
+      <view class="card p-4 mt-4">
+        <view class="flex items-center justify-between mb-4">
+          <view class="detail-section-title mb-0">
+            <text class="uni-icons uniui-phone-filled text-primary mr-2"></text>
+            <text>联系人</text>
+          </view>
+          <text class="text-sm text-primary" @click="addContact">+ 添加联系人</text>
+        </view>
+
+        <view v-for="(contact, index) in form.contacts" :key="contact.id" class="bg-gray-50 rounded-xl p-3 mb-3">
+          <view class="flex items-center justify-between mb-2">
+            <text class="text-sm font-medium text-gray-700">联系人 {{ index + 1 }}</text>
+            <text v-if="form.contacts.length > 1" class="text-xs text-red-500" @click="removeContact(index)">删除</text>
+          </view>
+
+          <view class="form-group">
+            <input v-model="contact.name" class="form-input" placeholder="姓名" />
+          </view>
+
+          <view class="form-group">
+            <input v-model="contact.phone" class="form-input" type="number" placeholder="手机号" maxlength="11" />
+          </view>
+
+          <view class="form-group mb-0">
+            <input v-model="contact.role" class="form-input" placeholder="职务" />
+          </view>
+
+          <view class="flex items-center mt-2">
+            <checkbox :checked="contact.isPrimary" @click="setPrimary(index)" color="#2563eb" />
+            <text class="text-sm text-gray-600 ml-2">设为主要联系人</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 财务信息 -->
+      <view class="card p-4 mt-4">
+        <view class="detail-section-title mb-0">
+          <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+          <text>财务信息</text>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">开户银行</text>
+          <input v-model="form.finance.bankName" class="form-input" placeholder="请输入开户银行" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">银行账号</text>
+          <input v-model="form.finance.bankAccount" class="form-input" placeholder="请输入银行账号" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">税号</text>
+          <input v-model="form.finance.taxNo" class="form-input" placeholder="请输入税号" />
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">发票抬头</text>
+          <input v-model="form.finance.invoiceTitle" class="form-input" placeholder="请输入发票抬头" />
+        </view>
+      </view>
+
+      <!-- 备注 -->
+      <view class="card p-4 mt-4">
+        <view class="form-group mb-0">
+          <text class="form-label">备注</text>
+          <textarea v-model="form.remark" class="form-textarea" placeholder="请输入备注信息" />
+        </view>
+      </view>
+
+      <!-- 保存按钮 -->
+      <view class="pt-6">
+        <button class="w-full py-4 btn-primary rounded-2xl text-base font-semibold" @click="submit">
+          保存
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import TopBar from '@/components/common/TopBar.vue'
+import { generateId, showToast } from '@/utils'
+import { createCustomer, getCustomerDetail, updateCustomer } from '@/api/customer'
+import type { CustomerFormData, CustomerType, CustomerLevel, CustomerStatus } from '@/types/customer'
+
+const customerId = ref('')
+const isEdit = computed(() => !!customerId.value)
+
+const form = ref<CustomerFormData>({
+  name: '',
+  type: 'enterprise',
+  level: 'B',
+  industry: '',
+  scale: '',
+  source: '',
+  address: '',
+  status: 'active',
+  contacts: [{ id: generateId('c'), name: '', phone: '', role: '', isPrimary: true }],
+  finance: {
+    bankName: '',
+    bankAccount: '',
+    taxNo: '',
+    invoiceTitle: ''
+  }
+})
+
+const typeOptions = [
+  { label: '企业', value: 'enterprise' },
+  { label: '个人', value: 'personal' },
+  { label: '政府', value: 'government' }
+]
+
+const levelOptions = [
+  { label: 'A级', value: 'A' },
+  { label: 'B级', value: 'B' },
+  { label: 'C级', value: 'C' },
+  { label: 'D级', value: 'D' }
+]
+
+const statusOptions = [
+  { label: '有效', value: 'active' },
+  { label: '失效', value: 'inactive' },
+  { label: '黑名单', value: 'blacklist' }
+]
+
+const typeIndex = computed(() => typeOptions.findIndex(o => o.value === form.value.type))
+const levelIndex = computed(() => levelOptions.findIndex(o => o.value === form.value.level))
+const statusIndex = computed(() => statusOptions.findIndex(o => o.value === form.value.status))
+
+const typeText = computed(() => typeOptions.find(o => o.value === form.value.type)?.label || '请选择')
+const levelText = computed(() => levelOptions.find(o => o.value === form.value.level)?.label || '请选择')
+const statusText = computed(() => statusOptions.find(o => o.value === form.value.status)?.label || '请选择')
+
+function onTypeChange(e: any) {
+  form.value.type = typeOptions[e.detail.value].value as CustomerType
+}
+
+function onLevelChange(e: any) {
+  form.value.level = levelOptions[e.detail.value].value as CustomerLevel
+}
+
+function onStatusChange(e: any) {
+  form.value.status = statusOptions[e.detail.value].value as CustomerStatus
+}
+
+function addContact() {
+  form.value.contacts.forEach(c => c.isPrimary = false)
+  form.value.contacts.push({ id: generateId('c'), name: '', phone: '', role: '', isPrimary: true })
+}
+
+function removeContact(index: number) {
+  form.value.contacts.splice(index, 1)
+  if (form.value.contacts.length > 0 && !form.value.contacts.some(c => c.isPrimary)) {
+    form.value.contacts[0].isPrimary = true
+  }
+}
+
+function setPrimary(index: number) {
+  form.value.contacts.forEach((c, i) => c.isPrimary = i === index)
+}
+
+function validate(): boolean {
+  if (!form.value.name.trim()) {
+    showToast('请输入客户名称')
+    return false
+  }
+  if (!form.value.type) {
+    showToast('请选择客户类型')
+    return false
+  }
+  if (!form.value.level) {
+    showToast('请选择客户等级')
+    return false
+  }
+  for (const c of form.value.contacts) {
+    if (!c.name.trim()) {
+      showToast('请输入联系人姓名')
+      return false
+    }
+    if (!c.phone.trim()) {
+      showToast('请输入联系人手机号')
+      return false
+    }
+  }
+  return true
+}
+
+async function submit() {
+  if (!validate()) return
+  try {
+    if (isEdit.value) {
+      await updateCustomer({ ...form.value, id: customerId.value })
+      showToast('更新成功')
+    } else {
+      await createCustomer(form.value)
+      showToast('创建成功')
+    }
+    setTimeout(() => uni.navigateBack(), 800)
+  } catch (e: any) {
+    showToast(e.message || '保存失败')
+  }
+}
+
+onMounted(async () => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  customerId.value = currentPage.options?.id || ''
+  if (customerId.value) {
+    const detail = await getCustomerDetail(customerId.value)
+    if (detail) {
+      form.value = {
+        name: detail.name,
+        type: detail.type,
+        level: detail.level,
+        industry: detail.industry || '',
+        scale: detail.scale || '',
+        source: detail.source || '',
+        address: detail.address || '',
+        status: detail.status,
+        contacts: detail.contacts.length ? detail.contacts.map(c => ({ ...c })) : [{ id: generateId('c'), name: '', phone: '', role: '', isPrimary: true }],
+        finance: {
+          bankName: detail.finance?.bankName || '',
+          bankAccount: detail.finance?.bankAccount || '',
+          taxNo: detail.finance?.taxNo || '',
+          invoiceTitle: detail.finance?.invoiceTitle || ''
+        }
+      }
+    }
+  }
+})
+</script>
+
+<style scoped>
+.detail-section-title {
+  border-bottom: none;
+  padding-left: 0;
+  padding-right: 0;
+}
+</style>

+ 315 - 0
src/subPackages/pages-customer/followUpList.vue

@@ -0,0 +1,315 @@
+<template>
+  <view class="app-container pb-28">
+    <TopBar title="跟进记录" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else-if="!customer" class="px-4 py-6">
+      <EmptyState message="客户不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4">
+      <!-- 客户信息头部 -->
+      <view class="card p-4">
+        <view class="flex items-center">
+          <view class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center mr-4">
+            <text class="text-white text-lg font-bold">{{ customer.name.slice(0, 1) }}</text>
+          </view>
+          <view class="flex-1 min-w-0">
+            <text class="text-base font-semibold text-gray-800 truncate block">{{ customer.name }}</text>
+            <view class="flex items-center mt-1">
+              <text class="tag mr-2" :class="levelClass">{{ customer.level }}级</text>
+              <text class="tag" :class="typeClass">{{ typeText }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 时间线 -->
+      <view class="card p-4 mt-4">
+        <view class="detail-section-title mb-0">
+          <text class="uni-icons uniui-chatbubble-filled text-primary mr-2"></text>
+          <text>跟进记录</text>
+        </view>
+
+        <view v-if="followUps.length === 0" class="py-10">
+          <EmptyState message="暂无跟进记录" />
+        </view>
+
+        <view v-else class="relative pl-4 mt-4">
+          <view class="absolute left-0 top-2 bottom-2 w-0.5 bg-gray-200"></view>
+
+          <view
+            v-for="item in followUps"
+            :key="item.id"
+            class="relative pb-5"
+          >
+            <view class="absolute -left-4 top-1 w-2 h-2 rounded-full bg-primary"></view>
+
+            <view class="card p-3 mb-0">
+              <view class="flex items-center justify-between mb-2">
+                <view class="flex items-center">
+                  <text class="text-sm font-semibold text-gray-800">{{ followTypeText(item.type) }}</text>
+                  <text class="tag ml-2" :class="intentClass(item.intent)">{{ intentText(item.intent) }}</text>
+                </view>
+                <text class="text-xs text-gray-400">{{ formatDate(item.createTime, 'MM-DD HH:mm') }}</text>
+              </view>
+
+              <text class="text-sm text-gray-600 block">{{ item.content }}</text>
+
+              <view v-if="item.nextPlan" class="mt-2 p-2 bg-blue-50 rounded-lg">
+                <text class="text-xs text-gray-500">下一步计划:{{ item.nextPlan }}</text>
+                <text v-if="item.nextTime" class="text-xs text-primary block mt-1">计划时间:{{ formatDate(item.nextTime, 'YYYY-MM-DD HH:mm') }}</text>
+              </view>
+
+              <view class="flex items-center justify-between mt-2">
+                <text class="text-xs text-gray-400">{{ item.operatorName || '销售' }}</text>
+                <text class="text-xs text-red-500" @click="deleteRecord(item.id)">删除</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部新增按钮 -->
+    <view v-if="customer" class="fixed left-0 right-0 bottom-0 bg-white border-t border-gray-100 px-4 py-3 safe-bottom z-30">
+      <view class="max-w-md mx-auto">
+        <button class="w-full py-4 btn-primary rounded-2xl text-base font-semibold" @click="openAddModal">
+          <text class="uni-icons uniui-plusempty mr-2"></text>
+          新增跟进
+        </button>
+      </view>
+    </view>
+
+    <!-- 新增跟进弹窗 -->
+    <view v-if="showModalRef" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
+      <view class="bg-white rounded-2xl w-11/12 max-w-sm p-5 max-h-[80vh] overflow-y-auto">
+        <text class="text-lg font-semibold text-gray-800 block mb-4">新增跟进</text>
+
+        <view class="form-group">
+          <text class="form-label">跟进方式 <text class="text-red-500">*</text></text>
+          <picker :range="followTypeOptions" :value="followTypeIndex" range-key="label" @change="onFollowTypeChange">
+            <view class="form-select">
+              <text :class="form.type ? 'text-gray-800' : 'text-gray-400'">{{ followTypeLabel }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">跟进内容 <text class="text-red-500">*</text></text>
+          <textarea v-model="form.content" class="form-textarea" placeholder="请输入跟进内容" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">意向度 <text class="text-red-500">*</text></text>
+          <picker :range="intentOptions" :value="intentIndex" range-key="label" @change="onIntentChange">
+            <view class="form-select">
+              <text :class="form.intent ? 'text-gray-800' : 'text-gray-400'">{{ intentLabel }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">下一步计划</text>
+          <input v-model="form.nextPlan" class="form-input" placeholder="请输入下一步计划" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">计划时间</text>
+          <picker mode="date" :value="form.nextTime" @change="onNextTimeChange">
+            <view class="form-select">
+              <text :class="form.nextTime ? 'text-gray-800' : 'text-gray-400'">{{ form.nextTime || '请选择计划时间' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-auto"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="flex items-center justify-between gap-3 mt-6">
+          <button class="flex-1 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium" @click="closeModal">取消</button>
+          <button class="flex-1 py-3 btn-primary rounded-xl text-sm font-medium" @click="confirmAdd">确定</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import { formatDate, showToast, showModal } from '@/utils'
+import { getCustomerDetail, getFollowUpList, createFollowUp, deleteFollowUp } from '@/api/customer'
+import type { Customer, FollowUpRecord, FollowUpType, FollowUpIntent } from '@/types/customer'
+
+const customerId = ref('')
+const customer = ref<Customer | null>(null)
+const followUps = ref<FollowUpRecord[]>([])
+const loading = ref(false)
+const showModalRef = ref(false)
+
+const form = ref({
+  type: 'phone' as FollowUpType,
+  content: '',
+  intent: 'medium' as FollowUpIntent,
+  nextPlan: '',
+  nextTime: ''
+})
+
+const followTypeOptions = [
+  { label: '电话跟进', value: 'phone' },
+  { label: '上门拜访', value: 'visit' },
+  { label: '微信沟通', value: 'wechat' },
+  { label: '邮件往来', value: 'email' },
+  { label: '其他', value: 'other' }
+]
+
+const intentOptions = [
+  { label: '高意向', value: 'high' },
+  { label: '中意向', value: 'medium' },
+  { label: '低意向', value: 'low' }
+]
+
+const followTypeIndex = computed(() => followTypeOptions.findIndex(o => o.value === form.value.type))
+const intentIndex = computed(() => intentOptions.findIndex(o => o.value === form.value.intent))
+const followTypeLabel = computed(() => followTypeOptions.find(o => o.value === form.value.type)?.label || '请选择')
+const intentLabel = computed(() => intentOptions.find(o => o.value === form.value.intent)?.label || '请选择')
+
+const levelClass = computed(() => {
+  const map: Record<string, string> = { A: 'tag-primary', B: 'tag-success', C: 'tag-warning', D: 'tag-gray' }
+  return map[customer.value?.level || 'D'] || 'tag-gray'
+})
+
+const typeClass = computed(() => {
+  const map: Record<string, string> = { enterprise: 'tag-success', personal: 'tag-warning', government: 'tag-primary' }
+  return map[customer.value?.type || ''] || 'tag-gray'
+})
+
+const typeText = computed(() => {
+  const map: Record<string, string> = { enterprise: '企业', personal: '个人', government: '政府' }
+  return map[customer.value?.type || ''] || '-'
+})
+
+function followTypeText(type: FollowUpType) {
+  const map: Record<string, string> = { phone: '电话跟进', visit: '上门拜访', wechat: '微信沟通', email: '邮件往来', other: '其他' }
+  return map[type] || type
+}
+
+function intentText(intent: FollowUpIntent) {
+  const map: Record<string, string> = { high: '高意向', medium: '中意向', low: '低意向' }
+  return map[intent] || intent
+}
+
+function intentClass(intent: FollowUpIntent) {
+  const map: Record<string, string> = { high: 'tag-primary', medium: 'tag-warning', low: 'tag-gray' }
+  return map[intent] || 'tag-gray'
+}
+
+function onFollowTypeChange(e: any) {
+  form.value.type = followTypeOptions[e.detail.value].value as FollowUpType
+}
+
+function onIntentChange(e: any) {
+  form.value.intent = intentOptions[e.detail.value].value as FollowUpIntent
+}
+
+function onNextTimeChange(e: any) {
+  form.value.nextTime = e.detail.value
+}
+
+function openAddModal() {
+  form.value = {
+    type: 'phone',
+    content: '',
+    intent: 'medium',
+    nextPlan: '',
+    nextTime: ''
+  }
+  showModalRef.value = true
+}
+
+function closeModal() {
+  showModalRef.value = false
+}
+
+async function confirmAdd() {
+  if (!customer.value) return
+  if (!form.value.content.trim()) {
+    showToast('请输入跟进内容')
+    return
+  }
+  try {
+    await createFollowUp({
+      customerId: customer.value.id,
+      customerName: customer.value.name,
+      type: form.value.type,
+      content: form.value.content,
+      intent: form.value.intent,
+      nextPlan: form.value.nextPlan || undefined,
+      nextTime: form.value.nextTime ? `${form.value.nextTime}T09:00:00` : undefined,
+      operatorName: '当前销售'
+    })
+    showToast('添加成功')
+    closeModal()
+    await loadData(customer.value.id)
+  } catch (e: any) {
+    showToast(e.message || '添加失败')
+  }
+}
+
+async function deleteRecord(id: string) {
+  const res = await showModal({ title: '确认删除', content: '确定要删除这条跟进记录吗?' })
+  if (!res.confirm) return
+  try {
+    await deleteFollowUp(id)
+    showToast('删除成功')
+    if (customer.value) await loadData(customer.value.id)
+  } catch (e: any) {
+    showToast(e.message || '删除失败')
+  }
+}
+
+async function loadData(id: string) {
+  loading.value = true
+  try {
+    const [detail, follows] = await Promise.all([
+      getCustomerDetail(id),
+      getFollowUpList(id)
+    ])
+    customer.value = detail
+    followUps.value = follows
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  customerId.value = currentPage.options?.id || ''
+  if (customerId.value) loadData(customerId.value)
+})
+
+onShow(() => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1] as any
+  const id = currentPage.options?.id
+  if (id && customerId.value !== id) {
+    customerId.value = id
+    loadData(id)
+  }
+})
+</script>
+
+<style scoped>
+.detail-section-title {
+  border-bottom: none;
+  padding-left: 0;
+  padding-right: 0;
+}
+</style>

+ 86 - 0
src/types/contract.ts

@@ -0,0 +1,86 @@
+export type ContractType = 'annual' | 'quarterly' | 'monthly' | 'single'
+export type ContractStatus = 'pending' | 'active' | 'expiring' | 'completed' | 'terminated'
+export type PaymentStatus = 'unpaid' | 'partpaid' | 'paid' | 'overdue'
+export type PaymentMethod = 'lump_sum' | 'installment' | 'monthly' | 'quarterly'
+
+export interface PaymentPlan {
+  id: string
+  contractId?: string
+  phase: number
+  amount: number
+  plannedDate: string
+  actualDate?: string
+  actualAmount?: number
+  status: PaymentStatus
+  remark?: string
+}
+
+export interface PaymentRecord {
+  id: string
+  contractId: string
+  planId: string
+  amount: number
+  payDate: string
+  payer?: string
+  receiver?: string
+  remark?: string
+  createTime: string
+}
+
+export interface ContractAttachment {
+  id: string
+  name: string
+  url: string
+  size?: number
+  createTime?: string
+}
+
+export interface Contract {
+  id: string
+  code: string
+  name: string
+  customerId: string
+  customerName: string
+  projectId?: string
+  projectName?: string
+  type: ContractType
+  amount: number
+  paidAmount: number
+  paymentMethod: PaymentMethod
+  startDate: string
+  endDate: string
+  signDate?: string
+  serviceContent?: string
+  remark?: string
+  status: ContractStatus
+  paymentPlans: PaymentPlan[]
+  attachments: ContractAttachment[]
+  createTime: string
+  updateTime: string
+}
+
+export interface ContractSearchParams {
+  keyword?: string
+  status?: ContractStatus | ''
+  customerId?: string
+  pageNum?: number
+  pageSize?: number
+}
+
+export interface ContractFormData {
+  id?: string
+  code: string
+  name: string
+  customerId: string
+  projectId?: string
+  type: ContractType
+  amount: number
+  paymentMethod: PaymentMethod
+  startDate: string
+  endDate: string
+  signDate?: string
+  serviceContent?: string
+  remark?: string
+  status: ContractStatus
+  paymentPlans: PaymentPlan[]
+}

+ 78 - 0
src/types/customer.ts

@@ -0,0 +1,78 @@
+export type CustomerType = 'enterprise' | 'personal' | 'government'
+export type CustomerLevel = 'A' | 'B' | 'C' | 'D'
+export type CustomerStatus = 'active' | 'inactive' | 'blacklist'
+export type FollowUpType = 'phone' | 'visit' | 'wechat' | 'email' | 'other'
+export type FollowUpIntent = 'high' | 'medium' | 'low'
+
+export interface CustomerContact {
+  id: string
+  name: string
+  phone: string
+  role?: string
+  isPrimary?: boolean
+}
+
+export interface CustomerFinance {
+  bankName?: string
+  bankAccount?: string
+  taxNo?: string
+  invoiceTitle?: string
+}
+
+export interface Customer {
+  id: string
+  name: string
+  type: CustomerType
+  level: CustomerLevel
+  industry?: string
+  scale?: string
+  source?: string
+  address?: string
+  remark?: string
+  status: CustomerStatus
+  contacts: CustomerContact[]
+  finance?: CustomerFinance
+  projectCount: number
+  contractAmount: number
+  followUpCount: number
+  lastFollowUpTime?: string
+  createTime: string
+  updateTime: string
+}
+
+export interface FollowUpRecord {
+  id: string
+  customerId: string
+  customerName?: string
+  type: FollowUpType
+  content: string
+  intent: FollowUpIntent
+  nextPlan?: string
+  nextTime?: string
+  operatorName?: string
+  createTime: string
+}
+
+export interface CustomerSearchParams {
+  keyword?: string
+  type?: CustomerType | ''
+  level?: CustomerLevel | ''
+  status?: CustomerStatus | ''
+  pageNum?: number
+  pageSize?: number
+}
+
+export interface CustomerFormData {
+  id?: string
+  name: string
+  type: CustomerType
+  level: CustomerLevel
+  industry?: string
+  scale?: string
+  source?: string
+  address?: string
+  remark?: string
+  status: CustomerStatus
+  contacts: CustomerContact[]
+  finance: CustomerFinance
+}