customer.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <template>
  2. <view class="app-container list-page pb-24">
  3. <TopBar title="客户管理" show-back />
  4. <!-- 工具栏:搜索 + 新增 -->
  5. <view class="toolbar px-4 py-3 sticky top-0 z-40 flex items-center gap-2">
  6. <view class="search-box flex-1 min-w-0 flex items-center rounded-2xl px-4 py-2" :class="{ 'search-focus': focusedSearch }">
  7. <text class="uni-icons uniui-search search-icon mr-2 text-base"></text>
  8. <input
  9. class="flex-1 bg-transparent text-sm text-gray-800"
  10. placeholder="搜索客户名称/联系人/电话"
  11. placeholder-class="search-ph"
  12. v-model="searchKeyword"
  13. confirm-type="search"
  14. @focus="focusedSearch = true"
  15. @blur="focusedSearch = false"
  16. @confirm="onSearch"
  17. />
  18. </view>
  19. <view
  20. class="add-btn w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0"
  21. hover-class="add-btn-hover"
  22. :hover-start-time="0"
  23. :hover-stay-time="120"
  24. @click="goToAdd"
  25. >
  26. <text class="uni-icons uniui-plusempty text-white text-xl"></text>
  27. </view>
  28. </view>
  29. <!-- 状态筛选 -->
  30. <scroll-view scroll-x class="filter-bar" :show-scrollbar="false" enhanced>
  31. <view class="flex items-center gap-2 px-4 py-3">
  32. <view
  33. v-for="(t, i) in filterTypes"
  34. :key="t.value"
  35. class="filter-tab flex-shrink-0 rounded-full px-4 py-1.5 text-sm border"
  36. :class="i === currentFilterIndex ? 'filter-tab--active' : 'filter-tab--normal'"
  37. @click="onFilterChange(i)"
  38. >
  39. {{ t.label }}
  40. </view>
  41. </view>
  42. </scroll-view>
  43. <!-- 列表 -->
  44. <view class="px-4 mt-3">
  45. <view v-if="customerStore.loading" class="py-16 text-center">
  46. <text class="loading-text">加载中...</text>
  47. </view>
  48. <view v-else-if="filteredCustomers.length === 0">
  49. <EmptyState icon="uniui-personadd-filled" text="暂无客户" />
  50. </view>
  51. <view v-else class="gap-y-3">
  52. <uni-swipe-action
  53. v-for="customer in filteredCustomers"
  54. :key="customer.id"
  55. :actions="swipeActions"
  56. @click="onSwipeAction(customer.id, $event)"
  57. >
  58. <view class="data-card" @click="goToDetail(customer.id)">
  59. <!-- 名称 + 状态 -->
  60. <view class="flex items-start justify-between gap-2">
  61. <text class="card-title truncate">{{ customer.customerName }}</text>
  62. <view
  63. class="status-pill flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs"
  64. :style="{ backgroundColor: statusBg(customer.status), color: statusColor(customer.status) }"
  65. >
  66. {{ statusText(customer.status) }}
  67. </view>
  68. </view>
  69. <!-- 联系人 + 电话 -->
  70. <view v-if="customer.contactName || customer.phone" class="mt-1.5 flex items-center flex-wrap">
  71. <view v-if="customer.contactName" class="info-item flex items-center mr-4">
  72. <text class="uni-icons uniui-person-filled info-icon text-xs mr-1"></text>
  73. <text class="info-text text-xs truncate">{{ customer.contactName }}</text>
  74. </view>
  75. <view v-if="customer.phone" class="info-item flex items-center">
  76. <text class="uni-icons uniui-phone-filled info-icon text-xs mr-1"></text>
  77. <text class="info-text text-xs">{{ customer.phone }}</text>
  78. </view>
  79. </view>
  80. <view v-else class="mt-1.5">
  81. <text class="info-text text-xs">暂无联系人信息</text>
  82. </view>
  83. <!-- 地址 -->
  84. <view v-if="fullAddress(customer)" class="card-foot mt-2.5 pt-2 flex items-center">
  85. <text class="uni-icons uniui-location-filled foot-icon text-xs mr-1 flex-shrink-0"></text>
  86. <text class="foot-text text-xs truncate flex-1">{{ fullAddress(customer) }}</text>
  87. </view>
  88. </view>
  89. </uni-swipe-action>
  90. </view>
  91. <!-- 加载更多 -->
  92. <view v-if="filteredCustomers.length > 0" class="mt-2 mb-4">
  93. <view v-if="customerStore.hasMore" class="text-center py-4">
  94. <text class="load-more" @click="loadMore">
  95. {{ customerStore.loadingMore ? '加载中...' : '加载更多' }}
  96. </text>
  97. </view>
  98. <view v-else class="text-center py-4">
  99. <text class="text-xs text-gray-400">已经到底了</text>
  100. </view>
  101. </view>
  102. </view>
  103. </view>
  104. </template>
  105. <script setup lang="ts">
  106. import { ref, computed, onMounted, onUnmounted } from 'vue'
  107. import { onPullDownRefresh } from '@dcloudio/uni-app'
  108. import { useCustomerStore } from '@/stores/customer'
  109. import type { Customer } from '@/types'
  110. import {
  111. getCustomerStatusText,
  112. getCustomerStatusColor,
  113. getCustomerStatusBgColor
  114. } from '@/utils'
  115. import TopBar from '@/components/common/TopBar.vue'
  116. import EmptyState from '@/components/common/EmptyState.vue'
  117. import UniSwipeAction from '@/components/uni-swipe-action/uni-swipe-action.vue'
  118. const customerStore = useCustomerStore()
  119. const searchKeyword = ref('')
  120. const currentFilterIndex = ref(0)
  121. const focusedSearch = ref(false)
  122. const filterTypes = [
  123. { value: 'all', label: '全部' },
  124. { value: '1', label: '合作中' },
  125. { value: '2', label: '潜在客户' },
  126. { value: '0', label: '已停用' }
  127. ]
  128. const swipeActions = [
  129. { text: '编辑', color: '#368f6f', key: 'edit' },
  130. { text: '删除', color: '#ff3b30', key: 'delete' }
  131. ]
  132. const statusText = getCustomerStatusText
  133. const statusColor = getCustomerStatusColor
  134. const statusBg = getCustomerStatusBgColor
  135. const filteredCustomers = computed(() => {
  136. const filterValue = filterTypes[currentFilterIndex.value].value
  137. if (filterValue === 'all') return customerStore.customers
  138. return customerStore.customers.filter(c => String(c.status) === filterValue)
  139. })
  140. function fullAddress(customer: Customer) {
  141. return [customer.province, customer.city, customer.district, customer.street, customer.detailAddress]
  142. .filter(Boolean)
  143. .join('')
  144. }
  145. let searchTimer: ReturnType<typeof setTimeout> | null = null
  146. function onSearch() {
  147. if (searchTimer) clearTimeout(searchTimer)
  148. searchTimer = setTimeout(() => {
  149. customerStore.setKeyword(searchKeyword.value.trim())
  150. customerStore.fetchCustomers(true)
  151. }, 300)
  152. }
  153. function onFilterChange(index: number) {
  154. currentFilterIndex.value = index
  155. }
  156. function goToDetail(id: number | string) {
  157. customerStore.setCurrentCustomer(id)
  158. uni.navigateTo({ url: `/subPackages/pages-customer/customerDetail?id=${id}` })
  159. }
  160. function goToAdd() {
  161. uni.navigateTo({ url: '/subPackages/pages-customer/customerForm' })
  162. }
  163. function onSwipeAction(id: number | string, action: { key?: string }) {
  164. if (action.key === 'edit') {
  165. uni.navigateTo({ url: `/subPackages/pages-customer/customerForm?id=${id}` })
  166. } else if (action.key === 'delete') {
  167. uni.showModal({
  168. title: '确认删除',
  169. content: '删除后无法恢复,是否继续?',
  170. success: async (res) => {
  171. if (res.confirm) {
  172. try {
  173. await customerStore.removeCustomer(id)
  174. uni.showToast({ title: '已删除', icon: 'success' })
  175. } catch (e) {
  176. uni.showToast({ title: '删除失败', icon: 'none' })
  177. }
  178. }
  179. }
  180. })
  181. }
  182. }
  183. function loadMore() {
  184. customerStore.loadMore()
  185. }
  186. async function refreshData() {
  187. await customerStore.fetchCustomers(true)
  188. uni.stopPullDownRefresh()
  189. }
  190. onMounted(() => {
  191. customerStore.fetchCustomers(true)
  192. })
  193. onUnmounted(() => {
  194. if (searchTimer) clearTimeout(searchTimer)
  195. })
  196. onPullDownRefresh(() => {
  197. refreshData()
  198. })
  199. </script>
  200. <style scoped>
  201. .list-page {
  202. background-color: #f6fbf9;
  203. }
  204. .toolbar {
  205. background-color: rgba(246, 251, 249, 0.88);
  206. backdrop-filter: blur(12px);
  207. -webkit-backdrop-filter: blur(12px);
  208. border-bottom: 1px solid rgba(164, 216, 152, 0.28);
  209. }
  210. .search-box {
  211. background-color: #ffffff;
  212. border: 1px solid #e5e7eb;
  213. transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
  214. }
  215. .search-focus {
  216. background-color: #ffffff;
  217. border-color: #368f6f;
  218. box-shadow: 0 0 0 3px rgba(164, 216, 152, 0.30);
  219. }
  220. .search-icon {
  221. color: #9ca3af;
  222. }
  223. .search-ph {
  224. color: #9ca3af;
  225. }
  226. .add-btn {
  227. background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
  228. box-shadow: 0 8px 18px -8px rgba(54, 143, 111, 0.55);
  229. }
  230. .add-btn-hover {
  231. opacity: 0.92;
  232. transform: scale(0.94);
  233. }
  234. .filter-bar {
  235. background-color: rgba(246, 251, 249, 0.88);
  236. border-bottom: 1px solid rgba(164, 216, 152, 0.20);
  237. white-space: nowrap;
  238. }
  239. .filter-tab {
  240. transition: all 0.15s ease;
  241. }
  242. .filter-tab--normal {
  243. background-color: #ffffff;
  244. border-color: #e5e7eb;
  245. color: #4b5563;
  246. }
  247. .filter-tab--active {
  248. background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
  249. border-color: transparent;
  250. color: #ffffff;
  251. box-shadow: 0 6px 14px -8px rgba(54, 143, 111, 0.6);
  252. }
  253. .data-card {
  254. background-color: #ffffff;
  255. border-radius: 20px;
  256. padding: 14px 16px;
  257. box-shadow: 0 14px 34px -18px rgba(54, 143, 111, 0.35);
  258. border: 1px solid rgba(164, 216, 152, 0.28);
  259. }
  260. .data-card:active {
  261. transform: scale(0.99);
  262. background-color: #fbfefb;
  263. }
  264. .card-title {
  265. font-size: 15px;
  266. font-weight: 600;
  267. color: #1f2937;
  268. }
  269. .status-pill {
  270. font-weight: 500;
  271. }
  272. .info-icon {
  273. color: #5ab8d0;
  274. }
  275. .info-text {
  276. color: #6b7280;
  277. }
  278. /* 底部地址行 */
  279. .card-foot {
  280. border-top: 1px dashed rgba(164, 216, 152, 0.45);
  281. }
  282. .foot-icon {
  283. color: #368f6f;
  284. }
  285. .foot-text {
  286. color: #9ca3af;
  287. }
  288. .loading-text {
  289. color: #6b7280;
  290. }
  291. .load-more {
  292. font-size: 14px;
  293. font-weight: 500;
  294. color: #368f6f;
  295. }
  296. </style>