Procházet zdrojové kódy

feat: 接入客户、合同管理并补齐移动端页面与资源

- 新增客户管理、合同管理列表/详情/新增-编辑表单,对接 /api/customer、/api/contract
- request 兼容裸 PageResult(客户列表),补充客户/合同状态、付款方式、金额工具方法
- 补齐缺失的通用组件(Timeline/ConstructionFlowBar/GradientStatCard/QuickEntry/PhotoUploader)与客户合同占位页
- 修复 TopBar 返回按钮与三个列表页的新增入口
- 更新登录页、个人中心界面及登录相关静态资源与文档
zhangzhicheng před 2 týdny
rodič
revize
874bef1a96

binární
docs/assets/login-bg.jpeg


+ 411 - 0
docs/login-page.html

@@ -0,0 +1,411 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>绿水青山 · 登录</title>
+
+  <!-- Tailwind CSS(CDN,国内可访问) -->
+  <script src="https://cdn.tailwindcss.com"></script>
+  <!-- Font Awesome 图标(国内 jsdelivr 镜像) -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" />
+
+  <script>
+    // Tailwind 主题扩展:品牌色 / 嫩芽色 / 山水色
+    tailwind.config = {
+      theme: {
+        extend: {
+          colors: {
+            brand: '#368F6F',   // 主标题 / 按钮起点(青山绿)
+            stream: '#5AB8D0',  // 按钮终点(溪流水蓝)
+            mist: '#709484',    // 副标题(晨雾灰绿)
+            sprout: '#9BCB8A',  // 嫩芽(卡片边框)
+            gold: '#E9C46A',    // 金色微光
+          },
+          fontFamily: {
+            sans: ['"PingFang SC"', '"Microsoft YaHei"', '"Hiragino Sans GB"', 'system-ui', 'sans-serif'],
+          },
+          boxShadow: {
+            glass: '0 18px 50px -12px rgba(54, 143, 111, 0.28)',
+            btn: '0 14px 30px -8px rgba(54, 143, 111, 0.45)',
+          },
+        },
+      },
+    }
+  </script>
+
+  <style>
+    /* ============ 基础 ============ */
+    html, body { height: 100%; }
+    body {
+      margin: 0;
+      -webkit-tap-highlight-color: transparent;
+      font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+      background: #eef4f1;
+    }
+
+    /* 手机外壳:移动端竖屏、居中 */
+    .phone {
+      position: relative;
+      width: 100%;
+      max-width: 28rem;            /* max-w-md */
+      min-height: 100vh;
+      margin: 0 auto;              /* mx-auto */
+      overflow: hidden;
+      background:
+        linear-gradient(180deg, #f7fbf9 0%, #eef6f1 38%, #e7f0ec 100%);
+      isolation: isolate;
+    }
+
+    /* ============ 顶部浅雾渐变天幕 ============ */
+    .sky {
+      position: absolute;
+      inset: 0 0 auto 0;
+      height: 46%;
+      background:
+        linear-gradient(160deg, #fbfefd 0%, #eaf4ef 45%, #dbe8ec 100%);
+      z-index: -3;
+    }
+
+    /* 金色微光(标题后方的柔光晕) */
+    .gold-glow {
+      position: absolute;
+      top: 7%;
+      left: 50%;
+      width: 260px;
+      height: 260px;
+      transform: translateX(-50%);
+      background: radial-gradient(circle, rgba(233,196,106,0.40) 0%, rgba(233,196,106,0.12) 42%, rgba(233,196,106,0) 70%);
+      filter: blur(6px);
+      z-index: -2;
+      animation: breathe 6s ease-in-out infinite;
+    }
+    @keyframes breathe {
+      0%, 100% { opacity: 0.7; transform: translateX(-50%) scale(1); }
+      50%      { opacity: 1;   transform: translateX(-50%) scale(1.08); }
+    }
+
+    /* 云雾朦胧斑 */
+    .mist-blob {
+      position: absolute;
+      border-radius: 9999px;
+      filter: blur(26px);
+      z-index: -2;
+      opacity: 0.55;
+      animation: drift 14s ease-in-out infinite;
+    }
+    .mist-1 { width: 200px; height: 120px; left: -60px;  top: 6%;  background: rgba(255,255,255,0.9); }
+    .mist-2 { width: 240px; height: 140px; right: -90px; top: 14%; background: rgba(214,235,230,0.9); animation-delay: -4s; }
+    .mist-3 { width: 180px; height: 100px; left: 30%;    top: 2%;  background: rgba(255,255,255,0.7); animation-delay: -8s; }
+    @keyframes drift {
+      0%, 100% { transform: translate3d(0, 0, 0); }
+      50%      { transform: translate3d(18px, 10px, 0); }
+    }
+
+    /* ============ 远山剪影 / 溪流(SVG 共用) ============ */
+    .scene { position: absolute; left: 0; right: 0; width: 100%; pointer-events: none; }
+    .scene-top { top: 30%; z-index: -1; opacity: 0.5; }
+    .scene-bottom { bottom: 0; z-index: -1; }
+
+    /* ============ 飘落绿叶粒子 ============ */
+    .leaf {
+      position: absolute;
+      top: -40px;
+      width: 18px;
+      height: 18px;
+      border-radius: 2px 16px 2px 16px;
+      transform: rotate(45deg);
+      z-index: 5;
+      opacity: 0.85;
+      animation: fall linear infinite;
+    }
+    @keyframes fall {
+      0%   { transform: translate3d(0, -40px, 0) rotate(0deg);   opacity: 0; }
+      10%  { opacity: 0.9; }
+      90%  { opacity: 0.8; }
+      100% { transform: translate3d(var(--dx, -30px), 105vh, 0) rotate(540deg); opacity: 0; }
+    }
+
+    /* ============ 磨砂玻璃卡片 ============ */
+    .glass-card {
+      background: rgba(255, 255, 255, 0.80);
+      -webkit-backdrop-filter: blur(12px);
+      backdrop-filter: blur(12px);
+      border: 1px solid rgba(155, 203, 138, 0.30);
+      border-radius: 24px;
+      box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
+    }
+
+    /* 输入框 */
+    .field {
+      display: flex;
+      align-items: center;
+      height: 52px;
+      padding: 0 16px;
+      border-radius: 16px;
+      background: rgba(246, 250, 247, 0.9);
+      border: 1px solid rgba(155, 203, 138, 0.35);
+      transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
+    }
+    .field:focus-within {
+      background: #ffffff;
+      border-color: #368F6F;
+      box-shadow: 0 0 0 4px rgba(54, 143, 111, 0.12);
+    }
+    .field input {
+      flex: 1;
+      height: 100%;
+      border: none;
+      outline: none;
+      background: transparent;
+      font-size: 15px;
+      color: #26403a;
+    }
+    .field input::placeholder { color: #9fb4aa; }
+    .field .fa { width: 20px; text-align: center; color: #7d978c; }
+    .field .toggle { color: #9fb4aa; cursor: pointer; padding: 6px; }
+
+    /* ============ 登录按钮(渐变 + 水波纹边缘) ============ */
+    .login-btn {
+      position: relative;
+      height: 56px;
+      width: 100%;
+      border: none;
+      border-radius: 24px;
+      color: #ffffff;
+      font-size: 17px;
+      font-weight: 700;
+      letter-spacing: 4px;
+      cursor: pointer;
+      overflow: hidden;
+      background: linear-gradient(135deg, #368F6F 0%, #5AB8D0 100%);
+      box-shadow: 0 14px 30px -8px rgba(54, 143, 111, 0.45);
+      transition: transform .15s ease, box-shadow .2s ease, opacity .2s ease;
+    }
+    .login-btn:active { transform: scale(0.985); }
+    .login-btn:disabled { opacity: 0.7; cursor: default; }
+
+    /* 持续呼吸的水波纹边缘 */
+    .login-btn::before {
+      content: "";
+      position: absolute;
+      inset: -3px;
+      border-radius: 26px;
+      border: 2px solid rgba(90, 184, 208, 0.55);
+      animation: ripple 2.6s ease-out infinite;
+      pointer-events: none;
+    }
+    @keyframes ripple {
+      0%   { transform: scale(0.96); opacity: 0.7; }
+      100% { transform: scale(1.08); opacity: 0; }
+    }
+    /* 点击水波圆斑 */
+    .login-btn .ink {
+      position: absolute;
+      border-radius: 50%;
+      transform: scale(0);
+      background: rgba(255, 255, 255, 0.45);
+      animation: ink .6s ease-out;
+      pointer-events: none;
+    }
+    @keyframes ink {
+      to { transform: scale(2.6); opacity: 0; }
+    }
+
+    /* ============ 底部草叶浮动 ============ */
+    .float-slow { animation: float 6s ease-in-out infinite; }
+    .float-slower { animation: float 8s ease-in-out infinite; animation-delay: -2s; }
+    @keyframes float {
+      0%, 100% { transform: translateY(0); }
+      50%      { transform: translateY(-8px); }
+    }
+
+    /* 链接 */
+    .link { color: #368F6F; font-size: 13px; }
+    .link:active { opacity: 0.7; }
+  </style>
+</head>
+
+<body>
+  <!-- ============ 手机容器 ============ -->
+  <main class="phone flex flex-col">
+
+    <!-- 天幕 / 光 / 雾(装饰层) -->
+    <div class="sky"></div>
+    <div class="gold-glow"></div>
+    <div class="mist-blob mist-1"></div>
+    <div class="mist-blob mist-2"></div>
+    <div class="mist-blob mist-3"></div>
+
+    <!-- 顶部远山剪影 -->
+    <svg class="scene scene-top" viewBox="0 0 400 120" preserveAspectRatio="none" aria-hidden="true">
+      <path d="M0,120 L0,70 Q60,20 120,68 Q180,16 240,66 Q310,30 400,72 L400,120 Z" fill="#7fb3a0" opacity="0.35"/>
+      <path d="M0,120 L0,86 Q70,48 140,84 Q210,46 280,86 Q340,58 400,86 L400,120 Z" fill="#5e9c86" opacity="0.40"/>
+    </svg>
+
+    <!-- 顶部溪流曲线 -->
+    <svg class="scene scene-top" style="top:40%; opacity:0.45" viewBox="0 0 400 60" preserveAspectRatio="none" aria-hidden="true">
+      <path d="M0,30 C60,10 90,50 150,30 S260,10 320,30 S380,42 400,30" fill="none" stroke="#5AB8D0" stroke-width="2" opacity="0.5"/>
+      <path d="M0,44 C70,26 100,58 160,42 S270,24 400,44" fill="none" stroke="#5AB8D0" stroke-width="1.4" opacity="0.35"/>
+    </svg>
+
+    <!-- 飘落绿叶粒子 -->
+    <i class="leaf" style="left:8%;  --dx:-40px; background:linear-gradient(135deg,#7cc68a,#368F6F); animation-duration:11s; animation-delay:-1s;"></i>
+    <i class="leaf" style="left:22%; --dx:30px;  background:linear-gradient(135deg,#9BCB8A,#5ab07a); width:14px;height:14px; animation-duration:14s; animation-delay:-6s;"></i>
+    <i class="leaf" style="left:40%; --dx:-20px; background:linear-gradient(135deg,#6fbf8f,#2f7f62); width:20px;height:20px; animation-duration:12s; animation-delay:-3s;"></i>
+    <i class="leaf" style="left:58%; --dx:40px;  background:linear-gradient(135deg,#a9d89c,#368F6F); width:13px;height:13px; animation-duration:15s; animation-delay:-9s;"></i>
+    <i class="leaf" style="left:74%; --dx:-34px; background:linear-gradient(135deg,#8bcf9a,#3f9b73); width:16px;height:16px; animation-duration:13s; animation-delay:-4s;"></i>
+    <i class="leaf" style="left:90%; --dx:24px;  background:linear-gradient(135deg,#9BCB8A,#5ab07a); width:12px;height:12px; animation-duration:16s; animation-delay:-11s;"></i>
+
+    <!-- ============ 内容层 ============ -->
+    <div class="relative z-10 flex flex-col flex-1 px-7 pt-16 pb-8">
+
+      <!-- 品牌区 -->
+      <header class="flex flex-col items-center">
+        <!-- Logo:山形 + 水滴 + 三片叶子 融合 -->
+        <div class="w-20 h-20 rounded-[22px] bg-white shadow-glass flex items-center justify-center">
+          <svg viewBox="0 0 64 64" width="54" height="54" aria-label="绿水青山">
+            <defs>
+              <linearGradient id="leafGrad" x1="0" y1="0" x2="1" y2="1">
+                <stop offset="0" stop-color="#7fd08a"/>
+                <stop offset="1" stop-color="#368F6F"/>
+              </linearGradient>
+              <linearGradient id="dropGrad" x1="0" y1="0" x2="0" y2="1">
+                <stop offset="0" stop-color="#8fd0e0"/>
+                <stop offset="1" stop-color="#5AB8D0"/>
+              </linearGradient>
+            </defs>
+            <!-- 山形 -->
+            <path d="M6 46 L24 22 L34 34 L44 20 L58 46 Z" fill="#e8f3ec" stroke="#cfe6d6" stroke-width="1.5" stroke-linejoin="round"/>
+            <!-- 水滴 -->
+            <path d="M32 14 C32 14 24 23 24 30 a8 8 0 0 0 16 0 C40 23 32 14 32 14 Z" fill="url(#dropGrad)" opacity="0.92"/>
+            <!-- 三片叶子 -->
+            <path d="M32 50 C24 44 20 36 22 28 C30 30 34 38 32 50 Z" fill="url(#leafGrad)"/>
+            <path d="M32 50 C40 44 44 36 42 28 C34 30 30 38 32 50 Z" fill="url(#leafGrad)" opacity="0.9"/>
+            <path d="M32 52 C30 42 32 34 38 28 C40 36 38 46 32 52 Z" fill="#2f7f62" opacity="0.55"/>
+          </svg>
+        </div>
+
+        <h1 class="mt-5 text-[32px] font-extrabold tracking-[6px] text-brand">绿水青山</h1>
+        <p class="mt-2 text-sm tracking-[3px] text-mist">与自然同行</p>
+      </header>
+
+      <!-- 登录卡片 -->
+      <section class="glass-card mt-10 px-6 py-7">
+        <h2 class="text-center text-lg font-bold text-brand">欢迎回来</h2>
+
+        <form class="mt-6 space-y-4" id="loginForm" novalidate>
+          <!-- 账号 -->
+          <div class="field">
+            <i class="fa fa-user mr-3"></i>
+            <input type="text" id="account" name="account" placeholder="请输入账号" autocomplete="username" />
+          </div>
+
+          <!-- 密码 -->
+          <div>
+            <div class="field">
+              <i class="fa fa-lock mr-3"></i>
+              <input type="password" id="password" name="password" placeholder="请输入密码" autocomplete="current-password" />
+              <i class="fa fa-eye toggle" id="togglePwd" aria-label="显示密码"></i>
+            </div>
+            <div class="mt-2 flex justify-end">
+              <a class="link" href="javascript:void(0)" id="forgot">忘记密码?</a>
+            </div>
+          </div>
+
+          <!-- 登录按钮 -->
+          <button type="submit" class="login-btn mt-2" id="loginBtn">登 录</button>
+        </form>
+      </section>
+
+      <!-- 协议 / 其他入口 -->
+      <p class="mt-6 text-center text-xs text-mist/80">
+        登录即代表同意 <span class="link">服务协议</span> 与 <span class="link">隐私政策</span>
+      </p>
+
+      <div class="flex-1"></div>
+      <p class="mt-6 text-center text-[11px] text-mist/60">v1.0.0 · 绿水青山环境服务</p>
+    </div>
+
+    <!-- ============ 底部山水(低透明度 + 浮动) ============ -->
+    <svg class="scene scene-bottom float-slower" style="opacity:0.55" viewBox="0 0 400 140" preserveAspectRatio="none" aria-hidden="true">
+      <path d="M0,140 L0,78 Q70,30 150,78 Q230,26 320,80 Q360,56 400,82 L400,140 Z" fill="#7fb3a0" opacity="0.30"/>
+      <path d="M0,140 L0,98 Q80,64 160,100 Q250,58 400,100 L400,140 Z" fill="#368F6F" opacity="0.22"/>
+    </svg>
+
+    <!-- 底部溪流波纹 -->
+    <svg class="scene scene-bottom float-slow" style="opacity:0.5" viewBox="0 0 400 70" preserveAspectRatio="none" aria-hidden="true">
+      <path d="M0,26 C60,8 100,44 160,26 S280,8 400,30" fill="none" stroke="#5AB8D0" stroke-width="2" opacity="0.45"/>
+      <path d="M0,44 C70,26 110,58 180,42 S300,26 400,46" fill="none" stroke="#5AB8D0" stroke-width="1.4" opacity="0.32"/>
+      <path d="M0,58 C80,46 120,66 200,56 S320,46 400,60" fill="none" stroke="#5AB8D0" stroke-width="1" opacity="0.22"/>
+    </svg>
+
+    <!-- 底部草叶纹理 / 绿叶点缀 -->
+    <div class="scene-bottom" style="bottom:14px; z-index:0; opacity:0.8;">
+      <i class="leaf float-slow"  style="top:auto; bottom:6px; left:24px;  position:absolute; width:16px;height:16px; background:linear-gradient(135deg,#9BCB8A,#368F6F);"></i>
+      <i class="leaf float-slower" style="top:auto; bottom:2px; right:30px; position:absolute; width:14px;height:14px; background:linear-gradient(135deg,#8bcf9a,#2f7f62);"></i>
+      <i class="leaf float-slow"  style="top:auto; bottom:22px; right:120px; position:absolute; width:12px;height:12px; background:linear-gradient(135deg,#a9d89c,#5ab07a);"></i>
+    </div>
+  </main>
+
+  <script>
+    // 密码显隐切换
+    const pwd = document.getElementById('password')
+    const toggle = document.getElementById('togglePwd')
+    toggle.addEventListener('click', () => {
+      const show = pwd.type === 'password'
+      pwd.type = show ? 'text' : 'password'
+      toggle.classList.toggle('fa-eye', !show)
+      toggle.classList.toggle('fa-eye-slash', show)
+    })
+
+    // 忘记密码
+    document.getElementById('forgot').addEventListener('click', () => {
+      alert('请联系管理员重置密码')
+    })
+
+    // 登录提交 + 按钮水波纹 + 加载态
+    const form = document.getElementById('loginForm')
+    const btn = document.getElementById('loginBtn')
+
+    function spawnInk(x, y) {
+      const ink = document.createElement('span')
+      ink.className = 'ink'
+      const size = 120
+      ink.style.width = ink.style.height = size + 'px'
+      ink.style.left = (x - size / 2) + 'px'
+      ink.style.top = (y - size / 2) + 'px'
+      btn.appendChild(ink)
+      setTimeout(() => ink.remove(), 600)
+    }
+
+    form.addEventListener('submit', (e) => {
+      e.preventDefault()
+      const account = document.getElementById('account').value.trim()
+      if (!account || !pwd.value) {
+        // 简单校验反馈
+        btn.animate(
+          [{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(6px)' }, { transform: 'translateX(0)' }],
+          { duration: 240, iterations: 2 }
+        )
+        return
+      }
+      const rect = btn.getBoundingClientRect()
+      // 点击位置水波(若来自键盘则居中)
+      const x = (e.clientX || 0) ? e.clientX - rect.left : rect.width / 2
+      const y = (e.clientY || 0) ? e.clientY - rect.top : rect.height / 2
+      spawnInk(x, y)
+
+      btn.disabled = true
+      const raw = btn.textContent
+      btn.textContent = '登录中…'
+      // 模拟登录请求
+      setTimeout(() => {
+        btn.disabled = false
+        btn.textContent = raw
+        alert('登录成功,欢迎回来,' + account + '!')
+      }, 900)
+    })
+  </script>
+</body>
+</html>

+ 40 - 0
src/api/contract.ts

@@ -0,0 +1,40 @@
+import { get, post, put, del } from './request'
+import type { Contract, ContractStatus, PageResult, PaymentMethod } from '../types'
+
+export interface ContractParams {
+  contractNo: string
+  customerId: number | string
+  customerName?: string
+  projectId?: number | string
+  contractAmount: number
+  paymentMethod: PaymentMethod
+  signDate?: string
+  servicePeriod?: string
+  status?: ContractStatus
+  remark?: string
+}
+
+export interface ContractListQuery {
+  keyword?: string
+  status?: ContractStatus
+}
+
+export function getContractList(pageNum = 1, pageSize = 10, query: ContractListQuery = {}) {
+  return get<PageResult<Contract>>('/contract/list', { pageNum, pageSize, ...query })
+}
+
+export function getContractDetail(id: number | string) {
+  return get<Contract>(`/contract/${id}`)
+}
+
+export function createContract(data: ContractParams) {
+  return post<number>('/contract', data)
+}
+
+export function updateContract(data: ContractParams & { id: number | string }) {
+  return put<void>('/contract', data)
+}
+
+export function deleteContract(id: number | string) {
+  return del<void>(`/contract/${id}`)
+}

+ 37 - 0
src/api/customer.ts

@@ -0,0 +1,37 @@
+import { get, post, put, del } from './request'
+import type { Customer, CustomerStatus, PageResult } from '../types'
+
+export interface CustomerParams {
+  customerName: string
+  contactName?: string
+  phone?: string
+  province?: string
+  city?: string
+  district?: string
+  street?: string
+  detailAddress?: string
+  latitude?: string
+  longitude?: string
+  status?: CustomerStatus
+  remark?: string
+}
+
+export function getCustomerList(pageNum = 1, pageSize = 10, keyword?: string) {
+  return get<PageResult<Customer>>('/customer/list', { pageNum, pageSize, keyword })
+}
+
+export function getCustomerDetail(id: number | string) {
+  return get<Customer>(`/customer/${id}`)
+}
+
+export function createCustomer(data: CustomerParams) {
+  return post<boolean>('/customer', data)
+}
+
+export function updateCustomer(data: CustomerParams & { id: number | string }) {
+  return put<boolean>('/customer', data)
+}
+
+export function deleteCustomer(id: number | string) {
+  return del<boolean>(`/customer/${id}`)
+}

+ 15 - 6
src/api/request.ts

@@ -1,6 +1,6 @@
 // HTTP 请求封装
 
-const BASE_URL = 'http://localhost:8081/api'
+const BASE_URL = 'http://localhost:8080/api'
 
 interface RequestOptions {
   url: string
@@ -63,14 +63,23 @@ export async function request<T = any>(options: RequestOptions): Promise<T> {
       success: (res) => {
         if (loading) uni.hideLoading()
 
-        const response = res.data as ApiResponse<T>
+        const payload = res.data as any
 
         if (res.statusCode === 200) {
-          if (response.code === 200) {
-            resolve(response.data)
+          // R 包装:{ code, msg, data }
+          if (payload && typeof payload.code === 'number') {
+            if (payload.code === 200) {
+              resolve(payload.data)
+            } else {
+              uni.showToast({ title: translateErrorMsg(payload.msg) || '请求失败', icon: 'none' })
+              reject(new Error(translateErrorMsg(payload.msg)))
+            }
+          } else if (payload && Array.isArray(payload.rows) && typeof payload.total === 'number') {
+            // 裸 PageResult(如客户列表直接返回分页对象)
+            resolve(payload as T)
           } else {
-            uni.showToast({ title: translateErrorMsg(response.msg) || '请求失败', icon: 'none' })
-            reject(new Error(translateErrorMsg(response.msg)))
+            // 其他直接返回的数据
+            resolve(payload as T)
           }
         } else if (res.statusCode === 401) {
           uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })

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

@@ -0,0 +1,200 @@
+<template>
+  <view class="flow-bar card p-4">
+    <view class="flex items-start justify-between">
+      <view
+        v-for="(step, index) in steps"
+        :key="step.key"
+        class="flow-step"
+      >
+        <view class="flow-node-wrap">
+          <view
+            v-if="index !== 0"
+            class="flow-line flow-line-left"
+            :class="step.active || step.current ? 'flow-line-active' : ''"
+          />
+          <view
+            class="flow-node"
+            :class="nodeClass(step)"
+          >
+            <text v-if="step.active && !step.current" class="uni-icons uniui-checkmarkempty flow-check"></text>
+            <text v-else class="flow-node-text">{{ index + 1 }}</text>
+          </view>
+          <view
+            v-if="index !== steps.length - 1"
+            class="flow-line flow-line-right"
+            :class="nextActive(index) ? 'flow-line-active' : ''"
+          />
+        </view>
+        <text class="flow-label" :class="step.current ? 'flow-label-current' : step.active ? 'flow-label-active' : ''">
+          {{ step.label }}
+        </text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { TimelineRecord } from './Timeline.vue'
+
+interface FlowStep {
+  key: string
+  label: string
+  threshold: number
+  active: boolean
+  current: boolean
+}
+
+const props = withDefaults(
+  defineProps<{
+    status: string
+    timeline?: TimelineRecord[]
+  }>(),
+  {
+    timeline: () => []
+  }
+)
+
+const STATUS_ORDER: string[] = [
+  'pending',
+  'confirmed',
+  'scheduled',
+  'departed',
+  'arrived',
+  'constructing',
+  'inspecting',
+  'completed'
+]
+
+const currentIndex = computed(() => {
+  const idx = STATUS_ORDER.indexOf(props.status)
+  return idx < 0 ? 0 : idx
+})
+
+const steps = computed<FlowStep[]>(() => {
+  const idx = currentIndex.value
+  const defs: Omit<FlowStep, 'active' | 'current'>[] = [
+    { key: 'confirm', label: '确认', threshold: 1 },
+    { key: 'depart', label: '出车', threshold: 3 },
+    { key: 'arrive', label: '到达', threshold: 4 },
+    { key: 'construct', label: '施工', threshold: 5 },
+    { key: 'inspect', label: '验收', threshold: 6 },
+    { key: 'complete', label: '完成', threshold: 7 }
+  ]
+  return defs.map(d => ({
+    ...d,
+    active: idx >= d.threshold,
+    current: idx === d.threshold
+  }))
+})
+
+function nodeClass(step: FlowStep): string {
+  if (step.current) return 'flow-node-current'
+  if (step.active) return 'flow-node-active'
+  return ''
+}
+
+function nextActive(index: number): boolean {
+  const next = steps.value[index + 1]
+  return !!next && next.active
+}
+</script>
+
+<style scoped>
+.flow-bar {
+  margin-bottom: 16px;
+}
+
+.flow-step {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-width: 0;
+}
+
+.flow-node-wrap {
+  width: 100%;
+  height: 28px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.flow-line {
+  position: absolute;
+  top: 50%;
+  height: 2px;
+  background-color: #e5e7eb;
+  transform: translateY(-50%);
+}
+
+.flow-line-left {
+  left: 0;
+  right: 50%;
+}
+
+.flow-line-right {
+  left: 50%;
+  right: 0;
+}
+
+.flow-line-active {
+  background-color: #2563eb;
+}
+
+.flow-node {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  background-color: #f3f4f6;
+  border: 2px solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+}
+
+.flow-node-active {
+  background-color: #2563eb;
+  border-color: #2563eb;
+}
+
+.flow-node-current {
+  background-color: #fff;
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
+}
+
+.flow-check {
+  color: #fff;
+  font-size: 12px;
+}
+
+.flow-node-text {
+  font-size: 11px;
+  color: #9ca3af;
+}
+
+.flow-node-current .flow-node-text {
+  color: #2563eb;
+  font-weight: 600;
+}
+
+.flow-label {
+  margin-top: 6px;
+  font-size: 11px;
+  color: #9ca3af;
+  text-align: center;
+}
+
+.flow-label-active {
+  color: #2563eb;
+}
+
+.flow-label-current {
+  color: #2563eb;
+  font-weight: 600;
+}
+</style>

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

@@ -0,0 +1,66 @@
+<template>
+  <view
+    class="gradient-stat-card"
+    :style="{ background: gradient }"
+    hover-class="stat-hover"
+    @click="$emit('click')"
+  >
+    <text class="stat-value">{{ displayValue }}</text>
+    <text class="stat-label">{{ label }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    value: number | string
+    label: string
+    gradient?: string
+  }>(),
+  {
+    gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
+  }
+)
+
+defineEmits<{
+  click: []
+}>()
+
+const displayValue = computed(() => {
+  if (typeof props.value === 'number') return props.value
+  return props.value ?? 0
+})
+</script>
+
+<style scoped>
+.gradient-stat-card {
+  border-radius: 12px;
+  padding: 14px 8px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 84px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.stat-value {
+  color: #ffffff;
+  font-size: 24px;
+  font-weight: 700;
+  line-height: 1.1;
+}
+
+.stat-label {
+  color: rgba(255, 255, 255, 0.9);
+  font-size: 12px;
+  margin-top: 6px;
+}
+
+.stat-hover {
+  opacity: 0.88;
+  transform: scale(0.98);
+}
+</style>

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

@@ -0,0 +1,86 @@
+<template>
+  <view class="photo-uploader">
+    <view class="flex flex-wrap gap-2">
+      <view
+        v-for="(url, index) in modelValue"
+        :key="index"
+        class="relative w-20 h-20 rounded-lg overflow-hidden border border-gray-200"
+      >
+        <image
+          :src="url"
+          class="w-full h-full object-cover"
+          mode="aspectFill"
+          @click="previewMedia(index)"
+        />
+        <view
+          class="absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center -mt-1 -mr-1 z-10"
+          @click="removeMedia(index)"
+        >
+          <text class="uni-icons uniui-close text-white text-xs"></text>
+        </view>
+      </view>
+
+      <view
+        v-if="modelValue.length < maxCount"
+        class="w-20 h-20 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center bg-gray-50"
+        @click="chooseMedia"
+      >
+        <text class="uni-icons uniui-camera-filled text-gray-400 text-xl"></text>
+        <text class="text-xs text-gray-400 mt-1">{{ modelValue.length }}/{{ maxCount }}</text>
+      </view>
+    </view>
+
+    <text v-if="tip" class="text-xs text-gray-400 mt-2 block">{{ tip }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+const props = withDefaults(
+  defineProps<{
+    modelValue: string[]
+    maxCount?: number
+    tip?: string
+    mediaType?: ('image' | 'video')[]
+  }>(),
+  {
+    maxCount: 9,
+    tip: '',
+    mediaType: () => ['image', 'video']
+  }
+)
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string[]]
+}>()
+
+function chooseMedia() {
+  const remainCount = props.maxCount - props.modelValue.length
+  if (remainCount <= 0) {
+    uni.showToast({ title: '最多上传' + props.maxCount + '个', icon: 'none' })
+    return
+  }
+
+  uni.chooseMedia({
+    count: remainCount,
+    mediaType: props.mediaType,
+    sourceType: ['album', 'camera'],
+    success: (res) => {
+      const paths = res.tempFiles.map(f => f.tempFilePath)
+      emit('update:modelValue', [...props.modelValue, ...paths])
+    }
+  })
+}
+
+function removeMedia(index: number) {
+  const list = [...props.modelValue]
+  list.splice(index, 1)
+  emit('update:modelValue', list)
+}
+
+function previewMedia(index: number) {
+  uni.previewImage({
+    urls: props.modelValue,
+    current: props.modelValue[index]
+  })
+}
+</script>

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

@@ -0,0 +1,62 @@
+<template>
+  <view
+    class="quick-entry"
+    hover-class="entry-hover"
+    @click="$emit('click')"
+  >
+    <view class="entry-icon">
+      <text class="uni-icons text-primary text-xl" :class="icon"></text>
+    </view>
+    <text class="entry-label">{{ label }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    label: string
+    icon?: string
+  }>(),
+  {
+    icon: 'uniui-folder-add-filled'
+  }
+)
+
+defineEmits<{
+  click: []
+}>()
+</script>
+
+<style scoped>
+.quick-entry {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 12px 4px;
+  border-radius: 12px;
+  background-color: #f9fafb;
+}
+
+.entry-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 12px;
+  background-color: #eff6ff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.entry-label {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #374151;
+  font-weight: 500;
+}
+
+.entry-hover {
+  background-color: #f3f4f6;
+  transform: scale(0.98);
+}
+</style>

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

@@ -0,0 +1,163 @@
+<template>
+  <view class="timeline">
+    <view v-if="!records || records.length === 0" class="py-4 text-center">
+      <text class="text-xs text-gray-400">暂无记录</text>
+    </view>
+    <view
+      v-for="(record, index) in records"
+      :key="index"
+      class="timeline-item"
+    >
+      <view class="timeline-marker">
+        <view
+          class="timeline-dot"
+          :class="isActive(record, index) ? 'timeline-dot-active' : ''"
+        />
+        <view v-if="index !== records.length - 1" class="timeline-line" />
+      </view>
+      <view class="timeline-content">
+        <view class="flex items-center justify-between mb-1">
+          <text class="text-sm font-medium text-gray-800">{{ record.action }}</text>
+          <text v-if="isActive(record, index)" class="timeline-current">当前</text>
+        </view>
+        <view class="flex items-center text-xs text-gray-400">
+          <text class="uni-icons uniui-clock-filled mr-1"></text>
+          <text>{{ record.time }}</text>
+        </view>
+        <view v-if="record.operator" class="flex items-center mt-1">
+          <text class="timeline-role" :class="roleClass(record.role)">{{ roleText(record.role) }}</text>
+          <text class="text-xs text-gray-500 ml-1">{{ record.operator }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts">
+export interface TimelineRecord {
+  time: string
+  action: string
+  operator: string
+  role: string
+  isCurrent?: boolean
+}
+</script>
+
+<script setup lang="ts">
+const props = withDefaults(
+  defineProps<{
+    records: TimelineRecord[]
+  }>(),
+  {
+    records: () => []
+  }
+)
+
+function isActive(record: TimelineRecord, index: number): boolean {
+  return record.isCurrent === true || index === props.records.length - 1
+}
+
+function roleText(role: string): string {
+  const map: Record<string, string> = {
+    sales: '销售',
+    dispatch: '调度',
+    construction: '施工',
+    worker: '施工'
+  }
+  return map[role] || role || '系统'
+}
+
+function roleClass(role: string): string {
+  const map: Record<string, string> = {
+    sales: 'role-sales',
+    dispatch: 'role-dispatch',
+    construction: 'role-construction',
+    worker: 'role-construction'
+  }
+  return map[role] || 'role-default'
+}
+</script>
+
+<style scoped>
+.timeline {
+  padding: 4px 0;
+}
+
+.timeline-item {
+  display: flex;
+  position: relative;
+}
+
+.timeline-marker {
+  width: 20px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.timeline-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  background-color: #d1d5db;
+  border: 2px solid #fff;
+  box-shadow: 0 0 0 1px #d1d5db;
+  margin-top: 4px;
+  z-index: 1;
+}
+
+.timeline-dot-active {
+  background-color: #2563eb;
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25);
+}
+
+.timeline-line {
+  width: 2px;
+  flex: 1;
+  background-color: #e5e7eb;
+  margin-top: 4px;
+  margin-bottom: -4px;
+}
+
+.timeline-content {
+  flex: 1;
+  padding-left: 12px;
+  padding-bottom: 16px;
+  min-width: 0;
+}
+
+.timeline-current {
+  font-size: 10px;
+  color: #2563eb;
+  background-color: #eff6ff;
+  padding: 1px 6px;
+  border-radius: 8px;
+}
+
+.timeline-role {
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+
+.role-sales {
+  color: #2563eb;
+  background-color: #eff6ff;
+}
+
+.role-dispatch {
+  color: #f59e0b;
+  background-color: #fff7ed;
+}
+
+.role-construction {
+  color: #10b981;
+  background-color: #ecfdf5;
+}
+
+.role-default {
+  color: #6b7280;
+  background-color: #f3f4f6;
+}
+</style>

+ 271 - 66
src/components/my/ProfileView.vue

@@ -1,79 +1,156 @@
 <template>
-  <view class="app-container pb-40">
+  <view class="app-container pb-24">
     <!-- 顶部个人信息 -->
-    <view class="px-4 pb-6 rounded-b-3xl" style="background: linear-gradient(135deg, #1e3a8a, #1e40af);">
+    <view class="header-gradient">
       <StatusBar />
-      <view class="flex items-center pt-3">
-        <view class="w-16 h-16 rounded-full flex items-center justify-center mr-4" style="background-color: rgba(255, 255, 255, 0.2);">
-          <text class="uni-icons uniui-person-filled text-white text-2xl"></text>
-        </view>
-        <view class="flex-1 min-w-0">
-          <text class="text-white text-lg font-semibold block truncate">{{ displayName }}</text>
-          <view class="flex items-center flex-wrap mt-1">
-            <text v-if="primaryMeta" class="text-white text-sm mr-3" style="opacity: 0.8;">{{ primaryMeta }}</text>
-            <text v-if="secondaryMeta" class="text-white text-sm" style="opacity: 0.8;">{{ secondaryMeta }}</text>
+      <view class="px-4 pt-4 pb-8">
+        <view class="flex items-center">
+          <view class="profile-avatar mr-4">
+            <text class="uni-icons uniui-person-filled text-primary text-3xl"></text>
+          </view>
+          <view class="flex-1 min-w-0">
+            <view class="flex items-center flex-wrap">
+              <text class="text-white text-xl font-bold truncate">{{ displayName }}</text>
+              <text class="role-chip">{{ roleLabel }}</text>
+            </view>
+            <view v-if="primaryMeta || secondaryMeta" class="flex items-center flex-wrap mt-2">
+              <text v-if="primaryMeta" class="profile-meta">{{ primaryMeta }}</text>
+              <text v-if="primaryMeta && secondaryMeta" class="profile-meta-dot">·</text>
+              <text v-if="secondaryMeta" class="profile-meta">{{ secondaryMeta }}</text>
+            </view>
+            <view v-if="userInfo?.employeeNo" class="mt-2">
+              <text class="emp-chip">工号 {{ userInfo.employeeNo }}</text>
+            </view>
           </view>
         </view>
       </view>
     </view>
 
-    <!-- 功能列表 -->
-    <view class="px-4 py-4 gap-y-4">
+    <!-- 内容区 -->
+    <view class="px-4 -mt-4">
       <!-- 账号信息 -->
-      <view class="bg-white rounded-xl shadow-sm p-4">
-        <text class="text-base font-semibold text-gray-800 block mb-3">账号信息</text>
-        <uni-list :border="false">
-          <uni-list-item title="姓名" :note="userInfo?.name || '-'" />
+      <view class="card">
+        <view class="flex items-center px-4 pt-4 pb-3">
+          <view class="section-bar mr-2"></view>
+          <text class="text-base font-bold text-gray-800">账号信息</text>
+        </view>
+        <view class="px-4 pb-2">
+          <view class="info-row">
+            <text class="info-label">姓名</text>
+            <text class="info-value">{{ userInfo?.name || '-' }}</text>
+          </view>
           <template v-if="role === 'construction'">
-            <uni-list-item title="班组" :note="userInfo?.team || '-'" />
-            <uni-list-item title="职位" :note="userInfo?.position || '-'" />
-            <uni-list-item title="工号" :note="userInfo?.employeeNo || '-'" />
-            <uni-list-item title="联系电话" :note="userInfo?.phone || '-'" />
+            <view class="info-row">
+              <text class="info-label">班组</text>
+              <text class="info-value">{{ userInfo?.team || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">职位</text>
+              <text class="info-value">{{ userInfo?.position || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">工号</text>
+              <text class="info-value">{{ userInfo?.employeeNo || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">联系电话</text>
+              <text class="info-value">{{ userInfo?.phone || '-' }}</text>
+            </view>
           </template>
           <template v-else>
-            <uni-list-item title="部门" :note="userInfo?.department || '-'" />
-            <uni-list-item title="职位" :note="userInfo?.position || '-'" />
-            <uni-list-item title="工号" :note="userInfo?.employeeNo || '-'" />
-            <uni-list-item title="电话" :note="userInfo?.phone || '-'" />
-            <uni-list-item title="邮箱" :note="userInfo?.email || '-'" />
-            <uni-list-item title="入职日期" :note="userInfo?.hireDate || '-'" />
+            <view class="info-row">
+              <text class="info-label">部门</text>
+              <text class="info-value">{{ userInfo?.department || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">职位</text>
+              <text class="info-value">{{ userInfo?.position || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">工号</text>
+              <text class="info-value">{{ userInfo?.employeeNo || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">电话</text>
+              <text class="info-value">{{ userInfo?.phone || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">邮箱</text>
+              <text class="info-value break-all">{{ userInfo?.email || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">入职日期</text>
+              <text class="info-value">{{ userInfo?.hireDate || '-' }}</text>
+            </view>
           </template>
-        </uni-list>
+        </view>
       </view>
 
       <!-- 功能菜单 -->
-      <view class="bg-white rounded-xl shadow-sm p-4">
-        <text class="text-base font-semibold text-gray-800 block mb-3">功能菜单</text>
-        <uni-list :border="false">
-          <view hover-class="scale-down" :hover-start-time="0" :hover-stay-time="150" @click="goToChangePassword">
-            <uni-list-item
-              title="修改密码"
-              icon="uniui-locked-filled"
-              show-arrow
-            />
+      <view class="card p-2">
+        <view
+          class="menu-item"
+          hover-class="menu-hover"
+          :hover-start-time="0"
+          :hover-stay-time="120"
+          @click="goToChangePassword"
+        >
+          <view class="menu-icon bg-blue-50">
+            <text class="uni-icons uniui-locked-filled text-blue-600"></text>
+          </view>
+          <view class="flex-1 min-w-0">
+            <text class="menu-title">修改密码</text>
+            <text class="menu-desc">定期更换,保障账号安全</text>
           </view>
-          <view hover-class="scale-down" :hover-start-time="0" :hover-stay-time="150" @click="goToNotices">
-            <uni-list-item
-              title="通知公告"
-              icon="uniui-notification-filled"
-              show-arrow
-            />
+          <text class="uni-icons uniui-arrowright text-gray-400"></text>
+        </view>
+        <view
+          class="menu-item"
+          hover-class="menu-hover"
+          :hover-start-time="0"
+          :hover-stay-time="120"
+          @click="goToNotices"
+        >
+          <view class="menu-icon bg-orange-50">
+            <text class="uni-icons uniui-notification-filled text-orange-600"></text>
+          </view>
+          <view class="flex-1 min-w-0">
+            <text class="menu-title">通知公告</text>
+            <text class="menu-desc">查看公司最新通知与公告</text>
+          </view>
+          <text class="uni-icons uniui-arrowright text-gray-400"></text>
+        </view>
+        <view
+          class="menu-item"
+          hover-class="menu-hover"
+          :hover-start-time="0"
+          :hover-stay-time="120"
+          @click="goToHelp"
+        >
+          <view class="menu-icon bg-green-50">
+            <text class="uni-icons uniui-help-filled text-green-600"></text>
           </view>
-          <view hover-class="scale-down" :hover-start-time="0" :hover-stay-time="150" @click="goToHelp">
-            <uni-list-item
-              title="帮助中心"
-              icon="uniui-help-filled"
-              show-arrow
-            />
+          <view class="flex-1 min-w-0">
+            <text class="menu-title">帮助中心</text>
+            <text class="menu-desc">常见问题与使用指南</text>
           </view>
-        </uni-list>
+          <text class="uni-icons uniui-arrowright text-gray-400"></text>
+        </view>
       </view>
 
       <!-- 退出登录 -->
-      <view class="mt-4">
-        <button class="w-full py-3 bg-red-500 text-white rounded-xl font-medium transition-colors logout-btn" @click="handleLogout">
-          退出登录
-        </button>
+      <view
+        class="card logout-row"
+        hover-class="logout-hover"
+        :hover-start-time="0"
+        :hover-stay-time="120"
+        @click="handleLogout"
+      >
+        <view class="menu-icon bg-red-50 mr-3">
+          <text class="uni-icons uniui-loop text-red-600"></text>
+        </view>
+        <text class="flex-1 text-base font-medium text-red-600">退出登录</text>
+        <text class="uni-icons uniui-arrowright text-red-300"></text>
       </view>
     </view>
   </view>
@@ -83,14 +160,21 @@
 import { computed } from 'vue'
 import { useAuthStore } from '@/stores/auth'
 import StatusBar from '@/components/common/StatusBar.vue'
-import UniList from '@/components/uni-list/uni-list.vue'
-import UniListItem from '@/components/uni-list/uni-list-item.vue'
 
 const authStore = useAuthStore()
 
 const userInfo = computed(() => authStore.user)
 const role = computed(() => userInfo.value?.role || 'sales')
 
+const roleLabel = computed(() => {
+  const map: Record<string, string> = {
+    sales: '销售',
+    dispatch: '调度',
+    construction: '施工',
+  }
+  return map[role.value] || '员工'
+})
+
 const displayName = computed(() => {
   return userInfo.value?.name || '用户'
 })
@@ -103,9 +187,6 @@ const primaryMeta = computed(() => {
 })
 
 const secondaryMeta = computed(() => {
-  if (role.value === 'construction') {
-    return userInfo.value?.position ? `/${userInfo.value.position}` : ''
-  }
   return userInfo.value?.position || ''
 })
 
@@ -143,15 +224,139 @@ async function handleLogout() {
 </script>
 
 <style scoped>
-.scale-down {
-  transition: transform 0.15s ease;
+/* 头像 */
+.profile-avatar {
+  width: 72px;
+  height: 72px;
+  border-radius: 50%;
+  background-color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
+  flex-shrink: 0;
+}
+
+/* 角色徽标 */
+.role-chip {
+  margin-left: 8px;
+  padding: 2px 10px;
+  border-radius: 20px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #ffffff;
+  background-color: rgba(255, 255, 255, 0.22);
+  border: 1px solid rgba(255, 255, 255, 0.35);
+}
+
+/* 头部 meta 文字 */
+.profile-meta {
+  color: rgba(255, 255, 255, 0.9);
+  font-size: 13px;
+}
+
+.profile-meta-dot {
+  color: rgba(255, 255, 255, 0.5);
+  font-size: 13px;
+  margin: 0 6px;
+}
+
+/* 工号 chip */
+.emp-chip {
+  display: inline-block;
+  padding: 2px 10px;
+  border-radius: 20px;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.92);
+  background-color: rgba(0, 0, 0, 0.16);
+}
+
+/* 区块标题装饰条 */
+.section-bar {
+  width: 4px;
+  height: 16px;
+  border-radius: 2px;
+  background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
+}
+
+/* 信息行 */
+.info-row {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding: 12px 0;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+.info-row:last-child {
+  border-bottom: none;
+}
+
+.info-label {
+  font-size: 14px;
+  color: #6b7280;
+  flex-shrink: 0;
+  margin-right: 16px;
+}
+
+.info-value {
+  flex: 1;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1f2937;
+  text-align: right;
+}
+
+/* 菜单项 */
+.menu-item {
+  display: flex;
+  align-items: center;
+  padding: 12px;
+  border-radius: 12px;
+}
+
+.menu-icon {
+  width: 40px;
+  height: 40px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-right: 12px;
+  font-size: 20px;
+}
+
+.menu-title {
+  display: block;
+  font-size: 15px;
+  font-weight: 500;
+  color: #1f2937;
+}
+
+.menu-desc {
+  display: block;
+  margin-top: 2px;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.menu-hover {
+  background-color: #f9fafb;
 }
-.scale-down:hover,
-.scale-down:active {
-  transform: scale(0.95);
+
+/* 退出登录 */
+.logout-row {
+  display: flex;
+  align-items: center;
+  padding: 14px 16px;
+}
+
+.logout-hover {
+  background-color: #fef2f2;
 }
 
-.logout-btn:active {
-  background-color: #dc2626;
+.text-red-300 {
+  color: #fca5a5;
 }
 </style>

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

@@ -0,0 +1,203 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar title="合同管理" show-back>
+      <template #right>
+        <view class="px-2 py-1 flex items-center" @click="goToAdd">
+          <text class="uni-icons uniui-plusempty text-primary text-xl"></text>
+          <text class="text-primary text-sm ml-1">新增</text>
+        </view>
+      </template>
+    </TopBar>
+
+    <!-- 搜索栏 -->
+    <view class="bg-white px-4 py-3 sticky top-0 z-40">
+      <view class="flex items-center bg-gray-100 rounded-2xl px-4 py-2">
+        <text class="uni-icons uniui-search text-gray-400 mr-2 text-base"></text>
+        <input
+          class="flex-1 bg-transparent text-sm text-gray-700"
+          placeholder="搜索合同号/备注"
+          v-model="searchKeyword"
+          confirm-type="search"
+          @confirm="onSearch"
+        />
+      </view>
+    </view>
+
+    <!-- 状态筛选 -->
+    <uni-segmented-control
+      :current="currentFilterIndex"
+      :values="filterLabels"
+      style-type="text"
+      active-color="#4f8ef7"
+      @clickItem="onFilterChange"
+    />
+
+    <!-- 列表 -->
+    <view class="mt-2">
+      <view v-if="contractStore.loading" class="py-16 text-center">
+        <text class="text-gray-400">加载中...</text>
+      </view>
+
+      <view v-else-if="contractStore.contracts.length === 0">
+        <EmptyState icon="uniui-file-text-filled" text="暂无合同" />
+      </view>
+
+      <uni-list v-else border>
+        <uni-swipe-action
+          v-for="contract in contractStore.contracts"
+          :key="contract.id"
+          :actions="swipeActions"
+          @click="onSwipeAction(contract.id, $event)"
+        >
+          <uni-list-item
+            :title="contract.contractNo"
+            :note="buildNote(contract)"
+            show-arrow
+            @click="goToDetail(contract.id)"
+          >
+            <template #header>
+              <view class="w-10 h-10 bg-green-50 rounded-xl flex items-center justify-center mr-3">
+                <text class="uni-icons uniui-file-text-filled text-green-500 text-lg"></text>
+              </view>
+            </template>
+            <template #footer>
+              <view
+                class="px-2 py-1 rounded text-xs"
+                :style="{ backgroundColor: statusBg(contract.status), color: statusColor(contract.status) }"
+              >
+                {{ statusText(contract.status) }}
+              </view>
+            </template>
+          </uni-list-item>
+        </uni-swipe-action>
+      </uni-list>
+    </view>
+
+    <!-- 加载更多 -->
+    <view v-if="contractStore.contracts.length > 0" class="px-4 mt-4 mb-4">
+      <view v-if="contractStore.hasMore" class="text-center py-4">
+        <text class="text-sm text-blue-500 font-medium" @click="loadMore">
+          {{ contractStore.loadingMore ? '加载中...' : '加载更多' }}
+        </text>
+      </view>
+      <view v-else class="text-center py-4">
+        <text class="text-xs text-gray-400">已经到底了</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import { onPullDownRefresh } from '@dcloudio/uni-app'
+import { useContractStore } from '@/stores/contract'
+import type { Contract, ContractStatus } from '@/types'
+import {
+  formatMoney,
+  getPaymentMethodText,
+  getContractStatusText,
+  getContractStatusColor,
+  getContractStatusBgColor
+} from '@/utils'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import UniList from '@/components/uni-list/uni-list.vue'
+import UniListItem from '@/components/uni-list/uni-list-item.vue'
+import UniSwipeAction from '@/components/uni-swipe-action/uni-swipe-action.vue'
+import UniSegmentedControl from '@/components/uni-segmented-control/uni-segmented-control.vue'
+
+const contractStore = useContractStore()
+const searchKeyword = ref('')
+const currentFilterIndex = ref(0)
+
+const filterTypes: { value: ContractStatus | ''; label: string }[] = [
+  { value: '', label: '全部' },
+  { value: 'draft', label: '草稿' },
+  { value: 'pending', label: '待生效' },
+  { value: 'active', label: '生效中' },
+  { value: 'expired', label: '已过期' },
+  { value: 'terminated', label: '已终止' }
+]
+const filterLabels = filterTypes.map(t => t.label)
+
+const swipeActions = [
+  { text: '编辑', color: '#4f8ef7', key: 'edit' },
+  { text: '删除', color: '#ff3b30', key: 'delete' }
+]
+
+const statusText = getContractStatusText
+const statusColor = getContractStatusColor
+const statusBg = getContractStatusBgColor
+
+function buildNote(contract: Contract) {
+  const amount = formatMoney(contract.contractAmount)
+  const method = getPaymentMethodText(contract.paymentMethod)
+  return [amount, method, contract.signDate].filter(Boolean).join(' · ')
+}
+
+let searchTimer: ReturnType<typeof setTimeout> | null = null
+function onSearch() {
+  if (searchTimer) clearTimeout(searchTimer)
+  searchTimer = setTimeout(() => {
+    contractStore.setKeyword(searchKeyword.value.trim())
+    contractStore.fetchContracts(true)
+  }, 300)
+}
+
+function onFilterChange(index: number) {
+  currentFilterIndex.value = index
+  contractStore.setStatus(filterTypes[index].value)
+  contractStore.fetchContracts(true)
+}
+
+function goToDetail(id: number | string) {
+  contractStore.setCurrentContract(id)
+  uni.navigateTo({ url: `/subPackages/pages-contract/contractDetail?id=${id}` })
+}
+
+function goToAdd() {
+  uni.navigateTo({ url: '/subPackages/pages-contract/contractForm' })
+}
+
+function onSwipeAction(id: number | string, action: { key?: string }) {
+  if (action.key === 'edit') {
+    uni.navigateTo({ url: `/subPackages/pages-contract/contractForm?id=${id}` })
+  } else if (action.key === 'delete') {
+    uni.showModal({
+      title: '确认删除',
+      content: '删除后无法恢复,是否继续?',
+      success: async (res) => {
+        if (res.confirm) {
+          try {
+            await contractStore.removeContract(id)
+            uni.showToast({ title: '已删除', icon: 'success' })
+          } catch (e) {
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          }
+        }
+      }
+    })
+  }
+}
+
+function loadMore() {
+  contractStore.loadMore()
+}
+
+async function refreshData() {
+  await contractStore.fetchContracts(true)
+  uni.stopPullDownRefresh()
+}
+
+onMounted(() => {
+  contractStore.fetchContracts(true)
+})
+
+onUnmounted(() => {
+  if (searchTimer) clearTimeout(searchTimer)
+})
+
+onPullDownRefresh(() => {
+  refreshData()
+})
+</script>

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

@@ -0,0 +1,202 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar title="客户管理" show-back>
+      <template #right>
+        <view class="px-2 py-1 flex items-center" @click="goToAdd">
+          <text class="uni-icons uniui-plusempty text-primary text-xl"></text>
+          <text class="text-primary text-sm ml-1">新增</text>
+        </view>
+      </template>
+    </TopBar>
+
+    <!-- 搜索栏 -->
+    <view class="bg-white px-4 py-3 sticky top-0 z-40">
+      <view class="flex items-center bg-gray-100 rounded-2xl px-4 py-2">
+        <text class="uni-icons uniui-search text-gray-400 mr-2 text-base"></text>
+        <input
+          class="flex-1 bg-transparent text-sm text-gray-700"
+          placeholder="搜索客户名称/联系人/电话"
+          v-model="searchKeyword"
+          confirm-type="search"
+          @confirm="onSearch"
+        />
+      </view>
+    </view>
+
+    <!-- 状态筛选 -->
+    <uni-segmented-control
+      :current="currentFilterIndex"
+      :values="filterLabels"
+      style-type="text"
+      active-color="#4f8ef7"
+      @clickItem="onFilterChange"
+    />
+
+    <!-- 列表 -->
+    <view class="mt-2">
+      <view v-if="customerStore.loading" class="py-16 text-center">
+        <text class="text-gray-400">加载中...</text>
+      </view>
+
+      <view v-else-if="filteredCustomers.length === 0">
+        <EmptyState icon="uniui-personadd-filled" text="暂无客户" />
+      </view>
+
+      <uni-list v-else border>
+        <uni-swipe-action
+          v-for="customer in filteredCustomers"
+          :key="customer.id"
+          :actions="swipeActions"
+          @click="onSwipeAction(customer.id, $event)"
+        >
+          <uni-list-item
+            :title="customer.customerName"
+            :note="buildNote(customer)"
+            show-arrow
+            @click="goToDetail(customer.id)"
+          >
+            <template #header>
+              <view class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center mr-3">
+                <text class="uni-icons uniui-person-filled text-blue-500 text-lg"></text>
+              </view>
+            </template>
+            <template #footer>
+              <view
+                class="px-2 py-1 rounded text-xs"
+                :style="{ backgroundColor: statusBg(customer.status), color: statusColor(customer.status) }"
+              >
+                {{ statusText(customer.status) }}
+              </view>
+            </template>
+          </uni-list-item>
+        </uni-swipe-action>
+      </uni-list>
+    </view>
+
+    <!-- 加载更多 -->
+    <view v-if="filteredCustomers.length > 0" class="px-4 mt-4 mb-4">
+      <view v-if="customerStore.hasMore" class="text-center py-4">
+        <text class="text-sm text-blue-500 font-medium" @click="loadMore">
+          {{ customerStore.loadingMore ? '加载中...' : '加载更多' }}
+        </text>
+      </view>
+      <view v-else class="text-center py-4">
+        <text class="text-xs text-gray-400">已经到底了</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { onPullDownRefresh } from '@dcloudio/uni-app'
+import { useCustomerStore } from '@/stores/customer'
+import type { Customer } from '@/types'
+import {
+  getCustomerStatusText,
+  getCustomerStatusColor,
+  getCustomerStatusBgColor
+} from '@/utils'
+import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import UniList from '@/components/uni-list/uni-list.vue'
+import UniListItem from '@/components/uni-list/uni-list-item.vue'
+import UniSwipeAction from '@/components/uni-swipe-action/uni-swipe-action.vue'
+import UniSegmentedControl from '@/components/uni-segmented-control/uni-segmented-control.vue'
+
+const customerStore = useCustomerStore()
+const searchKeyword = ref('')
+const currentFilterIndex = ref(0)
+
+const filterTypes = [
+  { value: 'all', label: '全部' },
+  { value: '1', label: '合作中' },
+  { value: '2', label: '潜在客户' },
+  { value: '0', label: '已停用' }
+]
+const filterLabels = filterTypes.map(t => t.label)
+
+const swipeActions = [
+  { text: '编辑', color: '#4f8ef7', key: 'edit' },
+  { text: '删除', color: '#ff3b30', key: 'delete' }
+]
+
+const statusText = getCustomerStatusText
+const statusColor = getCustomerStatusColor
+const statusBg = getCustomerStatusBgColor
+
+const filteredCustomers = computed(() => {
+  const filterValue = filterTypes[currentFilterIndex.value].value
+  if (filterValue === 'all') return customerStore.customers
+  return customerStore.customers.filter(c => String(c.status) === filterValue)
+})
+
+function buildNote(customer: Customer) {
+  const parts = [customer.contactName, customer.phone].filter(Boolean)
+  return parts.length ? parts.join(' · ') : (customer.detailAddress || '暂无联系人信息')
+}
+
+let searchTimer: ReturnType<typeof setTimeout> | null = null
+function onSearch() {
+  if (searchTimer) clearTimeout(searchTimer)
+  searchTimer = setTimeout(() => {
+    customerStore.setKeyword(searchKeyword.value.trim())
+    customerStore.fetchCustomers(true)
+  }, 300)
+}
+
+function onFilterChange(index: number) {
+  currentFilterIndex.value = index
+}
+
+function goToDetail(id: number | string) {
+  customerStore.setCurrentCustomer(id)
+  uni.navigateTo({ url: `/subPackages/pages-customer/customerDetail?id=${id}` })
+}
+
+function goToAdd() {
+  uni.navigateTo({ url: '/subPackages/pages-customer/customerForm' })
+}
+
+function onSwipeAction(id: number | string, action: { key?: string }) {
+  if (action.key === 'edit') {
+    uni.navigateTo({ url: `/subPackages/pages-customer/customerForm?id=${id}` })
+  } else if (action.key === 'delete') {
+    uni.showModal({
+      title: '确认删除',
+      content: '删除后无法恢复,是否继续?',
+      success: async (res) => {
+        if (res.confirm) {
+          try {
+            await customerStore.removeCustomer(id)
+            uni.showToast({ title: '已删除', icon: 'success' })
+          } catch (e) {
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          }
+        }
+      }
+    })
+  }
+}
+
+function loadMore() {
+  customerStore.loadMore()
+}
+
+async function refreshData() {
+  await customerStore.fetchCustomers(true)
+  uni.stopPullDownRefresh()
+}
+
+onMounted(() => {
+  customerStore.fetchCustomers(true)
+})
+
+onUnmounted(() => {
+  if (searchTimer) clearTimeout(searchTimer)
+})
+
+onPullDownRefresh(() => {
+  refreshData()
+})
+</script>

+ 263 - 108
src/pages/login/login.vue

@@ -1,127 +1,93 @@
-
 <template>
-  <view class="app-container min-h-screen bg-background flex flex-col items-center justify-center px-8">
-    <!-- Logo -->
-    <view class="mb-8 text-center">
-      <view class="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center mb-4 mx-auto shadow-card">
-        <text class="text-3xl font-bold text-white">清</text>
-      </view>
-      <text class="text-gray-800 text-2xl font-bold">绿水青山</text>
-      <text class="text-gray-500 text-sm mt-2 block">环境服务管理平台</text>
-    </view>
+  <view class="login-page">
+    <!-- 背景图(青山绿水,不修改、不重新生成) -->
+    <image class="bg-img" src="/static/login-bg.jpeg" mode="aspectFill" />
+    <!-- 柔光蒙版:保证文字与卡片清晰,不与背景混在一起 -->
+    <view class="veil"></view>
 
-    <!-- 角色选择 -->
-    <view class="w-full mb-4">
-      <view class="flex bg-surface rounded-xl p-1 border border-gray-200">
-        <view
-          v-for="item in roleOptions"
-          :key="item.value"
-          class="flex-1 py-2 text-center text-sm font-medium rounded-lg transition-all"
-          :class="selectedRole === item.value ? 'bg-primary text-white shadow-card' : 'text-gray-500'"
-          @click="selectedRole = item.value"
-        >
-          {{ item.label }}
+    <StatusBar />
+
+    <!-- 内容层 -->
+    <view class="content">
+      <!-- 顶部品牌区 -->
+      <view class="brand">
+        <view class="logo">
+          <view class="leaf">
+            <view class="leaf-vein"></view>
+          </view>
         </view>
+        <text class="title">绿水青山</text>
+        <text class="subtitle">与自然同行</text>
       </view>
-    </view>
 
-    <!-- 登录表单 -->
-    <view class="w-full bg-white rounded-2xl p-6 shadow-card border border-gray-200">
-      <text class="text-gray-800 text-lg font-semibold mb-6 block text-center">账号登录</text>
-
-      <view class="gap-y-4">
-        <!-- 用户名 -->
-        <view>
-          <text class="text-sm text-gray-500 block mb-2">用户名</text>
-          <view class="flex items-center bg-surface rounded-xl px-3 border border-gray-200 login-input-wrap">
-            <text class="uni-icons uniui-person text-gray-400 mr-2"></text>
-            <input
-              class="flex-1 login-input"
-              placeholder="请输入用户名"
-              v-model="form.username"
-            />
-          </view>
+      <!-- 登录卡片:半透明白色磨砂玻璃 -->
+      <view class="login-card">
+        <text class="card-title">欢迎回来</text>
+
+        <!-- 账号输入框 -->
+        <view class="field" :class="{ 'field-focus': focusedField === 'account' }">
+          <text class="uni-icons uniui-person field-icon"></text>
+          <input
+            class="field-input"
+            placeholder="请输入账号"
+            placeholder-class="field-ph"
+            v-model="form.account"
+            @focus="focusedField = 'account'"
+            @blur="focusedField = ''"
+          />
         </view>
 
-        <!-- 密码 -->
-        <view>
-          <text class="text-sm text-gray-500 block mb-2">密码</text>
-          <view class="flex items-center bg-surface rounded-xl px-3 border border-gray-200 login-input-wrap">
-            <text class="uni-icons uniui-locked text-gray-400 mr-2"></text>
-            <input
-              class="flex-1 login-input"
-              placeholder="请输入密码"
-              type="password"
-              v-model="form.password"
-            />
-          </view>
+        <!-- 密码输入框 -->
+        <view class="field" :class="{ 'field-focus': focusedField === 'password' }">
+          <text class="uni-icons uniui-locked field-icon"></text>
+          <input
+            class="field-input"
+            type="password"
+            placeholder="请输入密码"
+            placeholder-class="field-ph"
+            v-model="form.password"
+            @focus="focusedField = 'password'"
+            @blur="focusedField = ''"
+          />
         </view>
-      </view>
 
-      <button
-        class="w-full mt-6 py-3 rounded-xl text-white font-medium text-base bg-primary"
-        :class="canLogin ? 'opacity-100' : 'opacity-50'"
-        :disabled="!canLogin || loading"
-        @click="handleLogin"
-      >
-        {{ loading ? '登录中...' : '立即登录' }}
-      </button>
-
-      <!-- 测试账号提示 -->
-      <view class="mt-4 text-center">
-        <text class="text-gray-400 text-xs">测试账号: admin / ***</text>
+        <!-- 登录按钮:青山绿 → 流水青 -->
+        <button
+          class="login-btn"
+          :class="{ 'login-btn-disabled': !canLogin }"
+          :disabled="!canLogin || loading"
+          :hover-class="'login-btn-hover'"
+          @click="handleLogin"
+        >
+          {{ loading ? '登录中…' : '登录' }}
+        </button>
       </view>
     </view>
-
-    <!-- 版本号 -->
-    <text class="text-gray-400 text-xs mt-6">v1.0.0</text>
   </view>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
-import { useAuthStore } from '../../stores/auth'
-import type { MpRoleType } from '../../api/auth'
+import { ref, reactive, computed } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+import StatusBar from '@/components/common/StatusBar.vue'
 
 const authStore = useAuthStore()
 
-interface RoleOption {
-  label: string
-  value: MpRoleType
-}
-
-const roleOptions: RoleOption[] = [
-  { label: '销售端', value: 'mp-sales' },
-  { label: '调度端', value: 'mp-dispatch' },
-  { label: '施工端', value: 'mp-construction' },
-]
-
-const selectedRole = ref<MpRoleType>('mp-sales')
-
-const form = ref({
-  username: '',
-  password: '',
-})
-
+const form = reactive({ account: '', password: '' })
+const focusedField = ref('')
 const loading = ref(false)
 
-const canLogin = computed(() => form.value.username.trim() && form.value.password.trim())
-
-onMounted(() => {
-  uni.removeStorageSync('remember_username')
-  uni.removeStorageSync('remember_password')
-  uni.removeStorageSync('remember_me')
-})
+const canLogin = computed(() => form.account.trim() && form.password.trim())
 
 async function handleLogin() {
-  if (!canLogin.value) return
-
+  if (!canLogin.value || loading.value) return
   loading.value = true
   try {
-    await authStore.doLogin(form.value.username, form.value.password, selectedRole.value)
+    // 后端按账号识别角色,无需前端选择角色
+    await authStore.doLogin(form.account.trim(), form.password)
     uni.switchTab({ url: '/pages/home/home' })
-  } catch (error: any) {
-    // 请求封装已统一弹出 toast,这里避免重复提示
+  } catch (error) {
+    // 请求封装已统一 toast,避免重复提示
     console.error('登录失败', error)
   } finally {
     loading.value = false
@@ -130,22 +96,211 @@ async function handleLogin() {
 </script>
 
 <style scoped>
-.login-input-wrap {
-  height: 48px;
+/* ==================== 页面外壳:max-w-md 居中 ==================== */
+.login-page {
+  position: relative;
+  max-width: 448px;              /* max-w-md */
+  margin: 0 auto;                /* 居中 */
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background-color: #f6fbf9;     /* 云雾白兜底 */
+}
+
+/* 背景图铺满(竖屏 aspectFill 不变形) */
+.bg-img {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 0;
+}
+
+/* 蒙版:顶部提亮、中部通透、底部微压,保证层级清晰 */
+.veil {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+  background: linear-gradient(
+    180deg,
+    rgba(255, 255, 255, 0.52) 0%,
+    rgba(246, 251, 249, 0.18) 38%,
+    rgba(246, 251, 249, 0.10) 58%,
+    rgba(31, 71, 56, 0.22) 100%
+  );
+}
+
+/* ==================== 内容层 ==================== */
+.content {
+  position: relative;
+  z-index: 2;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 56px 32px calc(40px + env(safe-area-inset-bottom));
+  transform: translateY(3vh);    /* 居中基础上略向下 */
+}
+
+/* 品牌区 */
+.brand {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 40px;
+}
+
+.logo {
+  width: 76px;
+  height: 76px;
+  border-radius: 24px;
+  background-color: rgba(255, 255, 255, 0.86);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 10px 28px -8px rgba(54, 143, 111, 0.45);
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
 }
 
-.login-input {
-  height: 46px;
-  min-height: 46px;
+/* 叶子 Logo(CSS 绘制) */
+.leaf {
+  position: relative;
+  width: 38px;
+  height: 38px;
+  background: linear-gradient(135deg, #a4d898 0%, #368f6f 100%);
+  border-radius: 4px 28px 4px 28px;
+  transform: rotate(45deg);
+}
+
+.leaf-vein {
+  position: absolute;
+  left: 50%;
+  top: 4px;
+  bottom: 4px;
+  width: 2px;
+  margin-left: -1px;
+  border-radius: 2px;
+  background-color: rgba(255, 255, 255, 0.7);
+}
+
+.title {
+  margin-top: 22px;
+  font-size: 34px;
+  font-weight: 800;
+  letter-spacing: 6px;
+  color: #368f6f;                /* 青山绿 */
+}
+
+.subtitle {
+  margin-top: 10px;
   font-size: 14px;
-  color: #1f2937;
+  letter-spacing: 4px;
+  color: #709484;                /* 晨雾灰绿 */
+}
+
+/* ==================== 登录卡片:磨砂玻璃 ==================== */
+.login-card {
+  background-color: rgba(255, 255, 255, 0.8);   /* 白色/80 */
+  backdrop-filter: blur(14px);
+  -webkit-backdrop-filter: blur(14px);
+  border: 1px solid rgba(164, 216, 152, 0.35);  /* 嫩芽绿描边 */
+  border-radius: 24px;
+  padding: 30px 26px 28px;
+  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28); /* 柔和绿色阴影 */
+}
+
+.card-title {
+  display: block;
+  text-align: center;
+  font-size: 20px;
+  font-weight: 700;
+  color: #1f4738;                /* 文字深墨绿 */
+  margin-bottom: 26px;
+  letter-spacing: 1px;
+}
+
+/* ==================== 输入框 ==================== */
+.field {
+  display: flex;
+  align-items: center;
+  height: 54px;
+  padding: 0 18px;
+  margin-bottom: 18px;
+  border-radius: 16px;
+  background-color: #f6fbf9;     /* 云雾白 */
+  border: 1.5px solid rgba(164, 216, 152, 0.45);
+  transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
+}
+
+/* 浅嫩芽绿聚焦高亮 */
+.field-focus {
+  background-color: #ffffff;
+  border-color: #a4d898;         /* 嫩芽绿 */
+  box-shadow: 0 0 0 4px rgba(164, 216, 152, 0.32);
+}
+
+.field-icon {
+  width: 22px;
+  margin-right: 12px;
+  font-size: 18px;
+  color: #6f9686;
+  text-align: center;
+}
+
+.field-input {
+  flex: 1;
+  height: 100%;
+  font-size: 15px;
+  color: #1f4738;                /* 深墨绿 */
   background-color: transparent;
   border: none;
   outline: none;
 }
 
-.login-input::placeholder {
-  color: #9ca3af;
-  font-size: 14px;
+.field-ph {
+  color: #a8bcb3;
+  font-size: 15px;
+}
+
+/* ==================== 登录按钮 ==================== */
+.login-btn {
+  height: 54px;
+  line-height: 54px;
+  margin-top: 10px;
+  border: none;
+  border-radius: 16px;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%); /* 青山绿 → 流水青 */
+  color: #ffffff;
+  font-size: 17px;
+  font-weight: 700;
+  letter-spacing: 4px;
+  box-shadow: 0 14px 28px -10px rgba(54, 143, 111, 0.5);
+  transition: transform 0.15s ease, opacity 0.15s ease;
+}
+
+.login-btn::after {
+  border: none;                  /* 去掉小程序 button 默认边框 */
+}
+
+/* 点击反馈(小程序 hover-class + H5 active) */
+.login-btn-hover {
+  opacity: 0.9;
+  transform: scale(0.985);
+}
+
+.login-btn:active {
+  opacity: 0.9;
+  transform: scale(0.985);
+}
+
+.login-btn-disabled {
+  opacity: 0.55;
+  box-shadow: none;
 }
 </style>

binární
src/static/login-backpage.png


binární
src/static/login-bg.jpeg


+ 96 - 0
src/stores/contract.ts

@@ -0,0 +1,96 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { Contract, ContractStatus } from '../types'
+import {
+  getContractList,
+  deleteContract as apiDeleteContract
+} from '../api/contract'
+
+export const useContractStore = defineStore('contract', () => {
+  const contracts = ref<Contract[]>([])
+  const currentContract = ref<Contract | null>(null)
+  const keyword = ref('')
+  const status = ref<ContractStatus | ''>('')
+  const loading = ref(false)
+  const loadingMore = ref(false)
+  const pageNum = ref(1)
+  const pageSize = ref(10)
+  const total = ref(0)
+
+  const hasMore = computed(() => contracts.value.length < total.value)
+  const totalCount = computed(() => total.value)
+
+  async function fetchContracts(reset = false) {
+    if (reset) {
+      pageNum.value = 1
+      contracts.value = []
+    }
+    loading.value = true
+    try {
+      const res = await getContractList(pageNum.value, pageSize.value, {
+        keyword: keyword.value || undefined,
+        status: status.value || undefined
+      })
+      const rows = res.rows || []
+      contracts.value = reset ? rows : [...contracts.value, ...rows]
+      total.value = res.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function loadMore() {
+    if (loadingMore.value || !hasMore.value) return
+    loadingMore.value = true
+    pageNum.value++
+    try {
+      const res = await getContractList(pageNum.value, pageSize.value, {
+        keyword: keyword.value || undefined,
+        status: status.value || undefined
+      })
+      contracts.value = [...contracts.value, ...(res.rows || [])]
+    } catch (error) {
+      pageNum.value--
+    } finally {
+      loadingMore.value = false
+    }
+  }
+
+  function setKeyword(value: string) {
+    keyword.value = value
+  }
+
+  function setStatus(value: ContractStatus | '') {
+    status.value = value
+  }
+
+  function setCurrentContract(id: number | string) {
+    currentContract.value = contracts.value.find(c => String(c.id) === String(id)) || null
+  }
+
+  async function removeContract(id: number | string) {
+    await apiDeleteContract(id)
+    contracts.value = contracts.value.filter(c => String(c.id) !== String(id))
+    total.value--
+  }
+
+  return {
+    contracts,
+    currentContract,
+    keyword,
+    status,
+    loading,
+    loadingMore,
+    pageNum,
+    pageSize,
+    total,
+    hasMore,
+    totalCount,
+    fetchContracts,
+    loadMore,
+    setKeyword,
+    setStatus,
+    setCurrentContract,
+    removeContract
+  }
+})

+ 83 - 0
src/stores/customer.ts

@@ -0,0 +1,83 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { Customer } from '../types'
+import {
+  getCustomerList,
+  deleteCustomer as apiDeleteCustomer
+} from '../api/customer'
+
+export const useCustomerStore = defineStore('customer', () => {
+  const customers = ref<Customer[]>([])
+  const currentCustomer = ref<Customer | null>(null)
+  const keyword = ref('')
+  const loading = ref(false)
+  const loadingMore = ref(false)
+  const pageNum = ref(1)
+  const pageSize = ref(10)
+  const total = ref(0)
+
+  const hasMore = computed(() => customers.value.length < total.value)
+  const totalCount = computed(() => total.value)
+
+  async function fetchCustomers(reset = false) {
+    if (reset) {
+      pageNum.value = 1
+      customers.value = []
+    }
+    loading.value = true
+    try {
+      const res = await getCustomerList(pageNum.value, pageSize.value, keyword.value || undefined)
+      const rows = res.rows || []
+      customers.value = reset ? rows : [...customers.value, ...rows]
+      total.value = res.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function loadMore() {
+    if (loadingMore.value || !hasMore.value) return
+    loadingMore.value = true
+    pageNum.value++
+    try {
+      const res = await getCustomerList(pageNum.value, pageSize.value, keyword.value || undefined)
+      customers.value = [...customers.value, ...(res.rows || [])]
+    } catch (error) {
+      pageNum.value--
+    } finally {
+      loadingMore.value = false
+    }
+  }
+
+  function setKeyword(value: string) {
+    keyword.value = value
+  }
+
+  function setCurrentCustomer(id: number | string) {
+    currentCustomer.value = customers.value.find(c => String(c.id) === String(id)) || null
+  }
+
+  async function removeCustomer(id: number | string) {
+    await apiDeleteCustomer(id)
+    customers.value = customers.value.filter(c => String(c.id) !== String(id))
+    total.value--
+  }
+
+  return {
+    customers,
+    currentCustomer,
+    keyword,
+    loading,
+    loadingMore,
+    pageNum,
+    pageSize,
+    total,
+    hasMore,
+    totalCount,
+    fetchCustomers,
+    loadMore,
+    setKeyword,
+    setCurrentCustomer,
+    removeCustomer
+  }
+})

+ 8 - 11
src/subPackages/pages-common/projectList.vue

@@ -1,7 +1,14 @@
 
 <template>
   <view class="app-container pb-24">
-    <TopBar title="项目库" show-back />
+    <TopBar title="项目库" show-back>
+      <template #right>
+        <view class="px-2 py-1 flex items-center" @click="goToAdd">
+          <text class="uni-icons uniui-plusempty text-primary text-xl"></text>
+          <text class="text-primary text-sm ml-1">新增</text>
+        </view>
+      </template>
+    </TopBar>
     
     <!-- 顶部搜索栏 -->
     <view class="bg-white px-4 py-3 sticky top-0 z-40">
@@ -72,16 +79,6 @@
         <text class="text-xs text-gray-400">已经到底了</text>
       </view>
     </view>
-
-    <!-- 新增按钮 -->
-    <view class="fixed bottom-20 right-4">
-      <button 
-        class="w-14 h-14 rounded-full bg-blue-500 text-white flex items-center justify-center shadow-lg"
-        @click="goToAdd"
-      >
-        <text class="uni-icons uniui-plusempty text-white text-2xl"></text>
-      </button>
-    </view>
   </view>
 </template>
 

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

@@ -0,0 +1,207 @@
+<template>
+  <view class="app-container pb-24">
+    <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 text="合同不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4 gap-y-4">
+      <!-- 头部 -->
+      <view class="card p-4">
+        <view class="flex items-center justify-between mb-3">
+          <text class="text-base font-bold text-gray-800 flex-1 min-w-0 mr-3 truncate">{{ contract.contractNo }}</text>
+          <view
+            class="px-2 py-1 rounded text-xs flex-shrink-0"
+            :style="{ backgroundColor: statusBg(contract.status), color: statusColor(contract.status) }"
+          >
+            {{ statusText(contract.status) }}
+          </view>
+        </view>
+        <view class="bg-green-50 rounded-xl p-4 text-center">
+          <text class="text-xs text-gray-500 block mb-1">合同金额</text>
+          <text class="text-2xl font-bold text-green-600">{{ formatMoney(contract.contractAmount) }}</text>
+        </view>
+      </view>
+
+      <!-- 合同信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-file-text-filled text-primary mr-2"></text>
+          <text>合同信息</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">付款方式</text>
+          <text class="detail-value">{{ getPaymentMethodText(contract.paymentMethod) }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">签订日期</text>
+          <text class="detail-value">{{ contract.signDate || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">服务开始</text>
+          <text class="detail-value">{{ serviceStart }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">服务结束</text>
+          <text class="detail-value">{{ serviceEnd }}</text>
+        </view>
+        <view v-if="contract.remark" class="detail-row">
+          <text class="detail-label">备注</text>
+          <text class="detail-value text-right flex-1">{{ contract.remark }}</text>
+        </view>
+      </view>
+
+      <!-- 关联客户 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
+          <text>关联客户</text>
+        </view>
+        <view v-if="customer">
+          <view class="detail-row">
+            <text class="detail-label">客户名称</text>
+            <text class="detail-value">{{ customer.customerName }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="detail-label">联系人</text>
+            <text class="detail-value">{{ customer.contactName || '-' }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="detail-label">电话</text>
+            <view class="detail-value flex items-center" @click="callPhone(customer.phone)">
+              <text class="text-primary">{{ customer.phone || '-' }}</text>
+            </view>
+          </view>
+        </view>
+        <view v-else class="py-3">
+          <text class="text-xs text-gray-400">客户信息加载中或不存在</text>
+        </view>
+      </view>
+
+      <!-- 付款记录占位 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+          <text>付款记录</text>
+        </view>
+        <text class="text-xs text-gray-400">后端暂未提供付款记录接口</text>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="px-4 pt-2 pb-6 flex gap-3">
+        <button
+          class="flex-1 py-4 bg-white border border-blue-200 text-blue-600 rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="goToEdit"
+        >
+          <text class="uni-icons uniui-compose mr-2"></text>
+          编辑
+        </button>
+        <button
+          class="flex-1 py-4 bg-white border border-red-200 text-red-500 rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="onDelete"
+        >
+          <text class="uni-icons uniui-trash mr-2"></text>
+          删除
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import type { Contract, Customer } from '../../types'
+import { getContractDetail, deleteContract } from '../../api/contract'
+import { getCustomerDetail } from '../../api/customer'
+import {
+  formatMoney,
+  getPaymentMethodText,
+  getContractStatusText,
+  getContractStatusColor,
+  getContractStatusBgColor
+} from '../../utils'
+import TopBar from '../../components/common/TopBar.vue'
+import EmptyState from '../../components/common/EmptyState.vue'
+
+const contract = ref<Contract | null>(null)
+const customer = ref<Customer | null>(null)
+const loading = ref(true)
+const contractId = ref<number | string>('')
+
+const statusText = getContractStatusText
+const statusColor = getContractStatusColor
+const statusBg = getContractStatusBgColor
+
+const serviceParts = computed(() => {
+  const sp = contract.value?.servicePeriod || ''
+  const parts = sp.split(' 至 ')
+  return {
+    start: parts[0] || '-',
+    end: parts[1] || '-'
+  }
+})
+const serviceStart = computed(() => serviceParts.value.start)
+const serviceEnd = computed(() => serviceParts.value.end)
+
+function callPhone(phone?: string) {
+  if (!phone) return
+  uni.makePhoneCall({ phoneNumber: phone })
+}
+
+function goToEdit() {
+  uni.navigateTo({ url: `/subPackages/pages-contract/contractForm?id=${contractId.value}` })
+}
+
+function onDelete() {
+  uni.showModal({
+    title: '确认删除',
+    content: '删除后无法恢复,是否继续?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await deleteContract(contractId.value)
+          uni.showToast({ title: '已删除', icon: 'success' })
+          setTimeout(() => uni.navigateBack(), 800)
+        } catch (e) {
+          uni.showToast({ title: '删除失败', icon: 'none' })
+        }
+      }
+    }
+  })
+}
+
+async function loadDetail(id: number | string) {
+  loading.value = true
+  try {
+    const c = await getContractDetail(id)
+    contract.value = c
+    if (c.customerId) {
+      try {
+        customer.value = await getCustomerDetail(c.customerId)
+      } catch (e) {
+        customer.value = null
+      }
+    }
+  } catch (e) {
+    contract.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+onLoad((options) => {
+  const id = options?.id
+  if (id) {
+    contractId.value = id
+    loadDetail(id)
+  } else {
+    loading.value = false
+  }
+})
+</script>

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

@@ -0,0 +1,350 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar :title="isEdit ? '编辑合同' : '新增合同'" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else class="px-4 py-4 gap-y-4">
+      <!-- 基本信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-file-text-filled text-green-500"></text>
+          </view>
+          <text>基本信息</text>
+        </view>
+
+        <view class="gap-y-4">
+          <view>
+            <text class="form-label">合同编号<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入合同编号" v-model="form.contractNo" @blur="validateField('contractNo')" />
+            <text v-if="errors.contractNo" class="text-red-500 text-xs mt-1">{{ errors.contractNo }}</text>
+          </view>
+
+          <view>
+            <text class="form-label">关联客户<text class="text-red-500">*</text></text>
+            <picker mode="selector" :range="customerLabels" :value="customerIndex" @change="onCustomerChange">
+              <view class="form-select flex items-center" :class="{ 'text-gray-400': !form.customerId }">
+                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
+                {{ currentCustomerLabel }}
+              </view>
+            </picker>
+            <text v-if="errors.customerId" class="text-red-500 text-xs mt-1">{{ errors.customerId }}</text>
+          </view>
+
+          <view>
+            <text class="form-label">合同金额(元)<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入合同金额" v-model="form.contractAmount" type="digit" @blur="validateField('contractAmount')" />
+            <text v-if="errors.contractAmount" class="text-red-500 text-xs mt-1">{{ errors.contractAmount }}</text>
+          </view>
+
+          <view>
+            <text class="form-label">付款方式<text class="text-red-500">*</text></text>
+            <picker mode="selector" :range="paymentLabels" :value="paymentIndex" @change="onPaymentChange">
+              <view class="form-select flex items-center">
+                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
+                {{ currentPaymentLabel }}
+              </view>
+            </picker>
+          </view>
+
+          <view>
+            <text class="form-label">合同状态</text>
+            <picker mode="selector" :range="statusLabels" :value="statusIndex" @change="onStatusChange">
+              <view class="form-select flex items-center">
+                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
+                {{ currentStatusLabel }}
+              </view>
+            </picker>
+          </view>
+        </view>
+      </view>
+
+      <!-- 日期 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-calendar text-primary"></text>
+          </view>
+          <text>日期</text>
+        </view>
+
+        <view class="gap-y-4">
+          <view>
+            <text class="form-label">签订日期</text>
+            <picker mode="date" :value="form.signDate" @change="onSignDateChange">
+              <view class="form-select flex items-center text-gray-700">
+                <text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
+                {{ form.signDate || '选择日期' }}
+              </view>
+            </picker>
+          </view>
+
+          <view class="grid grid-cols-2 gap-3">
+            <view>
+              <text class="form-label">服务开始</text>
+              <picker mode="date" :value="form.serviceStart" @change="onServiceStartChange">
+                <view class="form-select flex items-center text-gray-700">
+                  <text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
+                  {{ form.serviceStart || '选择' }}
+                </view>
+              </picker>
+            </view>
+            <view>
+              <text class="form-label">服务结束</text>
+              <picker mode="date" :value="form.serviceEnd" @change="onServiceEndChange">
+                <view class="form-select flex items-center text-gray-700">
+                  <text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
+                  {{ form.serviceEnd || '选择' }}
+                </view>
+              </picker>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 备注 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-compose text-gray-500"></text>
+          </view>
+          <text>备注</text>
+        </view>
+        <textarea
+          v-model="form.remark"
+          class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
+          placeholder="请输入备注..."
+          :maxlength="1000"
+        />
+      </view>
+
+      <!-- 提交 -->
+      <view class="px-4 pt-2 pb-6">
+        <button
+          class="w-full py-4 btn-primary rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="submitForm"
+          :disabled="submitting"
+        >
+          <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
+          <text v-else class="uni-icons uniui-checkmarkempty mr-2"></text>
+          {{ submitting ? '提交中...' : isEdit ? '保存修改' : '新增合同' }}
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import type { ContractStatus, Customer, PaymentMethod } from '../../types'
+import { getContractDetail, createContract, updateContract } from '../../api/contract'
+import { getCustomerList } from '../../api/customer'
+import TopBar from '../../components/common/TopBar.vue'
+
+const paymentOptions: { label: string; value: PaymentMethod }[] = [
+  { label: '一次性', value: 'lump_sum' },
+  { label: '分期', value: 'installment' }
+]
+const paymentLabels = paymentOptions.map(p => p.label)
+
+const statusOptions: { label: string; value: ContractStatus }[] = [
+  { label: '草稿', value: 'draft' },
+  { label: '待生效', value: 'pending' },
+  { label: '生效中', value: 'active' },
+  { label: '已过期', value: 'expired' },
+  { label: '已终止', value: 'terminated' }
+]
+const statusLabels = statusOptions.map(s => s.label)
+
+const isEdit = ref(false)
+const editId = ref<number | string>('')
+const loading = ref(false)
+const submitting = ref(false)
+
+const customers = ref<Customer[]>([])
+const customerLabels = computed(() => customers.value.map(c => c.customerName))
+
+const form = ref({
+  contractNo: '',
+  customerId: '' as number | string,
+  customerName: '',
+  contractAmount: '' as number | string,
+  paymentMethod: 'lump_sum' as PaymentMethod,
+  status: 'draft' as ContractStatus,
+  signDate: '',
+  serviceStart: '',
+  serviceEnd: '',
+  remark: ''
+})
+
+const errors = reactive<Record<string, string>>({
+  contractNo: '',
+  customerId: '',
+  contractAmount: ''
+})
+
+const customerIndex = computed(() => {
+  const idx = customers.value.findIndex(c => String(c.id) === String(form.value.customerId))
+  return idx < 0 ? 0 : idx
+})
+const currentCustomerLabel = computed(() => {
+  if (!form.value.customerId) return '请选择客户'
+  return customers.value[customerIndex.value]?.customerName || form.value.customerName || '请选择客户'
+})
+const paymentIndex = computed(() => {
+  const idx = paymentOptions.findIndex(p => p.value === form.value.paymentMethod)
+  return idx < 0 ? 0 : idx
+})
+const currentPaymentLabel = computed(() => paymentOptions[paymentIndex.value].label)
+const statusIndex = computed(() => {
+  const idx = statusOptions.findIndex(s => s.value === form.value.status)
+  return idx < 0 ? 0 : idx
+})
+const currentStatusLabel = computed(() => statusOptions[statusIndex.value].label)
+
+function onCustomerChange(e: any) {
+  const c = customers.value[Number(e.detail.value)]
+  if (c) {
+    form.value.customerId = c.id
+    form.value.customerName = c.customerName
+    errors.customerId = ''
+  }
+}
+function onPaymentChange(e: any) {
+  form.value.paymentMethod = paymentOptions[Number(e.detail.value)].value
+}
+function onStatusChange(e: any) {
+  form.value.status = statusOptions[Number(e.detail.value)].value
+}
+function onSignDateChange(e: any) {
+  form.value.signDate = e.detail.value
+}
+function onServiceStartChange(e: any) {
+  form.value.serviceStart = e.detail.value
+}
+function onServiceEndChange(e: any) {
+  form.value.serviceEnd = e.detail.value
+}
+
+function validateField(field: string) {
+  errors[field] = ''
+  if (field === 'customerId') {
+    if (!form.value.customerId) {
+      errors.customerId = '请选择关联客户'
+      return false
+    }
+    return true
+  }
+  const value = (form.value as any)[field]
+  if (value === undefined || value === null || value === '') {
+    const names: Record<string, string> = { contractNo: '合同编号', contractAmount: '合同金额' }
+    errors[field] = `请输入${names[field] || field}`
+    return false
+  }
+  if (field === 'contractAmount') {
+    const num = Number(value)
+    if (isNaN(num) || num < 0) {
+      errors.contractAmount = '请输入有效的金额'
+      return false
+    }
+  }
+  return true
+}
+
+function validateForm() {
+  let ok = true
+  for (const f of ['contractNo', 'customerId', 'contractAmount']) {
+    if (!validateField(f)) ok = false
+  }
+  return ok
+}
+
+async function loadCustomers() {
+  try {
+    const res = await getCustomerList(1, 1000)
+    customers.value = res.rows || []
+  } catch (e) {
+    customers.value = []
+  }
+}
+
+function parseServicePeriod(sp?: string) {
+  if (!sp) return { start: '', end: '' }
+  const parts = sp.split(' 至 ')
+  return { start: parts[0] || '', end: parts[1] || '' }
+}
+
+async function loadDetail(id: number | string) {
+  loading.value = true
+  try {
+    const c = await getContractDetail(id)
+    const sp = parseServicePeriod(c.servicePeriod)
+    form.value = {
+      contractNo: c.contractNo || '',
+      customerId: c.customerId || '',
+      customerName: c.customerName || '',
+      contractAmount: c.contractAmount ?? '',
+      paymentMethod: c.paymentMethod || 'lump_sum',
+      status: c.status || 'draft',
+      signDate: c.signDate || '',
+      serviceStart: sp.start,
+      serviceEnd: sp.end,
+      remark: c.remark || ''
+    }
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+async function submitForm() {
+  if (!validateForm()) {
+    uni.showToast({ title: '请完善表单信息', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    const servicePeriod =
+      form.value.serviceStart && form.value.serviceEnd
+        ? `${form.value.serviceStart} 至 ${form.value.serviceEnd}`
+        : undefined
+    const payload = {
+      contractNo: form.value.contractNo.trim(),
+      customerId: form.value.customerId,
+      customerName: form.value.customerName || undefined,
+      contractAmount: Number(form.value.contractAmount),
+      paymentMethod: form.value.paymentMethod,
+      status: form.value.status,
+      signDate: form.value.signDate || undefined,
+      servicePeriod,
+      remark: form.value.remark || undefined
+    }
+    if (isEdit.value) {
+      await updateContract({ ...payload, id: editId.value })
+    } else {
+      await createContract(payload)
+    }
+    uni.showToast({ title: isEdit.value ? '保存成功' : '创建成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 900)
+  } catch (e) {
+    uni.showToast({ title: isEdit.value ? '保存失败' : '创建失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+onLoad(async (options) => {
+  await loadCustomers()
+  const id = options?.id
+  if (id) {
+    isEdit.value = true
+    editId.value = id
+    await loadDetail(id)
+  }
+})
+</script>

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

@@ -0,0 +1,13 @@
+<template>
+  <view class="app-container">
+    <TopBar title="付款记录" show-back />
+    <view class="px-4 py-4">
+      <EmptyState icon="uniui-list" text="后端暂未提供付款记录接口" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import TopBar from '../../components/common/TopBar.vue'
+import EmptyState from '../../components/common/EmptyState.vue'
+</script>

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

@@ -0,0 +1,184 @@
+<template>
+  <view class="app-container pb-24">
+    <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 text="客户不存在" />
+    </view>
+
+    <view v-else class="px-4 py-4 gap-y-4">
+      <!-- 基本信息 -->
+      <view class="card p-4">
+        <view class="flex items-center justify-between mb-4">
+          <view class="flex-1 min-w-0 mr-3">
+            <text class="text-lg font-bold text-gray-800 truncate">{{ customer.customerName }}</text>
+            <text v-if="customer.createTime" class="text-xs text-gray-400 block mt-1">
+              创建于 {{ formatDate(customer.createTime, 'YYYY-MM-DD') }}
+            </text>
+          </view>
+          <view
+            class="px-2 py-1 rounded text-xs flex-shrink-0"
+            :style="{ backgroundColor: statusBg(customer.status), color: statusColor(customer.status) }"
+          >
+            {{ statusText(customer.status) }}
+          </view>
+        </view>
+
+        <view class="flex items-center justify-between py-3 border-t border-gray-100">
+          <view class="flex items-center">
+            <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
+            <text class="text-sm text-gray-500">联系人</text>
+          </view>
+          <text class="text-sm text-gray-800">{{ customer.contactName || '-' }}</text>
+        </view>
+        <view class="flex items-center justify-between py-3 border-t border-gray-100">
+          <view class="flex items-center">
+            <text class="uni-icons uniui-phone-filled text-primary mr-2"></text>
+            <text class="text-sm text-gray-500">联系电话</text>
+          </view>
+          <view class="flex items-center" @click="callPhone(customer.phone)">
+            <text class="text-sm text-primary">{{ customer.phone || '-' }}</text>
+            <text v-if="customer.phone" class="uni-icons uniui-arrowright text-gray-400 ml-1"></text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 地址信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-location-filled text-primary mr-2"></text>
+          <text>地址信息</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">省市区</text>
+          <text class="detail-value text-right flex-1">{{ region || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">街道</text>
+          <text class="detail-value text-right flex-1">{{ customer.street || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">详细地址</text>
+          <text class="detail-value text-right flex-1">{{ customer.detailAddress || '-' }}</text>
+        </view>
+      </view>
+
+      <!-- 备注 -->
+      <view v-if="customer.remark" class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-compose text-primary mr-2"></text>
+          <text>备注</text>
+        </view>
+        <text class="text-sm text-gray-600">{{ customer.remark }}</text>
+      </view>
+
+      <!-- 关联数据提示 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-info-filled text-primary mr-2"></text>
+          <text>关联项目 / 任务</text>
+        </view>
+        <text class="text-xs text-gray-400">后端暂未提供按客户筛选的接口,待补充后展示</text>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="px-4 pt-2 pb-6 flex gap-3">
+        <button
+          class="flex-1 py-4 bg-white border border-blue-200 text-blue-600 rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="goToEdit"
+        >
+          <text class="uni-icons uniui-compose mr-2"></text>
+          编辑
+        </button>
+        <button
+          class="flex-1 py-4 bg-white border border-red-200 text-red-500 rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="onDelete"
+        >
+          <text class="uni-icons uniui-trash mr-2"></text>
+          删除
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import type { Customer } from '../../types'
+import { getCustomerDetail, deleteCustomer } from '../../api/customer'
+import {
+  formatDate,
+  getCustomerStatusText,
+  getCustomerStatusColor,
+  getCustomerStatusBgColor
+} from '../../utils'
+import TopBar from '../../components/common/TopBar.vue'
+import EmptyState from '../../components/common/EmptyState.vue'
+
+const customer = ref<Customer | null>(null)
+const loading = ref(true)
+const customerId = ref<number | string>('')
+
+const statusText = getCustomerStatusText
+const statusColor = getCustomerStatusColor
+const statusBg = getCustomerStatusBgColor
+
+const region = computed(() => {
+  if (!customer.value) return ''
+  const parts = [customer.value.province, customer.value.city, customer.value.district].filter(Boolean)
+  return parts.join('')
+})
+
+function callPhone(phone?: string) {
+  if (!phone) return
+  uni.makePhoneCall({ phoneNumber: phone })
+}
+
+function goToEdit() {
+  uni.navigateTo({ url: `/subPackages/pages-customer/customerForm?id=${customerId.value}` })
+}
+
+function onDelete() {
+  uni.showModal({
+    title: '确认删除',
+    content: '删除后无法恢复,是否继续?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await deleteCustomer(customerId.value)
+          uni.showToast({ title: '已删除', icon: 'success' })
+          setTimeout(() => uni.navigateBack(), 800)
+        } catch (e) {
+          uni.showToast({ title: '删除失败', icon: 'none' })
+        }
+      }
+    }
+  })
+}
+
+async function loadDetail(id: number | string) {
+  loading.value = true
+  try {
+    customer.value = await getCustomerDetail(id)
+  } catch (e) {
+    customer.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+onLoad((options) => {
+  const id = options?.id
+  if (id) {
+    customerId.value = id
+    loadDetail(id)
+  } else {
+    loading.value = false
+  }
+})
+</script>

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

@@ -0,0 +1,283 @@
+<template>
+  <view class="app-container pb-24">
+    <TopBar :title="isEdit ? '编辑客户' : '新增客户'" show-back />
+
+    <view v-if="loading" class="py-20 text-center">
+      <text class="text-gray-400">加载中...</text>
+    </view>
+
+    <view v-else class="px-4 py-4 gap-y-4">
+      <!-- 基本信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-person-filled text-primary"></text>
+          </view>
+          <text>基本信息</text>
+        </view>
+
+        <view class="gap-y-4">
+          <view>
+            <text class="form-label">客户名称<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入客户名称" v-model="form.customerName" @blur="validateField('customerName')" />
+            <text v-if="errors.customerName" class="text-red-500 text-xs mt-1">{{ errors.customerName }}</text>
+          </view>
+
+          <view>
+            <text class="form-label">客户状态<text class="text-red-500">*</text></text>
+            <picker mode="selector" :range="statusLabels" :value="statusIndex" @change="onStatusChange">
+              <view class="form-select flex items-center">
+                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
+                {{ currentStatusLabel }}
+              </view>
+            </picker>
+          </view>
+        </view>
+      </view>
+
+      <!-- 联系人 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-phone-filled text-purple-500"></text>
+          </view>
+          <text>联系人</text>
+        </view>
+
+        <view class="gap-y-4">
+          <view>
+            <text class="form-label">联系人<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入联系人姓名" v-model="form.contactName" @blur="validateField('contactName')" />
+            <text v-if="errors.contactName" class="text-red-500 text-xs mt-1">{{ errors.contactName }}</text>
+          </view>
+
+          <view>
+            <text class="form-label">联系电话<text class="text-red-500">*</text></text>
+            <input
+              class="form-input"
+              placeholder="请输入联系电话"
+              v-model="form.phone"
+              type="number"
+              maxlength="11"
+              @blur="validateField('phone')"
+            />
+            <text v-if="errors.phone" class="text-red-500 text-xs mt-1">{{ errors.phone }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 地址 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-location-filled text-green-500"></text>
+          </view>
+          <text>地址信息</text>
+        </view>
+
+        <view class="gap-y-4">
+          <view class="grid grid-cols-3 gap-3">
+            <view>
+              <text class="form-label">省</text>
+              <uni-easyinput class="form-input" placeholder="省" v-model="form.province" />
+            </view>
+            <view>
+              <text class="form-label">市</text>
+              <uni-easyinput class="form-input" placeholder="市" v-model="form.city" />
+            </view>
+            <view>
+              <text class="form-label">区/县</text>
+              <uni-easyinput class="form-input" placeholder="区/县" v-model="form.district" />
+            </view>
+          </view>
+
+          <view>
+            <text class="form-label">街道</text>
+            <uni-easyinput class="form-input" placeholder="街道(选填)" v-model="form.street" />
+          </view>
+
+          <view>
+            <text class="form-label">详细地址<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入详细地址" v-model="form.detailAddress" @blur="validateField('detailAddress')" />
+            <text v-if="errors.detailAddress" class="text-red-500 text-xs mt-1">{{ errors.detailAddress }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 备注 -->
+      <view class="card p-4">
+        <view class="detail-section-title mb-4">
+          <view class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center mr-3">
+            <text class="uni-icons uniui-compose text-gray-500"></text>
+          </view>
+          <text>备注</text>
+        </view>
+        <textarea
+          v-model="form.remark"
+          class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
+          placeholder="请输入备注..."
+          :maxlength="500"
+        />
+      </view>
+
+      <!-- 提交 -->
+      <view class="px-4 pt-2 pb-6">
+        <button
+          class="w-full py-4 btn-primary rounded-2xl text-base font-semibold flex items-center justify-center"
+          @click="submitForm"
+          :disabled="submitting"
+        >
+          <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
+          <text v-else class="uni-icons uniui-checkmarkempty mr-2"></text>
+          {{ submitting ? '提交中...' : isEdit ? '保存修改' : '新增客户' }}
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import type { CustomerStatus } from '../../types'
+import { getCustomerDetail, createCustomer, updateCustomer } from '../../api/customer'
+import TopBar from '../../components/common/TopBar.vue'
+
+const statusOptions: { label: string; value: CustomerStatus }[] = [
+  { label: '合作中', value: 1 },
+  { label: '潜在客户', value: 2 },
+  { label: '已停用', value: 0 }
+]
+const statusLabels = statusOptions.map(s => s.label)
+
+const isEdit = ref(false)
+const editId = ref<number | string>('')
+const loading = ref(false)
+const submitting = ref(false)
+
+const form = ref({
+  customerName: '',
+  contactName: '',
+  phone: '',
+  province: '',
+  city: '',
+  district: '',
+  street: '',
+  detailAddress: '',
+  status: 1 as CustomerStatus,
+  remark: ''
+})
+
+const errors = reactive<Record<string, string>>({
+  customerName: '',
+  contactName: '',
+  phone: '',
+  detailAddress: ''
+})
+
+const statusIndex = computed(() => {
+  const idx = statusOptions.findIndex(s => s.value === form.value.status)
+  return idx < 0 ? 0 : idx
+})
+const currentStatusLabel = computed(() => statusOptions[statusIndex.value].label)
+
+function onStatusChange(e: any) {
+  form.value.status = statusOptions[Number(e.detail.value)].value
+}
+
+function validateField(field: string) {
+  const value = (form.value as any)[field] as string
+  errors[field] = ''
+  if (!value || !value.toString().trim()) {
+    const names: Record<string, string> = {
+      customerName: '客户名称',
+      contactName: '联系人',
+      phone: '联系电话',
+      detailAddress: '详细地址'
+    }
+    errors[field] = `请输入${names[field] || field}`
+    return false
+  }
+  if (field === 'phone') {
+    const re = /^1[3-9]\d{9}$/
+    if (!re.test(value)) {
+      errors[field] = '请输入正确的手机号'
+      return false
+    }
+  }
+  return true
+}
+
+function validateForm() {
+  let ok = true
+  for (const f of ['customerName', 'contactName', 'phone', 'detailAddress']) {
+    if (!validateField(f)) ok = false
+  }
+  return ok
+}
+
+async function loadDetail(id: number | string) {
+  loading.value = true
+  try {
+    const c = await getCustomerDetail(id)
+    form.value = {
+      customerName: c.customerName || '',
+      contactName: c.contactName || '',
+      phone: c.phone || '',
+      province: c.province || '',
+      city: c.city || '',
+      district: c.district || '',
+      street: c.street || '',
+      detailAddress: c.detailAddress || '',
+      status: (Number(c.status) as CustomerStatus) ?? 1,
+      remark: c.remark || ''
+    }
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+async function submitForm() {
+  if (!validateForm()) {
+    uni.showToast({ title: '请完善表单信息', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    const payload = {
+      customerName: form.value.customerName.trim(),
+      contactName: form.value.contactName.trim(),
+      phone: form.value.phone.trim(),
+      province: form.value.province || undefined,
+      city: form.value.city || undefined,
+      district: form.value.district || undefined,
+      street: form.value.street || undefined,
+      detailAddress: form.value.detailAddress.trim(),
+      status: form.value.status,
+      remark: form.value.remark || undefined
+    }
+    if (isEdit.value) {
+      await updateCustomer({ ...payload, id: editId.value })
+    } else {
+      await createCustomer(payload)
+    }
+    uni.showToast({ title: isEdit.value ? '保存成功' : '创建成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 900)
+  } catch (e) {
+    uni.showToast({ title: isEdit.value ? '保存失败' : '创建失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+onLoad((options) => {
+  const id = options?.id
+  if (id) {
+    isEdit.value = true
+    editId.value = id
+    loadDetail(id)
+  }
+})
+</script>

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

@@ -0,0 +1,13 @@
+<template>
+  <view class="app-container">
+    <TopBar title="跟进记录" show-back />
+    <view class="px-4 py-4">
+      <EmptyState icon="uniui-chat-filled" text="后端暂未提供跟进记录接口" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import TopBar from '../../components/common/TopBar.vue'
+import EmptyState from '../../components/common/EmptyState.vue'
+</script>

+ 50 - 0
src/types/index.ts

@@ -58,6 +58,56 @@ export interface Project {
   updateTime: string
 }
 
+/** 后端分页结果(PageResult) */
+export interface PageResult<T> {
+  rows: T[]
+  total: number
+  current: number
+  size: number
+  pages: number
+}
+
+/** 客户状态:1 合作中 / 0 已停用 / 2 潜在客户(后端硬编码) */
+export type CustomerStatus = 0 | 1 | 2
+
+export interface Customer {
+  id: number | string
+  customerName: string
+  contactName?: string
+  phone?: string
+  address?: string
+  province?: string
+  city?: string
+  district?: string
+  street?: string
+  detailAddress?: string
+  latitude?: string
+  longitude?: string
+  status: CustomerStatus
+  remark?: string
+  createTime?: string
+  updateTime?: string
+}
+
+export type ContractStatus = 'draft' | 'pending' | 'active' | 'expired' | 'terminated'
+export type PaymentMethod = 'lump_sum' | 'installment'
+
+export interface Contract {
+  id: number | string
+  contractNo: string
+  customerId: number | string
+  customerName?: string
+  projectId?: number | string
+  contractAmount: number
+  paymentMethod: PaymentMethod
+  signDate?: string
+  servicePeriod?: string
+  status: ContractStatus
+  remark?: string
+  createTime?: string
+  updateTime?: string
+}
+
 export type TaskType = 'regular' | 'emergency'
 export type TaskStatus =
   | 'pending'

+ 87 - 0
src/utils/index.ts

@@ -329,3 +329,90 @@ export function getNotificationTypeText(type: string): string {
   }
   return map[type] || type
 }
+
+/**
+ * 客户状态文本(1 合作中 / 0 已停用 / 2 潜在客户)
+ */
+export function getCustomerStatusText(status: number | string): string {
+  const map: Record<string, string> = {
+    1: '合作中',
+    0: '已停用',
+    2: '潜在客户'
+  }
+  return map[String(status)] || '-'
+}
+
+export function getCustomerStatusColor(status: number | string): string {
+  const map: Record<string, string> = {
+    1: '#10b981',
+    2: '#2563eb',
+    0: '#6b7280'
+  }
+  return map[String(status)] || '#6b7280'
+}
+
+export function getCustomerStatusBgColor(status: number | string): string {
+  const map: Record<string, string> = {
+    1: '#ecfdf5',
+    2: '#eff6ff',
+    0: '#f3f4f6'
+  }
+  return map[String(status)] || '#f3f4f6'
+}
+
+/**
+ * 合同状态文本
+ */
+export function getContractStatusText(status: string): string {
+  const map: Record<string, string> = {
+    draft: '草稿',
+    pending: '待生效',
+    active: '生效中',
+    expired: '已过期',
+    terminated: '已终止'
+  }
+  return map[status] || status || '-'
+}
+
+export function getContractStatusColor(status: string): string {
+  const map: Record<string, string> = {
+    draft: '#6b7280',
+    pending: '#f59e0b',
+    active: '#10b981',
+    expired: '#ef4444',
+    terminated: '#ef4444'
+  }
+  return map[status] || '#6b7280'
+}
+
+export function getContractStatusBgColor(status: string): string {
+  const map: Record<string, string> = {
+    draft: '#f3f4f6',
+    pending: '#fff7ed',
+    active: '#ecfdf5',
+    expired: '#fef2f2',
+    terminated: '#fef2f2'
+  }
+  return map[status] || '#f3f4f6'
+}
+
+/**
+ * 付款方式文本
+ */
+export function getPaymentMethodText(method: string): string {
+  const map: Record<string, string> = {
+    lump_sum: '一次性',
+    installment: '分期'
+  }
+  return map[method] || method || '-'
+}
+
+/**
+ * 金额格式化(元,保留两位)
+ */
+export function formatMoney(amount: number | string | undefined | null): string {
+  if (amount === undefined || amount === null || amount === '') return '-'
+  const num = typeof amount === 'string' ? Number(amount) : amount
+  if (isNaN(num)) return '-'
+  return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+}