Selaa lähdekoodia

feat(web-admin): unify CRM confirm dialogs for customer and product.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 viikkoa sitten
vanhempi
commit
4fd178499e

+ 43 - 0
web-admin/scripts/test-mp-crm-customer-confirm-dom.mjs

@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const root = new URL('../src/views/m/crm/customer/', import.meta.url)
+const files = [
+  'index.vue',
+  'detail.vue',
+  'form.vue',
+  'ContactPanel.vue',
+  'FollowPanel.vue',
+  'InvoicePanel.vue',
+  'OpportunityActions.vue',
+  'CustomerPoolSheet.vue'
+]
+
+const sources = {}
+for (const name of files) {
+  sources[name] = await readFile(new URL(name, root), 'utf8')
+}
+
+const customerScss = await readFile(new URL('customer.scss', root), 'utf8')
+assert.match(
+  customerScss,
+  /van-dialog:not\(\.mp-crm-dialog\)/,
+  '客户 scss 不得用旧 Dialog 按钮样式压扁 mp-crm-dialog'
+)
+
+for (const [name, src] of Object.entries(sources)) {
+  assert.doesNotMatch(src, /showConfirmDialog\s*\(/, `${name} 不得直接调用 showConfirmDialog`)
+  assert.match(src, /showCrmConfirmDialog|from '@\/utils\/mp-crm-confirm'/, `${name} 必须使用 CRM 确认弹窗`)
+}
+
+assert.match(sources['index.vue'], /kind:\s*'danger'/, '列表删除客户须 danger')
+assert.match(sources['detail.vue'], /kind:\s*'danger'/, '详情删除客户须 danger')
+assert.match(sources['ContactPanel.vue'], /kind:\s*'danger'/, '删除联系人须 danger')
+assert.match(sources['ContactPanel.vue'], /kind:\s*'default'/, '设主要联系人须 default')
+assert.match(sources['FollowPanel.vue'], /kind:\s*'danger'/, '删除跟进须 danger')
+assert.match(sources['InvoicePanel.vue'], /kind:\s*'danger'/, '删除开票须 danger')
+assert.match(sources['OpportunityActions.vue'], /kind:\s*'danger'/, '删除商机须 danger')
+assert.match(sources['form.vue'], /kind:\s*'default'/, '新增字典项须 default')
+assert.match(sources['CustomerPoolSheet.vue'], /kind:\s*'default'/, '放入公海须 default')
+
+console.log('mp-crm-customer-confirm-dom: ok')

+ 13 - 11
web-admin/scripts/test-mp-crm-product-confirm-dom.mjs

@@ -2,27 +2,29 @@ import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
 
 const root = new URL('../src/views/m/crm/product/', import.meta.url)
-const util = await readFile(new URL('../src/utils/mp-crm-product-confirm.ts', import.meta.url), 'utf8')
-const scss = await readFile(new URL('product.scss', root), 'utf8')
+const util = await readFile(new URL('../src/utils/mp-crm-confirm.ts', import.meta.url), 'utf8')
+const alias = await readFile(new URL('../src/utils/mp-crm-product-confirm.ts', import.meta.url), 'utf8')
+const primitives = await readFile(new URL('../src/styles/mp-crm-primitives.scss', import.meta.url), 'utf8')
 const list = await readFile(new URL('index.vue', root), 'utf8')
 const detail = await readFile(new URL('detail.vue', root), 'utf8')
 const categories = await readFile(new URL('categories.vue', root), 'utf8')
 
-assert.match(util, /ProductConfirmKind\s*=\s*'default'\s*\|\s*'danger'/, 'helper 必须支持 danger kind')
+assert.match(util, /CrmConfirmKind\s*=\s*'default'\s*\|\s*'danger'/, 'helper 必须支持 danger kind')
 assert.match(util, /kind\s*===\s*'danger'/, 'helper 必须分支处理 danger')
-assert.match(util, /mp-product-dialog--danger/, 'helper 必须带 danger className')
-assert.match(util, /mp-product-dialog__title/, '方案 B 标题须写入 HTML(图标在上)')
+assert.match(util, /mp-crm-dialog--danger/, 'helper 必须带 danger className')
+assert.match(util, /mp-crm-dialog__title/, '方案 B 标题须写入 HTML')
 assert.match(util, /#dc2626/, '删除确认色必须为危险红')
 assert.match(util, /#0a9f6d/, '普通确认色必须为三墙绿')
 assert.doesNotMatch(util, /round-button/, '禁止 round-button 主题')
 assert.doesNotMatch(util, /#10b981/, '禁止 Fresh Mint')
 
-assert.match(scss, /body \.van-dialog\.mp-product-dialog/, 'scss 必须限定产品 Dialog')
-assert.match(scss, /mp-product-dialog--danger/, 'scss 必须覆盖危险确认键')
-assert.match(scss, /mp-product-dialog__warn-icon/, 'scss 必须有警示图标')
-assert.match(scss, /mp-product-dialog__title/, 'scss 必须有居中标题')
-assert.match(scss, /text-align:\s*center/, '标题/文案须居中')
-assert.doesNotMatch(scss, /#10b981/, 'scss 禁止 Fresh Mint')
+assert.match(alias, /showCrmConfirmDialog as showProductConfirmDialog/, '产品侧须转发共用 helper')
+
+assert.match(primitives, /body \.van-dialog\.mp-crm-dialog/, 'primitives 必须限定 CRM Dialog')
+assert.match(primitives, /mp-crm-dialog--danger/, 'primitives 必须覆盖危险确认键')
+assert.match(primitives, /mp-crm-dialog__warn-icon/, 'primitives 必须有警示图标')
+assert.match(primitives, /mp-crm-dialog__title/, 'primitives 必须有居中标题')
+assert.doesNotMatch(primitives, /#10b981/, 'scss 禁止 Fresh Mint')
 
 for (const [name, src] of [
   ['index', list],

+ 532 - 0
web-admin/src/styles/mp-crm-primitives.scss

@@ -0,0 +1,532 @@
+/* 工作台业务页视觉原语:仅通过局部 modifier 启用,避免改变历史两栏筛选消费者。 */
+.mp-wv-touch-target {
+  min-width: 44px;
+  min-height: 44px;
+}
+
+.mp-wv-status-tabs {
+  display: flex;
+  gap: 8px;
+  padding: 0 0 10px;
+  overflow-x: auto;
+  scrollbar-width: none;
+  -webkit-overflow-scrolling: touch;
+}
+
+.mp-wv-status-tabs::-webkit-scrollbar {
+  display: none;
+}
+
+.mp-wv-status-tabs .filter-tag {
+  flex: 0 0 auto;
+  min-height: 44px;
+  padding: 0 14px;
+  font-size: 13px;
+  color: #6b7b74;
+  background: #fff;
+  border: 1px solid #dcebe4;
+  border-radius: 999px;
+}
+
+.mp-wv-status-tabs .filter-tag.is-active {
+  font-weight: 600;
+  color: #087f57;
+  background: #e9f9f2;
+  border-color: #0a9f6d;
+}
+
+.mp-wv-status-tabs .filter-tag:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+.mp-wv-status-tabs .filter-tag__count {
+  margin-left: 4px;
+  font-size: 11px;
+}
+
+.mp-wv-filter-wrap.mp-wv-filter-wrap--business {
+  z-index: 21;
+}
+
+.mp-wv-filter-bar.mp-wv-filter-bar--three {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.mp-wv-filter-bar--three .mp-wv-filter-trig {
+  min-height: 44px;
+}
+
+.mp-wv-filter-wrap--business .mp-wv-filter-opt {
+  min-height: 44px;
+}
+
+.mp-wv-filter-wrap--business > .mp-wv-filter-mask {
+  z-index: 7;
+}
+
+.mp-wv-filter-more__dot {
+  position: absolute;
+  top: 9px;
+  right: calc(50% - 24px);
+  width: 7px;
+  height: 7px;
+  background: #dc2626;
+  border: 1px solid #fff;
+  border-radius: 50%;
+}
+
+.mp-crm-expense-card {
+  margin-bottom: 10px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #dcebe4;
+  border-radius: 14px;
+  box-shadow: 0 6px 16px rgb(10 159 109 / 8%);
+}
+
+.mp-crm-expense-card .ec-top {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  padding: 12px 12px 8px;
+}
+
+.mp-crm-expense-card .expense-card__select {
+  display: inline-flex;
+  flex: 0 0 44px;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  background: transparent;
+  border: 0;
+}
+
+.mp-crm-expense-card .expense-card__select > span {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+  color: #fff;
+  background: #0a9f6d;
+  border: 1px solid #0a9f6d;
+  border-radius: 50%;
+}
+
+.mp-crm-expense-card .expense-card__select[aria-pressed='false'] > span {
+  background: #fff;
+  border-color: #a8bbb2;
+}
+
+.mp-crm-expense-card .ec-title {
+  flex: 1;
+  min-width: 0;
+  margin: 0;
+  overflow: hidden;
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2a26;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.mp-crm-expense-card .expense-card__open {
+  display: block;
+  width: 100%;
+  padding: 0;
+  overflow: hidden;
+  font: inherit;
+  color: inherit;
+  text-align: left;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+}
+
+.mp-crm-expense-card .status-tag,
+.mp-crm-approval-timeline .status-tag {
+  display: inline-flex;
+  flex: 0 0 auto;
+  align-items: center;
+  min-height: 24px;
+  padding: 2px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 1.2;
+  border: 1px solid transparent;
+  border-radius: 999px;
+}
+
+.mp-crm-expense-card .status-tag--success,
+.mp-crm-approval-timeline .status-tag--success {
+  color: #087f57;
+  background: #e9f9f2;
+  border-color: #b7ebce;
+}
+
+.mp-crm-expense-card .status-tag--primary,
+.mp-crm-approval-timeline .status-tag--primary {
+  color: #087f57;
+  background: #e9f9f2;
+  border-color: #b7ebce;
+}
+
+.mp-crm-expense-card .status-tag--warning,
+.mp-crm-approval-timeline .status-tag--warning {
+  color: #a16207;
+  background: #fffbeb;
+  border-color: #fde68a;
+}
+
+.mp-crm-expense-card .status-tag--danger,
+.mp-crm-approval-timeline .status-tag--danger {
+  color: #b91c1c;
+  background: #fef2f2;
+  border-color: #fecaca;
+}
+
+.mp-crm-expense-card .status-tag--info,
+.mp-crm-approval-timeline .status-tag--info {
+  color: #475569;
+  background: #f1f5f9;
+  border-color: #dbe4ee;
+}
+
+.mp-crm-expense-card .ec-amount {
+  padding: 4px 12px 10px;
+}
+
+.mp-crm-expense-card .ec-amount__label {
+  display: block;
+  margin-bottom: 2px;
+  font-size: 11px;
+  color: #6b7b74;
+}
+
+.mp-crm-expense-card .ec-amount__value {
+  font-size: 24px;
+  line-height: 1.2;
+  color: #087f57;
+}
+
+.mp-crm-expense-card .ec-emphasis {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 7px 12px;
+  font-size: 13px;
+  color: #334155;
+  border-top: 1px solid #f0f4f2;
+}
+
+.mp-crm-expense-card .list-card-kv {
+  padding: 8px 10px;
+  margin: 0 12px 8px;
+  font-size: 12px;
+  line-height: 1.55;
+  color: #475569;
+  background: #f7faf8;
+  border-radius: 9px;
+}
+
+.mp-crm-expense-card .ec-time {
+  padding: 0 12px 10px;
+  margin: 0;
+  font-size: 11px;
+  color: #94a3b8;
+}
+
+.mp-crm-expense-card .ec-bottom {
+  padding: 8px 12px;
+  border-top: 1px dashed #cbded4;
+}
+
+.mp-crm-expense-card .op-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  justify-content: flex-end;
+}
+
+.mp-crm-expense-card .op-actions > button,
+.mp-crm-expense-card .op-actions > a {
+  min-height: 44px;
+}
+
+.mp-crm-att-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, 72px);
+  gap: 10px;
+  padding: 0;
+  margin: 0;
+  list-style: none;
+}
+
+.mp-crm-att-grid .att-item,
+.mp-crm-att-grid .att-img,
+.mp-crm-att-grid .att-doc-thumb,
+.mp-crm-att-grid .att-add {
+  width: 72px;
+  height: 72px;
+}
+
+.mp-crm-att-grid .att-item {
+  position: relative;
+}
+
+.mp-crm-att-grid .att-preview,
+.mp-crm-att-grid .att-add {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  overflow: hidden;
+  background: #f7faf8;
+  border: 1px solid #dcebe4;
+  border-radius: 8px;
+}
+
+.mp-crm-att-grid .att-img {
+  display: block;
+  object-fit: cover;
+}
+
+.mp-crm-att-grid .att-doc-thumb {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  align-items: center;
+  justify-content: center;
+  padding: 6px;
+  color: #087f57;
+  text-align: center;
+}
+
+.mp-crm-att-grid .att-doc-thumb small {
+  width: 100%;
+  overflow: hidden;
+  font-size: 10px;
+  color: #6b7b74;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.mp-crm-att-grid .att-add {
+  flex-direction: column;
+  gap: 2px;
+  color: #087f57;
+  background: #fff;
+  border-color: #8ed8b8;
+  border-style: dashed;
+}
+
+.mp-crm-att-grid .att-add > span {
+  font-size: 24px;
+  line-height: 1;
+}
+
+.mp-crm-att-grid .att-remove {
+  position: absolute;
+  top: -11px;
+  right: -11px;
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  padding: 0;
+  font-size: 16px;
+  line-height: 1;
+  color: #fff;
+  background: radial-gradient(circle, #dc2626 0 10px, transparent 11px);
+  border: 0;
+}
+
+.mp-crm-approval-timeline {
+  padding: 0;
+  margin: 0;
+  list-style: none;
+}
+
+.mp-crm-approval-timeline__item {
+  display: grid;
+  grid-template-columns: 38px minmax(0, 1fr);
+  gap: 10px;
+  min-height: 64px;
+}
+
+.mp-crm-approval-timeline__rail {
+  position: relative;
+  display: flex;
+  justify-content: center;
+}
+
+.mp-crm-approval-timeline__rail::after {
+  position: absolute;
+  top: 34px;
+  bottom: 0;
+  width: 1px;
+  content: '';
+  background: #cfe7dc;
+}
+
+.mp-crm-approval-timeline__item:last-child .mp-crm-approval-timeline__rail::after {
+  display: none;
+}
+
+.mp-crm-approval-timeline__avatar {
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  font-size: 13px;
+  font-weight: 700;
+  color: #fff;
+  background: #0a9f6d;
+  border-radius: 50%;
+}
+
+.mp-crm-approval-timeline__content {
+  padding: 0 0 16px;
+}
+
+.mp-crm-approval-timeline__head {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 32px;
+  font-size: 13px;
+}
+
+.mp-crm-approval-timeline__opinion,
+.mp-crm-approval-timeline__time {
+  display: block;
+  margin: 4px 0 0;
+  font-size: 12px;
+  line-height: 1.45;
+  color: #6b7b74;
+}
+
+/* ── CRM 确认弹窗:方案 A(绿)/ B(红+警示),className 限定 ── */
+body .van-dialog.mp-crm-dialog {
+  overflow: hidden;
+  width: 300px !important;
+  max-width: calc(100vw - 48px);
+  border-radius: 16px;
+  box-shadow: 0 16px 40px rgb(15 40 30 / 22%);
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__header {
+  padding: 20px 20px 0;
+  font-size: 16px;
+  font-weight: 700;
+  color: #1f2a26;
+  line-height: 1.4;
+  text-align: center;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__content {
+  padding: 0;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__message {
+  padding: 10px 20px 20px;
+  font-size: 14px;
+  line-height: 1.55;
+  color: #6b7b74;
+  text-align: center;
+}
+
+body .van-dialog.mp-crm-dialog.mp-crm-dialog--danger .van-dialog__header {
+  display: none;
+}
+
+body .van-dialog.mp-crm-dialog.mp-crm-dialog--danger .van-dialog__message {
+  padding: 20px 20px 20px;
+}
+
+body .van-dialog.mp-crm-dialog .mp-crm-dialog__warn {
+  display: flex;
+  justify-content: center;
+  margin: 0 0 12px;
+}
+
+body .van-dialog.mp-crm-dialog .mp-crm-dialog__warn-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  font-size: 22px;
+  font-weight: 700;
+  line-height: 1;
+  color: #dc2626;
+  background: #fef2f2;
+  border-radius: 50%;
+}
+
+body .van-dialog.mp-crm-dialog .mp-crm-dialog__title {
+  margin: 0 0 8px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1.4;
+  color: #1f2a26;
+  text-align: center;
+}
+
+body .van-dialog.mp-crm-dialog .mp-crm-dialog__msg {
+  color: #6b7b74;
+  text-align: center;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__footer {
+  display: flex;
+  gap: 10px;
+  padding: 0 16px 16px;
+  border-top: none !important;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__footer::after,
+body .van-dialog.mp-crm-dialog .van-hairline--left::after,
+body .van-dialog.mp-crm-dialog .van-hairline--top::after {
+  display: none !important;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__cancel,
+body .van-dialog.mp-crm-dialog .van-dialog__confirm {
+  flex: 1;
+  height: 40px !important;
+  min-height: 40px !important;
+  margin: 0 !important;
+  padding: 0 10px !important;
+  font-size: 14px !important;
+  font-weight: 600 !important;
+  line-height: 40px !important;
+  border-radius: 10px !important;
+  border: 1px solid #dcebe4 !important;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__cancel {
+  color: #1f2a26 !important;
+  background: #fff !important;
+}
+
+body .van-dialog.mp-crm-dialog .van-dialog__confirm {
+  color: #fff !important;
+  background: #0a9f6d !important;
+  border-color: #0a9f6d !important;
+}
+
+body .van-dialog.mp-crm-dialog.mp-crm-dialog--danger .van-dialog__confirm {
+  background: #dc2626 !important;
+  border-color: #dc2626 !important;
+}

+ 49 - 0
web-admin/src/utils/mp-crm-confirm.test.ts

@@ -0,0 +1,49 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import { buildCrmConfirmOptions } from './mp-crm-confirm'
+
+describe('buildCrmConfirmOptions', () => {
+  it('builds scheme A for non-delete confirms', () => {
+    const opts = buildCrmConfirmOptions({
+      kind: 'default',
+      title: '放入公海',
+      message: '确定将客户放入公海吗?'
+    })
+    assert.equal(opts.title, '放入公海')
+    assert.equal(opts.message, '确定将客户放入公海吗?')
+    assert.equal(opts.allowHtml, false)
+    assert.equal(opts.className, 'mp-crm-dialog')
+    assert.equal(opts.confirmButtonText, '确认')
+    assert.equal(opts.confirmButtonColor, '#0a9f6d')
+    assert.equal(opts.theme, undefined)
+  })
+
+  it('builds scheme B for delete confirms with warn html', () => {
+    const opts = buildCrmConfirmOptions({
+      kind: 'danger',
+      title: '删除客户',
+      message: '确认删除该客户吗?'
+    })
+    assert.equal(opts.title, '')
+    assert.equal(opts.allowHtml, true)
+    assert.equal(opts.className, 'mp-crm-dialog mp-crm-dialog--danger')
+    assert.equal(opts.confirmButtonText, '确认删除')
+    assert.equal(opts.confirmButtonColor, '#dc2626')
+    assert.match(String(opts.message), /mp-crm-dialog__warn/)
+    assert.match(String(opts.message), /mp-crm-dialog__title/)
+    assert.match(String(opts.message), /删除客户/)
+    assert.match(String(opts.message), /确认删除该客户吗?/)
+    assert.doesNotMatch(String(opts.message), /<script/)
+    assert.equal(opts.theme, undefined)
+  })
+
+  it('escapes html in danger title and message body', () => {
+    const opts = buildCrmConfirmOptions({
+      kind: 'danger',
+      title: '删 <b>除</b>',
+      message: 'a <b>x</b> & "y"'
+    })
+    assert.match(String(opts.message), /删 &lt;b&gt;除&lt;\/b&gt;/)
+    assert.match(String(opts.message), /a &lt;b&gt;x&lt;\/b&gt; &amp; &quot;y&quot;/)
+  })
+})

+ 44 - 0
web-admin/src/utils/mp-crm-confirm.ts

@@ -0,0 +1,44 @@
+import { showConfirmDialog, type DialogOptions } from 'vant'
+
+export type CrmConfirmKind = 'default' | 'danger'
+
+export type BuildCrmConfirmInput = {
+  kind: CrmConfirmKind
+  title: string
+  message: string
+  confirmButtonText?: string
+}
+
+function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+/** 方案 B:图标在上、标题居中(对齐 Demo),标题写入 message HTML */
+function dangerMessageHtml(title: string, message: string): string {
+  return (
+    `<div class="mp-crm-dialog__warn"><span class="mp-crm-dialog__warn-icon">!</span></div>` +
+    `<div class="mp-crm-dialog__title">${escapeHtml(title)}</div>` +
+    `<div class="mp-crm-dialog__msg">${escapeHtml(message)}</div>`
+  )
+}
+
+export function buildCrmConfirmOptions(input: BuildCrmConfirmInput): DialogOptions {
+  const isDanger = input.kind === 'danger'
+  return {
+    title: isDanger ? '' : input.title,
+    message: isDanger ? dangerMessageHtml(input.title, input.message) : input.message,
+    allowHtml: isDanger,
+    className: isDanger ? 'mp-crm-dialog mp-crm-dialog--danger' : 'mp-crm-dialog',
+    confirmButtonText: input.confirmButtonText ?? (isDanger ? '确认删除' : '确认'),
+    confirmButtonColor: isDanger ? '#dc2626' : '#0a9f6d'
+  }
+}
+
+export function showCrmConfirmDialog(input: BuildCrmConfirmInput) {
+  return showConfirmDialog(buildCrmConfirmOptions(input))
+}

+ 5 - 34
web-admin/src/utils/mp-crm-product-confirm.test.ts

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
 import { describe, it } from 'node:test'
 import { buildProductConfirmOptions } from './mp-crm-product-confirm'
 
-describe('buildProductConfirmOptions', () => {
+describe('buildProductConfirmOptions (alias of CRM confirm)', () => {
   it('builds scheme A for non-delete confirms', () => {
     const opts = buildProductConfirmOptions({
       kind: 'default',
@@ -10,50 +10,21 @@ describe('buildProductConfirmOptions', () => {
       message: '确认下架该产品?'
     })
     assert.equal(opts.title, '下架产品')
-    assert.equal(opts.message, '确认下架该产品?')
-    assert.equal(opts.allowHtml, false)
-    assert.equal(opts.className, 'mp-product-dialog')
-    assert.equal(opts.confirmButtonText, '确认')
+    assert.equal(opts.className, 'mp-crm-dialog')
     assert.equal(opts.confirmButtonColor, '#0a9f6d')
-    assert.equal(opts.theme, undefined)
   })
 
-  it('builds scheme B for delete confirms with warn html', () => {
+  it('builds scheme B for delete confirms', () => {
     const opts = buildProductConfirmOptions({
       kind: 'danger',
       title: '删除产品',
       message: '确认删除该产品?删除后不可恢复。'
     })
     assert.equal(opts.title, '')
-    assert.equal(opts.allowHtml, true)
-    assert.equal(opts.className, 'mp-product-dialog mp-product-dialog--danger')
+    assert.equal(opts.className, 'mp-crm-dialog mp-crm-dialog--danger')
     assert.equal(opts.confirmButtonText, '确认删除')
     assert.equal(opts.confirmButtonColor, '#dc2626')
-    assert.match(String(opts.message), /mp-product-dialog__warn/)
-    assert.match(String(opts.message), /mp-product-dialog__title/)
+    assert.match(String(opts.message), /mp-crm-dialog__warn/)
     assert.match(String(opts.message), /删除产品/)
-    assert.match(String(opts.message), /确认删除该产品?删除后不可恢复。/)
-    assert.doesNotMatch(String(opts.message), /<script/)
-    assert.equal(opts.theme, undefined)
-  })
-
-  it('escapes html in danger title and message body', () => {
-    const opts = buildProductConfirmOptions({
-      kind: 'danger',
-      title: '删 <b>除</b>',
-      message: 'a <b>x</b> & "y"'
-    })
-    assert.match(String(opts.message), /删 &lt;b&gt;除&lt;\/b&gt;/)
-    assert.match(String(opts.message), /a &lt;b&gt;x&lt;\/b&gt; &amp; &quot;y&quot;/)
-  })
-
-  it('allows override confirmButtonText', () => {
-    const opts = buildProductConfirmOptions({
-      kind: 'default',
-      title: '上架产品',
-      message: '确认上架该产品?',
-      confirmButtonText: '立即上架'
-    })
-    assert.equal(opts.confirmButtonText, '立即上架')
   })
 })

+ 10 - 45
web-admin/src/utils/mp-crm-product-confirm.ts

@@ -1,45 +1,10 @@
-import { showConfirmDialog, type DialogOptions } from 'vant'
-
-export type ProductConfirmKind = 'default' | 'danger'
-
-export type BuildProductConfirmInput = {
-  kind: ProductConfirmKind
-  title: string
-  message: string
-  confirmButtonText?: string
-}
-
-function escapeHtml(text: string): string {
-  return text
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;')
-    .replace(/'/g, '&#39;')
-}
-
-/** 方案 B:图标在上、标题居中(对齐 Demo),故标题写入 message HTML,不走 Vant header */
-function dangerMessageHtml(title: string, message: string): string {
-  return (
-    `<div class="mp-product-dialog__warn"><span class="mp-product-dialog__warn-icon">!</span></div>` +
-    `<div class="mp-product-dialog__title">${escapeHtml(title)}</div>` +
-    `<div class="mp-product-dialog__msg">${escapeHtml(message)}</div>`
-  )
-}
-
-export function buildProductConfirmOptions(input: BuildProductConfirmInput): DialogOptions {
-  const isDanger = input.kind === 'danger'
-  return {
-    // 危险态用空 title,避免 Vant header 左对齐且排在图标上方
-    title: isDanger ? '' : input.title,
-    message: isDanger ? dangerMessageHtml(input.title, input.message) : input.message,
-    allowHtml: isDanger,
-    className: isDanger ? 'mp-product-dialog mp-product-dialog--danger' : 'mp-product-dialog',
-    confirmButtonText: input.confirmButtonText ?? (isDanger ? '确认删除' : '确认'),
-    confirmButtonColor: isDanger ? '#dc2626' : '#0a9f6d'
-  }
-}
-
-export function showProductConfirmDialog(input: BuildProductConfirmInput) {
-  return showConfirmDialog(buildProductConfirmOptions(input))
-}
+/**
+ * 产品管理确认弹窗 — 转发至 CRM 共用实现(方案 A/B)。
+ * 保留本文件避免既有 import 断裂。
+ */
+export {
+  buildCrmConfirmOptions as buildProductConfirmOptions,
+  showCrmConfirmDialog as showProductConfirmDialog,
+  type BuildCrmConfirmInput as BuildProductConfirmInput,
+  type CrmConfirmKind as ProductConfirmKind
+} from './mp-crm-confirm'

+ 446 - 0
web-admin/src/views/m/crm/customer/ContactPanel.vue

@@ -0,0 +1,446 @@
+<template>
+  <section class="mp-customer-contact-panel">
+    <div class="mp-customer-actions">
+      <van-button type="primary" size="small" @click="open()">+ 新增联系人</van-button>
+      <van-button size="small" :disabled="loading" @click="load">刷新</van-button>
+    </div>
+
+    <input
+      v-model="keyword"
+      class="mp-wv-search mp-customer-contact-search"
+      type="search"
+      placeholder="姓名 / 手机 / 职位 / 角色"
+      aria-label="搜索联系人"
+      autocomplete="off"
+    />
+
+    <van-dropdown-menu class="mp-customer-dd mp-customer-contact-dd" :z-index="3000">
+      <van-dropdown-item
+        v-model="roleFilter"
+        teleport="body"
+        :options="roleFilterOptions"
+      />
+      <van-dropdown-item
+        v-model="genderFilter"
+        teleport="body"
+        :options="genderFilterOptions"
+      />
+      <van-dropdown-item
+        ref="moreDropdown"
+        teleport="body"
+        :title="moreActive ? '更多·已筛' : '更多'"
+        @open="onMoreOpen"
+      />
+    </van-dropdown-menu>
+
+    <p v-if="error" class="mp-customer-error">{{ error }}</p>
+    <van-loading v-if="loading" vertical>正在加载联系人…</van-loading>
+    <van-empty v-else-if="!filtered.length && !error" description="暂无联系人" />
+
+    <article v-for="row in filtered" :key="row.id" class="mp-wv-card mp-customer-detail-card">
+      <div class="mp-customer-detail-card__head">
+        <div class="mp-customer-detail-card__main">
+          <div class="mp-customer-detail-card__title">{{ row.contactName || '未命名联系人' }}</div>
+          <div class="mp-customer-detail-card__meta">
+            {{ row.mobile || row.telephone || '—' }}
+            <template v-if="row.position"> · {{ row.position }}</template>
+            · {{ roleLabel(row) }}
+          </div>
+        </div>
+        <span class="status-tag" :class="`status-tag--${statusTone(row)}`">
+          {{ statusLabel(row) }}
+        </span>
+      </div>
+
+      <button
+        class="mp-customer-detail-card__toggle"
+        type="button"
+        :aria-expanded="isExpanded(row.id)"
+        @click="toggleExpand(row.id)"
+      >
+        {{ isExpanded(row.id) ? '收起' : '展开' }}
+        <span class="mp-customer-detail-card__toggle-icon" aria-hidden="true">{{
+          isExpanded(row.id) ? '▴' : '▾'
+        }}</span>
+      </button>
+
+      <div v-show="isExpanded(row.id)" class="mp-customer-detail-card__extra">
+        <div class="mp-customer-detail-card__rows">
+          <div
+            v-for="field in contactFields"
+            :key="field.key"
+            class="mp-customer-detail-card__row"
+          >
+            <span>{{ field.label }}</span>
+            <span>{{ contactFieldText(row, field) }}</span>
+          </div>
+        </div>
+
+        <div class="mp-customer-detail-card__actions mp-customer-contact-actions">
+          <button
+            v-if="Number(row.isPrimary) !== 1"
+            class="mp-wv-btn"
+            type="button"
+            :disabled="acting"
+            @click="primary(row)"
+          >
+            设为主要
+          </button>
+          <button class="mp-wv-btn" type="button" :disabled="acting" @click="open(row)">
+            编辑
+          </button>
+          <button
+            class="mp-wv-btn"
+            type="button"
+            :disabled="acting || !canDeleteByCreator(row)"
+            :title="CREATOR_DELETE_TIP"
+            @click="remove(row)"
+          >
+            删除
+          </button>
+        </div>
+      </div>
+    </article>
+
+    <van-popup
+      :show="moreOpen"
+      position="bottom"
+      round
+      teleport="body"
+      class="mp-customer-vant-popup"
+      :style="{ height: '70%' }"
+      @update:show="moreOpen = $event"
+    >
+      <div class="mp-customer-filter-vant">
+        <van-nav-bar title="更多筛选" left-text="关闭" @click-left="moreOpen = false" />
+        <div class="mp-customer-filter-vant__body">
+          <van-cell-group inset>
+            <van-field
+              v-model="moreDraft.position"
+              label="职位"
+              placeholder="输入职位"
+              clearable
+            />
+            <van-field v-model="moreDraft.email" label="邮箱" placeholder="输入邮箱" clearable />
+            <van-field
+              is-link
+              readonly
+              label="状态"
+              :model-value="statusDraftLabel"
+              placeholder="请选择"
+              @click="statusPickerOpen = true"
+            />
+          </van-cell-group>
+        </div>
+        <div class="mp-customer-filter-vant__foot">
+          <button class="mp-wv-btn" type="button" @click="resetMore">重置</button>
+          <button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="applyMore">确定</button>
+        </div>
+      </div>
+    </van-popup>
+
+    <van-popup v-model:show="statusPickerOpen" position="bottom" round teleport="body">
+      <van-picker
+        :columns="statusPickerColumns"
+        :model-value="[moreDraft.status || '']"
+        @confirm="onStatusConfirm"
+        @cancel="statusPickerOpen = false"
+      />
+    </van-popup>
+
+    <van-popup
+      :show="editing"
+      position="bottom"
+      round
+      teleport="body"
+      class="mp-customer-vant-popup"
+      :style="{ height: '82%' }"
+      :close-on-click-overlay="!acting"
+      @update:show="onEditingShow"
+    >
+      <div class="mp-customer-pool-vant">
+        <van-nav-bar
+          :title="form.id ? '编辑联系人' : '新增联系人'"
+          left-text="关闭"
+          :left-disabled="acting"
+          @click-left="closeEditing"
+        />
+        <div class="mp-customer-pool-vant__body">
+          <form id="mp-customer-contact" class="mp-customer-sheet" @submit.prevent="save">
+            <CustomerFields v-model="form" :fields="contactFields" />
+            <p v-if="saveError" class="mp-customer-error" role="alert">{{ saveError }}</p>
+          </form>
+        </div>
+        <div class="mp-customer-pool-vant__foot">
+          <van-button block round type="primary" :loading="acting" @click="save">
+            {{ acting ? '保存中…' : '保存联系人' }}
+          </van-button>
+        </div>
+      </div>
+    </van-popup>
+  </section>
+</template>
+<script setup lang="ts">
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import CustomerFields from './CustomerFields.vue'
+  import { useDictStore } from '@/store/modules/dict'
+  import {
+    fetchContactByCustomerId,
+    fetchContactDetail,
+    fetchCreateContact,
+    fetchUpdateContact,
+    fetchDeleteContact,
+    fetchSetPrimaryContact
+  } from '@/api/crm/customer'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import { contactFields, unwrapRows, unwrapData, type Field, type Row } from './model'
+
+  const props = defineProps<{ customerId: string }>()
+  const emit = defineEmits<{ changed: [] }>()
+  const dict = useDictStore()
+
+  const rows = ref<Row[]>([])
+  const keyword = ref('')
+  const roleFilter = ref('')
+  const genderFilter = ref('')
+  const moreOpen = ref(false)
+  const moreApplied = ref({ position: '', email: '', status: '' })
+  const moreDraft = ref({ position: '', email: '', status: '' })
+  const statusPickerOpen = ref(false)
+  const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
+  const expandedIds = ref<Record<string, boolean>>({})
+
+  const form = ref<Row>({})
+  const editing = ref(false)
+  const loading = ref(false)
+  const acting = ref(false)
+  const error = ref('')
+  const saveError = ref('')
+
+  function isExpanded(id: unknown) {
+    return !!expandedIds.value[String(id)]
+  }
+  function toggleExpand(id: unknown) {
+    const key = String(id)
+    expandedIds.value = { ...expandedIds.value, [key]: !expandedIds.value[key] }
+  }
+
+  const roleFilterOptions = computed(() => [
+    { text: '全部角色', value: '' },
+    ...dict.getOptions('crm_contact_role').map((o) => ({ text: o.label, value: String(o.value) }))
+  ])
+  const genderFilterOptions = [
+    { text: '全部性别', value: '' },
+    { text: '男', value: 'male' },
+    { text: '女', value: 'female' }
+  ]
+  const statusPickerColumns = [
+    { text: '全部状态', value: '' },
+    { text: '在职', value: 'active' },
+    { text: '离职', value: 'leave' }
+  ]
+
+  const moreActive = computed(
+    () =>
+      !!(
+        moreApplied.value.position.trim() ||
+        moreApplied.value.email.trim() ||
+        moreApplied.value.status
+      )
+  )
+  const statusDraftLabel = computed(() => {
+    const hit = statusPickerColumns.find((c) => c.value === moreDraft.value.status)
+    return hit && hit.value ? hit.text : ''
+  })
+
+  const filtered = computed(() => {
+    const kw = keyword.value.trim().toLowerCase()
+    return rows.value.filter((row) => {
+      if (roleFilter.value && String(row.role ?? '') !== roleFilter.value) return false
+      if (genderFilter.value && String(row.gender ?? '') !== genderFilter.value) return false
+      if (
+        moreApplied.value.status &&
+        String(row.status ?? '') !== moreApplied.value.status
+      ) {
+        return false
+      }
+      if (
+        moreApplied.value.position.trim() &&
+        !String(row.position ?? '')
+          .toLowerCase()
+          .includes(moreApplied.value.position.trim().toLowerCase())
+      ) {
+        return false
+      }
+      if (
+        moreApplied.value.email.trim() &&
+        !String(row.email ?? '')
+          .toLowerCase()
+          .includes(moreApplied.value.email.trim().toLowerCase())
+      ) {
+        return false
+      }
+      if (!kw) return true
+      return [row.contactName, row.mobile, row.telephone, row.position, row.role, row.email].some(
+        (v) =>
+          String(v ?? '')
+            .toLowerCase()
+            .includes(kw)
+      )
+    })
+  })
+
+  function genderLabel(row: Row) {
+    if (row.gender === 'male') return '男'
+    if (row.gender === 'female') return '女'
+    return '—'
+  }
+  function roleLabel(row: Row) {
+    return dict.getLabel('crm_contact_role', row.role) || row.role || '未设角色'
+  }
+  function statusLabel(row: Row) {
+    if (row.status === 'leave') return '离职'
+    if (Number(row.isKey) === 1) return '关键决策人'
+    if (Number(row.isPrimary) === 1) return '主要'
+    return row.status === 'active' ? '在职' : '联系人'
+  }
+  function statusTone(row: Row) {
+    if (row.status === 'leave') return 'danger'
+    if (Number(row.isKey) === 1) return 'warning'
+    if (Number(row.isPrimary) === 1) return 'success'
+    return 'info'
+  }
+  function contactFieldText(row: Row, field: Field) {
+    const value = row[field.key]
+    if (field.type === 'switch') return Number(value) === 1 ? '是' : '否'
+    if (field.key === 'gender') return genderLabel(row)
+    if (field.key === 'role') return roleLabel(row)
+    if (field.key === 'status') {
+      if (value === 'leave') return '离职'
+      if (value === 'active') return '在职'
+      return value ? String(value) : '—'
+    }
+    if (field.dict) return dict.getLabel(field.dict, value) || (value ? String(value) : '—')
+    if (field.options) {
+      const hit = field.options.find((o) => String(o.value) === String(value ?? ''))
+      return hit?.label || (value ? String(value) : '—')
+    }
+    return value !== '' && value != null ? String(value) : '—'
+  }
+
+  function onMoreOpen() {
+    moreDraft.value = { ...moreApplied.value }
+    moreOpen.value = true
+    nextTick(() => moreDropdown.value?.toggle?.(false))
+  }
+  function resetMore() {
+    moreDraft.value = { position: '', email: '', status: '' }
+  }
+  function applyMore() {
+    moreApplied.value = { ...moreDraft.value }
+    moreOpen.value = false
+  }
+  function onStatusConfirm({ selectedValues }: { selectedValues: Array<string | number> }) {
+    moreDraft.value.status = String(selectedValues[0] ?? '')
+    statusPickerOpen.value = false
+  }
+
+  function onEditingShow(show: boolean) {
+    if (!acting.value) editing.value = show
+  }
+  function closeEditing() {
+    if (!acting.value) editing.value = false
+  }
+
+  async function load() {
+    loading.value = true
+    error.value = ''
+    try {
+      rows.value = unwrapRows(await fetchContactByCustomerId({ customerId: props.customerId }))
+    } catch (e: any) {
+      error.value = e.message || '联系人加载失败'
+    } finally {
+      loading.value = false
+    }
+  }
+  async function open(row?: Row) {
+    saveError.value = ''
+    try {
+      form.value = {
+        ...Object.fromEntries(contactFields.map((f) => [f.key, ''])),
+        isKey: 0,
+        isPrimary: 0,
+        status: 'active',
+        ...(row ? unwrapData(await fetchContactDetail({ id: String(row.id) })) : {}),
+        customerId: props.customerId
+      }
+      editing.value = true
+    } catch (e: any) {
+      error.value = e.message || '联系人详情加载失败'
+    }
+  }
+  async function save() {
+    if (acting.value) return
+    if (!String(form.value.contactName || '').trim()) {
+      saveError.value = '联系人姓名不能为空'
+      return
+    }
+    acting.value = true
+    saveError.value = ''
+    try {
+      const data = Object.fromEntries(contactFields.map((f) => [f.key, form.value[f.key] ?? '']))
+      Object.assign(data, {
+        customerId: props.customerId,
+        ...(form.value.id ? { id: String(form.value.id) } : {})
+      })
+      await (form.value.id ? fetchUpdateContact : fetchCreateContact)(data)
+      editing.value = false
+      emit('changed')
+      await load()
+    } catch (e: any) {
+      saveError.value = e.message || '保存失败'
+    } finally {
+      acting.value = false
+    }
+  }
+  async function primary(row: Row) {
+    if (acting.value) return
+    acting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'default',
+        title: '主要联系人',
+        message: '确认设为主要联系人吗?'
+      })
+      await fetchSetPrimaryContact({ id: String(row.id) })
+      await load()
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e.message || '设置失败'
+      }
+    } finally {
+      acting.value = false
+    }
+  }
+  async function remove(row: Row) {
+    if (acting.value || !canDeleteByCreator(row)) return
+    acting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: '删除联系人',
+        message: '确认删除该联系人吗?'
+      })
+      await fetchDeleteContact({ ids: [String(row.id)] })
+      emit('changed')
+      await load()
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e.message || '删除失败'
+      }
+    } finally {
+      acting.value = false
+    }
+  }
+  onMounted(load)
+</script>

+ 94 - 0
web-admin/src/views/m/crm/customer/CustomerPoolSheet.vue

@@ -0,0 +1,94 @@
+<template>
+  <van-popup
+    :show="modelValue"
+    position="bottom"
+    round
+    :close-on-click-overlay="!saving"
+    @update:show="onShow"
+  >
+    <div class="mp-customer-pool-vant">
+      <van-nav-bar title="放入公海" left-text="关闭" :left-disabled="saving" @click-left="close" />
+      <div class="mp-customer-pool-vant__body">
+        <p class="mp-customer-pool-vant__tip">
+          将选中的 {{ ids.length }} 位客户放入公海,历史跟进记录保留。放入后将失去对这些客户的编辑权限。
+        </p>
+        <van-field
+          v-model="reason"
+          rows="5"
+          autosize
+          type="textarea"
+          label="放入原因"
+          required
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入放入公海的原因"
+        />
+        <p v-if="error" class="mp-customer-error" role="alert">{{ error }}</p>
+      </div>
+      <div class="mp-customer-pool-vant__foot">
+        <van-button
+          block
+          round
+          type="primary"
+          :loading="saving"
+          :disabled="!reason.trim() || !ids.length"
+          @click="submit"
+        >
+          确认放入
+        </van-button>
+      </div>
+    </div>
+  </van-popup>
+</template>
+
+<script setup lang="ts">
+  import { showSuccessToast } from 'vant'
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import { fetchIntoPool } from '@/api/crm/customer'
+
+  const props = defineProps<{ modelValue: boolean; ids: string[] }>()
+  const emit = defineEmits<{ 'update:modelValue': [boolean]; success: [] }>()
+  const reason = ref('')
+  const error = ref('')
+  const saving = ref(false)
+
+  watch(
+    () => props.modelValue,
+    (open) => {
+      if (open) {
+        reason.value = ''
+        error.value = ''
+      }
+    }
+  )
+
+  function onShow(show: boolean) {
+    if (!saving.value) emit('update:modelValue', show)
+  }
+  function close() {
+    if (!saving.value) emit('update:modelValue', false)
+  }
+
+  async function submit() {
+    if (saving.value || !props.ids.length || !reason.value.trim()) return
+    saving.value = true
+    error.value = ''
+    try {
+      await showCrmConfirmDialog({
+        kind: 'default',
+        title: '放入公海',
+        message: `确定将 ${props.ids.length} 位客户放入公海吗?`
+      })
+      await fetchIntoPool({ ids: props.ids, reason: reason.value.trim() })
+      emit('update:modelValue', false)
+      emit('success')
+      showSuccessToast('已放入公海')
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e?.message || '操作失败,请重试'
+      }
+    } finally {
+      saving.value = false
+    }
+  }
+</script>

+ 280 - 0
web-admin/src/views/m/crm/customer/FollowPanel.vue

@@ -0,0 +1,280 @@
+<template>
+  <section class="mp-customer-follow-panel">
+    <div class="mp-customer-actions">
+      <van-button type="primary" size="small" @click="open">+ 添加跟进</van-button>
+      <van-button size="small" :disabled="loading" @click="load">刷新</van-button>
+    </div>
+    <p v-if="error" class="mp-customer-error">{{ error }}</p>
+    <van-loading v-if="loading" vertical>正在加载跟进记录…</van-loading>
+    <van-empty v-else-if="!rows.length && !error" description="暂无跟进记录" />
+
+    <article v-for="row in rows" :key="row.id" class="mp-wv-card mp-customer-detail-card">
+      <div class="mp-customer-detail-card__head">
+        <div class="mp-customer-detail-card__main">
+          <div class="mp-customer-detail-card__title">{{ followContentPreview(row) }}</div>
+          <div class="mp-customer-detail-card__meta">
+            {{ row.createTime || '—' }} · {{ row.createByName || row.ownerUserName || '—' }} ·
+            {{ row.contactName || '未关联联系人' }}
+          </div>
+        </div>
+        <span class="status-tag" :class="`status-tag--${followTone(row)}`">
+          {{ dict.getLabel('crm_follow_type', row.followType) || '跟进' }}
+        </span>
+      </div>
+
+      <button
+        class="mp-customer-detail-card__toggle"
+        type="button"
+        :aria-expanded="isExpanded(row.id)"
+        @click="toggleExpand(row.id)"
+      >
+        {{ isExpanded(row.id) ? '收起' : '展开' }}
+        <span class="mp-customer-detail-card__toggle-icon" aria-hidden="true">{{
+          isExpanded(row.id) ? '▴' : '▾'
+        }}</span>
+      </button>
+
+      <div v-show="isExpanded(row.id)" class="mp-customer-detail-card__extra">
+        <p class="mp-customer-detail-card__body">{{ row.followContent || '—' }}</p>
+        <div class="mp-customer-detail-card__rows">
+          <div class="mp-customer-detail-card__row">
+            <span>跟进方式</span>
+            <span>{{ dict.getLabel('crm_follow_type', row.followType) || '—' }}</span>
+          </div>
+          <div class="mp-customer-detail-card__row">
+            <span>跟进人</span>
+            <span>{{ row.createByName || row.ownerUserName || '—' }}</span>
+          </div>
+          <div class="mp-customer-detail-card__row">
+            <span>联系人</span>
+            <span>{{ row.contactName || '未关联' }}</span>
+          </div>
+          <div class="mp-customer-detail-card__row">
+            <span>跟进时间</span>
+            <span>{{ row.createTime || '—' }}</span>
+          </div>
+          <div class="mp-customer-detail-card__row">
+            <span>下次联系</span>
+            <span>{{ row.nextTime ? formatNextTime(row.nextTime) : '—' }}</span>
+          </div>
+          <div v-if="row.remark" class="mp-customer-detail-card__row">
+            <span>备注</span>
+            <span>{{ row.remark }}</span>
+          </div>
+        </div>
+
+        <CustomerAttachments
+          v-if="row.attachment || row.attachments"
+          class="mp-customer-follow-att"
+          :model-value="row.attachment || row.attachments"
+          readonly
+        />
+
+        <div class="mp-customer-detail-card__actions">
+          <button
+            class="mp-wv-btn mp-customer-follow-del"
+            type="button"
+            :disabled="acting || !canDeleteByCreator(row)"
+            :title="CREATOR_DELETE_TIP"
+            @click="remove(row)"
+          >
+            删除记录
+          </button>
+        </div>
+      </div>
+    </article>
+
+    <van-popup
+      :show="editing"
+      position="bottom"
+      round
+      teleport="body"
+      class="mp-customer-vant-popup"
+      :style="{ height: '82%' }"
+      :close-on-click-overlay="!acting"
+      @update:show="onEditingShow"
+    >
+      <div class="mp-customer-pool-vant">
+        <van-nav-bar
+          title="添加跟进记录"
+          left-text="关闭"
+          :left-disabled="acting"
+          @click-left="closeEditing"
+        />
+        <div class="mp-customer-pool-vant__body">
+          <form id="mp-customer-follow" class="mp-customer-sheet" @submit.prevent="save">
+            <CustomerFields v-model="form" :fields="fields" />
+            <section class="mp-wv-section mp-customer-follow-upload">
+              <h3>附件</h3>
+              <CustomerAttachments
+                ref="upload"
+                v-model="form.attachment"
+                folder="crm/customer/follow"
+                :limit="15"
+                hint="支持图片、视频、PDF、Word、Excel 等文件,最多 15 个"
+              />
+            </section>
+            <p v-if="saveError" class="mp-customer-error" role="alert">{{ saveError }}</p>
+          </form>
+        </div>
+        <div class="mp-customer-pool-vant__foot">
+          <van-button block round type="primary" :loading="acting" @click="save">
+            {{ acting ? '保存中…' : '保存跟进' }}
+          </van-button>
+        </div>
+      </div>
+    </van-popup>
+  </section>
+</template>
+<script setup lang="ts">
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import CustomerFields from './CustomerFields.vue'
+  import CustomerAttachments from './CustomerAttachments.vue'
+  import {
+    fetchFollowByCustomerId,
+    fetchContactByCustomerId,
+    fetchCreateCustomerFollow,
+    fetchDeleteCustomerFollow
+  } from '@/api/crm/customer'
+  import { useDictStore } from '@/store/modules/dict'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import { apiTime, unwrapRows, type Field, type Row } from './model'
+
+  const props = defineProps<{ customerId: string }>()
+  const emit = defineEmits<{ changed: [] }>()
+  const dict = useDictStore()
+  const rows = ref<Row[]>([])
+  const contacts = ref<Row[]>([])
+  const loading = ref(false)
+  const acting = ref(false)
+  const editing = ref(false)
+  const error = ref('')
+  const saveError = ref('')
+  const form = ref<Row>({})
+  const upload = ref<InstanceType<typeof CustomerAttachments>>()
+  const expandedIds = ref<Record<string, boolean>>({})
+
+  function isExpanded(id: unknown) {
+    return !!expandedIds.value[String(id)]
+  }
+  function toggleExpand(id: unknown) {
+    const key = String(id)
+    expandedIds.value = { ...expandedIds.value, [key]: !expandedIds.value[key] }
+  }
+
+  function followTone(row: Row) {
+    const t = dict.getTagType('crm_follow_type', row.followType)
+    if (t === 'warning') return 'warning'
+    if (t === 'danger') return 'danger'
+    if (t === 'success' || t === 'primary') return 'success'
+    return 'info'
+  }
+  function followContentPreview(row: Row) {
+    const text = String(row.followContent || '').trim()
+    if (!text) return '(无跟进内容)'
+    return text.length > 36 ? `${text.slice(0, 36)}…` : text
+  }
+  function formatNextTime(value: unknown) {
+    return String(value || '')
+      .replace('T', ' ')
+      .slice(0, 16)
+  }
+  function onEditingShow(show: boolean) {
+    if (!acting.value) editing.value = show
+  }
+  function closeEditing() {
+    if (!acting.value) editing.value = false
+  }
+
+  const fields = computed<Field[]>(() => [
+    { key: 'followType', label: '跟进方式', dict: 'crm_follow_type', required: true },
+    {
+      key: 'contactId',
+      label: '关联联系人',
+      options: contacts.value.map((c) => ({
+        value: String(c.id),
+        label: `${c.contactName}${c.position ? ' · ' + c.position : ''}`
+      }))
+    },
+    { key: 'followContent', label: '跟进内容', type: 'textarea', required: true, max: 1000 },
+    { key: 'nextTime', label: '下次联系时间', type: 'datetime-local' },
+    { key: 'remark', label: '备注', type: 'textarea', max: 500 }
+  ])
+  async function load() {
+    loading.value = true
+    error.value = ''
+    try {
+      rows.value = unwrapRows(await fetchFollowByCustomerId({ customerId: props.customerId }))
+    } catch (e: any) {
+      error.value = e.message || '跟进记录加载失败'
+    } finally {
+      loading.value = false
+    }
+  }
+  async function open() {
+    form.value = {
+      followType: 'phone',
+      contactId: '',
+      followContent: '',
+      nextTime: '',
+      attachment: '',
+      remark: ''
+    }
+    saveError.value = ''
+    editing.value = true
+    try {
+      contacts.value = unwrapRows(await fetchContactByCustomerId({ customerId: props.customerId }))
+    } catch (e: any) {
+      saveError.value = e.message || '联系人加载失败,请重新打开'
+    }
+  }
+  async function save() {
+    if (acting.value) return
+    if (upload.value?.hasUploading()) {
+      saveError.value = '附件上传中,请稍候'
+      return
+    }
+    if (!String(form.value.followContent || '').trim()) {
+      saveError.value = '跟进内容不能为空'
+      return
+    }
+    acting.value = true
+    saveError.value = ''
+    try {
+      await fetchCreateCustomerFollow({
+        ...form.value,
+        customerId: props.customerId,
+        contactId: form.value.contactId || undefined,
+        nextTime: apiTime(form.value.nextTime)
+      })
+      editing.value = false
+      emit('changed')
+      await load()
+    } catch (e: any) {
+      saveError.value = e.message || '保存失败,请重试'
+    } finally {
+      acting.value = false
+    }
+  }
+  async function remove(row: Row) {
+    if (acting.value || !canDeleteByCreator(row)) return
+    acting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: '删除跟进',
+        message: '确认删除这条跟进记录吗?'
+      })
+      await fetchDeleteCustomerFollow({ ids: [String(row.id)] })
+      emit('changed')
+      await load()
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e.message || '删除失败'
+      }
+    } finally {
+      acting.value = false
+    }
+  }
+  onMounted(load)
+</script>

+ 373 - 0
web-admin/src/views/m/crm/customer/InvoicePanel.vue

@@ -0,0 +1,373 @@
+<template>
+  <section class="mp-customer-invoice-panel">
+    <div class="mp-customer-actions">
+      <van-button type="primary" size="small" @click="open()">+ 新增开票信息</van-button>
+      <van-button size="small" :disabled="loading" @click="load">刷新</van-button>
+    </div>
+
+    <input
+      v-model="keyword"
+      class="mp-wv-search mp-customer-contact-search"
+      type="search"
+      placeholder="单位名称 / 税号 / 银行"
+      aria-label="搜索开票信息"
+      autocomplete="off"
+    />
+
+    <van-dropdown-menu class="mp-customer-dd mp-customer-contact-dd" :z-index="3000">
+      <van-dropdown-item v-model="defaultFilter" teleport="body" :options="defaultFilterOptions" />
+      <van-dropdown-item v-model="taxFilter" teleport="body" :options="taxFilterOptions" />
+      <van-dropdown-item
+        ref="moreDropdown"
+        teleport="body"
+        :title="moreActive ? '更多·已筛' : '更多'"
+        @open="onMoreOpen"
+      />
+    </van-dropdown-menu>
+
+    <p v-if="error" class="mp-customer-error">{{ error }}</p>
+    <van-loading v-if="loading" vertical>正在加载开票资料…</van-loading>
+    <van-empty v-else-if="!filtered.length && !error" description="暂无开票信息" />
+
+    <article v-for="row in filtered" :key="row.id" class="mp-wv-card mp-customer-detail-card">
+      <div class="mp-customer-detail-card__head">
+        <div class="mp-customer-detail-card__main">
+          <div class="mp-customer-detail-card__title">{{ row.companyName || '未命名单位' }}</div>
+          <div class="mp-customer-detail-card__meta">
+            {{ row.taxNo || '—' }} · {{ row.bankName || '—' }}
+          </div>
+        </div>
+        <span
+          class="status-tag"
+          :class="String(row.isDefault) === '1' ? 'status-tag--success' : 'status-tag--info'"
+        >
+          {{ String(row.isDefault) === '1' ? '默认' : '非默认' }}
+        </span>
+      </div>
+
+      <button
+        class="mp-customer-detail-card__toggle"
+        type="button"
+        :aria-expanded="isExpanded(row.id)"
+        @click="toggleExpand(row.id)"
+      >
+        {{ isExpanded(row.id) ? '收起' : '展开' }}
+        <span class="mp-customer-detail-card__toggle-icon" aria-hidden="true">{{
+          isExpanded(row.id) ? '▴' : '▾'
+        }}</span>
+      </button>
+
+      <div v-show="isExpanded(row.id)" class="mp-customer-detail-card__extra">
+        <div class="mp-customer-detail-card__rows">
+          <div v-for="field in invoiceFields" :key="field.key" class="mp-customer-detail-card__row">
+            <span>{{ field.label }}</span>
+            <span>{{ fieldText(row, field) }}</span>
+          </div>
+        </div>
+        <div class="mp-customer-detail-card__actions">
+          <button
+            v-if="String(row.isDefault) !== '1'"
+            class="mp-wv-btn"
+            type="button"
+            :disabled="acting"
+            @click="setDefault(row)"
+          >
+            设为默认
+          </button>
+          <button class="mp-wv-btn" type="button" :disabled="acting" @click="open(row)">编辑</button>
+          <button
+            class="mp-wv-btn"
+            type="button"
+            :disabled="acting || !canDeleteByCreator(row)"
+            :title="CREATOR_DELETE_TIP"
+            @click="remove(row)"
+          >
+            删除
+          </button>
+        </div>
+      </div>
+    </article>
+
+    <van-popup
+      :show="moreOpen"
+      position="bottom"
+      round
+      teleport="body"
+      class="mp-customer-vant-popup"
+      :style="{ height: '70%' }"
+      @update:show="moreOpen = $event"
+    >
+      <div class="mp-customer-filter-vant">
+        <van-nav-bar title="更多筛选" left-text="关闭" @click-left="moreOpen = false" />
+        <div class="mp-customer-filter-vant__body">
+          <van-cell-group inset>
+            <van-field
+              v-for="key in moreTextKeys"
+              :key="key"
+              v-model="moreDraft[key]"
+              :label="moreLabel(key)"
+              :placeholder="`输入${moreLabel(key)}`"
+              clearable
+            />
+          </van-cell-group>
+        </div>
+        <div class="mp-customer-filter-vant__foot">
+          <button class="mp-wv-btn" type="button" @click="resetMore">重置</button>
+          <button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="applyMore">确定</button>
+        </div>
+      </div>
+    </van-popup>
+
+    <van-popup
+      :show="editing"
+      position="bottom"
+      round
+      teleport="body"
+      class="mp-customer-vant-popup"
+      :style="{ height: '82%' }"
+      :close-on-click-overlay="!acting"
+      @update:show="onEditingShow"
+    >
+      <div class="mp-customer-pool-vant">
+        <van-nav-bar
+          :title="form.id ? '编辑开票信息' : '新增开票信息'"
+          left-text="关闭"
+          :left-disabled="acting"
+          @click-left="closeEditing"
+        />
+        <div class="mp-customer-pool-vant__body">
+          <form id="mp-customer-invoice" class="mp-customer-sheet" @submit.prevent="save">
+            <InvoiceFields :key="editKey" v-model="form" />
+            <van-field
+              is-link
+              readonly
+              label="是否默认"
+              :model-value="form.isDefault === '1' ? '是' : '否'"
+              placeholder="请选择"
+              @click="defaultOpen = true"
+            />
+            <p v-if="saveError" class="mp-customer-error" role="alert">{{ saveError }}</p>
+          </form>
+        </div>
+        <div class="mp-customer-pool-vant__foot">
+          <van-button block round type="primary" :loading="acting" @click="save">
+            {{ acting ? '保存中…' : '保存开票信息' }}
+          </van-button>
+        </div>
+      </div>
+    </van-popup>
+
+    <van-popup v-model:show="defaultOpen" position="bottom" round teleport="body">
+      <van-picker
+        :columns="defaultColumns"
+        :model-value="[form.isDefault || '0']"
+        @confirm="onDefaultConfirm"
+        @cancel="defaultOpen = false"
+      />
+    </van-popup>
+  </section>
+</template>
+<script setup lang="ts">
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import InvoiceFields from './InvoiceFields.vue'
+  import {
+    getInvoiceInfoByCustomerId,
+    addInvoiceInfo,
+    updateInvoiceInfo,
+    delInvoiceInfo
+  } from '@/api/crm/invoiceInfo'
+  import request from '@/utils/http'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import { invoiceFields, unwrapRows, type Field, type Row } from './model'
+
+  const props = defineProps<{ customerId: string }>()
+
+  type MoreDraft = Record<string, string>
+  const moreTextKeys = [
+    'companyName',
+    'taxNo',
+    'companyAddress',
+    'legalRepresentative',
+    'agent',
+    'phoneFax',
+    'bankName',
+    'bankAccount'
+  ] as const
+
+  const emptyMore = (): MoreDraft =>
+    Object.fromEntries(moreTextKeys.map((k) => [k, ''])) as MoreDraft
+
+  const rows = ref<Row[]>([])
+  const keyword = ref('')
+  const defaultFilter = ref('')
+  const taxFilter = ref('')
+  const moreOpen = ref(false)
+  const moreApplied = ref<MoreDraft>(emptyMore())
+  const moreDraft = ref<MoreDraft>(emptyMore())
+  const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
+  const expandedIds = ref<Record<string, boolean>>({})
+  const form = ref<Row>({})
+  const editing = ref(false)
+  const editKey = ref(0)
+  const loading = ref(false)
+  const acting = ref(false)
+  const error = ref('')
+  const saveError = ref('')
+  const defaultOpen = ref(false)
+  const defaultColumns = [
+    { text: '否', value: '0' },
+    { text: '是', value: '1' }
+  ]
+  const defaultFilterOptions = [
+    { text: '全部默认', value: '' },
+    { text: '默认', value: '1' },
+    { text: '非默认', value: '0' }
+  ]
+  const taxFilterOptions = [
+    { text: '全部税号', value: '' },
+    { text: '有税号', value: '1' },
+    { text: '无税号', value: '0' }
+  ]
+
+  function isExpanded(id: unknown) {
+    return !!expandedIds.value[String(id)]
+  }
+  function toggleExpand(id: unknown) {
+    const key = String(id)
+    expandedIds.value = { ...expandedIds.value, [key]: !expandedIds.value[key] }
+  }
+  function moreLabel(key: string) {
+    return invoiceFields.find((f) => f.key === key)?.label || key
+  }
+  function fieldText(row: Row, field: Field) {
+    const value = row[field.key]
+    return value !== '' && value != null ? String(value) : '—'
+  }
+
+  const moreActive = computed(() => moreTextKeys.some((k) => !!moreApplied.value[k]?.trim()))
+
+  const filtered = computed(() => {
+    const kw = keyword.value.trim().toLowerCase()
+    return rows.value.filter((row) => {
+      if (defaultFilter.value && String(row.isDefault ?? '0') !== defaultFilter.value) return false
+      const hasTax = !!String(row.taxNo ?? '').trim()
+      if (taxFilter.value === '1' && !hasTax) return false
+      if (taxFilter.value === '0' && hasTax) return false
+      for (const key of moreTextKeys) {
+        const tip = moreApplied.value[key]?.trim().toLowerCase()
+        if (tip && !String(row[key] ?? '').toLowerCase().includes(tip)) return false
+      }
+      if (!kw) return true
+      return [row.companyName, row.taxNo, row.bankName, row.bankAccount].some((v) =>
+        String(v ?? '')
+          .toLowerCase()
+          .includes(kw)
+      )
+    })
+  })
+
+  function onMoreOpen() {
+    moreDraft.value = { ...moreApplied.value }
+    moreOpen.value = true
+    nextTick(() => moreDropdown.value?.toggle?.(false))
+  }
+  function resetMore() {
+    moreDraft.value = emptyMore()
+  }
+  function applyMore() {
+    moreApplied.value = { ...moreDraft.value }
+    moreOpen.value = false
+  }
+
+  function onEditingShow(show: boolean) {
+    if (!acting.value) editing.value = show
+  }
+  function closeEditing() {
+    if (!acting.value) editing.value = false
+  }
+  function onDefaultConfirm({ selectedValues }: { selectedValues: string[] }) {
+    form.value.isDefault = selectedValues[0] || '0'
+    defaultOpen.value = false
+  }
+
+  async function load() {
+    loading.value = true
+    error.value = ''
+    try {
+      rows.value = unwrapRows(await getInvoiceInfoByCustomerId(props.customerId))
+    } catch (e: any) {
+      error.value = e.message || '开票资料加载失败'
+    } finally {
+      loading.value = false
+    }
+  }
+  function open(row?: Row) {
+    form.value = {
+      ...Object.fromEntries(invoiceFields.map((f) => [f.key, ''])),
+      isDefault: '0',
+      ...row
+    }
+    editKey.value++
+    saveError.value = ''
+    editing.value = true
+  }
+  async function save() {
+    if (acting.value) return
+    if (!String(form.value.companyName || '').trim()) {
+      saveError.value = '单位名称不能为空'
+      return
+    }
+    acting.value = true
+    saveError.value = ''
+    try {
+      const data = {
+        ...Object.fromEntries(invoiceFields.map((f) => [f.key, form.value[f.key] ?? ''])),
+        customerId: props.customerId,
+        isDefault: String(form.value.isDefault),
+        ...(form.value.id ? { id: String(form.value.id) } : {})
+      }
+      await (form.value.id ? updateInvoiceInfo : addInvoiceInfo)(data)
+      editing.value = false
+      await load()
+    } catch (e: any) {
+      saveError.value = e.message || '保存失败'
+    } finally {
+      acting.value = false
+    }
+  }
+  async function setDefault(row: Row) {
+    if (acting.value) return
+    acting.value = true
+    try {
+      await request.post({
+        url: '/web/crm/invoice-info/set-default',
+        data: { id: String(row.id), customerId: props.customerId }
+      })
+      await load()
+    } catch (e: any) {
+      error.value = e.message || '设置失败'
+    } finally {
+      acting.value = false
+    }
+  }
+  async function remove(row: Row) {
+    if (acting.value || !canDeleteByCreator(row)) return
+    acting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: '删除开票信息',
+        message: '确认删除该开票信息吗?'
+      })
+      await delInvoiceInfo({ id: String(row.id) })
+      await load()
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e.message || '删除失败'
+      }
+    } finally {
+      acting.value = false
+    }
+  }
+  onMounted(load)
+</script>

+ 326 - 0
web-admin/src/views/m/crm/customer/OpportunityActions.vue

@@ -0,0 +1,326 @@
+<template>
+  <div class="mp-customer-opportunity-actions">
+    <div class="mp-customer-actions">
+      <van-button size="small" :disabled="busy || closed" @click="open('edit')">修改商机</van-button>
+      <van-button
+        size="small"
+        :disabled="busy || opportunity.status !== 'following'"
+        @click="open('stage')"
+      >
+        推进阶段
+      </van-button>
+      <van-button
+        size="small"
+        :disabled="busy || opportunity.status !== 'following'"
+        @click="open('follow')"
+      >
+        添加商机跟进
+      </van-button>
+      <van-button
+        size="small"
+        :disabled="
+          busy || ['won', 'lost'].includes(opportunity.status) || !canDeleteByCreator(opportunity)
+        "
+        @click="remove()"
+      >
+        删除商机
+      </van-button>
+    </div>
+    <p v-if="error" class="mp-customer-error" role="alert">{{ error }}</p>
+    <van-cell-group v-if="mode" inset class="mp-customer-panel-card">
+      <van-cell
+        :title="mode === 'edit' ? '修改商机' : mode === 'stage' ? '推进阶段' : '商机跟进'"
+      />
+      <form @submit.prevent="save">
+        <fieldset :disabled="busy" style="min-width: 0; padding: 0; border: 0">
+          <template v-if="mode === 'edit'">
+            <van-field v-model="customerKeyword" label="搜索关联客户" clearable />
+            <van-button size="small" native-type="button" @click="searchCustomers">搜索客户</van-button>
+          </template>
+          <CustomerFields
+            :model-value="form"
+            :fields="fields"
+            :users="users"
+            @update:model-value="updateForm"
+          />
+          <template v-if="mode === 'follow'">
+            <van-cell title="附件" />
+            <CustomerAttachments
+              ref="upload"
+              v-model="form.attachments"
+              folder="crm/opportunity/follow"
+              :limit="15"
+            />
+          </template>
+          <div class="mp-customer-actions">
+            <van-button size="small" native-type="button" @click="mode = ''">取消操作</van-button>
+            <van-button size="small" type="primary" native-type="submit">保存商机操作</van-button>
+          </div>
+        </fieldset>
+      </form>
+    </van-cell-group>
+  </div>
+</template>
+<script setup lang="ts">
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import CustomerFields from './CustomerFields.vue'
+  import CustomerAttachments from './CustomerAttachments.vue'
+  import {
+    updateOpportunity,
+    changeStage,
+    delOpportunity,
+    addOpportunityFollow,
+    updateOpportunityFollow,
+    delOpportunityFollow
+  } from '@/api/crm/opportunity'
+  import { fetchCustomerOptions } from '@/api/crm/customer'
+  import { fetchUserOptions } from '@/api/admin'
+  import { canDeleteByCreator } from '@/utils/creator-delete'
+  import { useDictStore } from '@/store/modules/dict'
+  import {
+    OPPORTUNITY_SOURCE_FALLBACK,
+    OPPORTUNITY_TYPE_FALLBACK
+  } from '@/utils/crm-opportunity-labels'
+  import { apiTime, stageSuggestion, unwrapRows, type Row, type Field } from './model'
+
+  const props = defineProps<{ opportunity: Row }>()
+  const emit = defineEmits<{ changed: []; deleted: [] }>()
+  const mode = ref('')
+  const form = ref<Row>({})
+  const busy = ref(false)
+  const error = ref('')
+  const users = ref<{ value: string; label: string }[]>([])
+  const customers = ref<{ value: string; label: string }[]>([])
+  const customerKeyword = ref('')
+  const upload = ref<InstanceType<typeof CustomerAttachments>>()
+  const closed = computed(() => ['won', 'lost'].includes(props.opportunity.status))
+  const dict = useDictStore()
+
+  function dictOptions(code: string, fallback: Record<string, string>) {
+    const options = dict.getOptions(code)
+    return options.length
+      ? options
+      : Object.entries(fallback).map(([value, label]) => ({ value, label }))
+  }
+  const progress: Field[] = [
+    { key: 'stage', label: '商机阶段', dict: 'crm_opportunity_stage', required: true },
+    {
+      key: 'stagePercent',
+      label: '阶段进度(%)',
+      type: 'number',
+      minValue: 0,
+      maxValue: 100,
+      required: true
+    },
+    {
+      key: 'winRate',
+      label: '预估赢单率(%)',
+      type: 'number',
+      minValue: 0,
+      maxValue: 100,
+      required: true
+    }
+  ]
+  const fields = computed<Field[]>(() =>
+    mode.value === 'follow'
+      ? [
+          {
+            key: 'followType',
+            label: '商机跟进方式',
+            dict: 'crm_opportunity_follow_type',
+            required: true
+          },
+          { key: 'followTime', label: '商机跟进时间', type: 'datetime-local', required: true },
+          {
+            key: 'followContent',
+            label: '商机跟进内容',
+            type: 'textarea',
+            required: true,
+            max: 2000
+          },
+          { key: 'nextTime', label: '下次商机跟进时间', type: 'datetime-local' },
+          { key: 'nextPlan', label: '下次跟进计划', type: 'textarea', max: 1000 }
+        ]
+      : mode.value === 'stage'
+        ? [
+            ...progress,
+            { key: 'changeReason', label: '变更原因', type: 'textarea', required: true, max: 500 },
+            { key: 'remark', label: '备注', type: 'textarea', max: 500 }
+          ]
+        : [
+            { key: 'opportunityName', label: '商机名称', required: true, max: 200 },
+            { key: 'customerId', label: '关联客户', options: customers.value, required: true },
+            ...progress,
+            { key: 'expectDealDate', label: '预计成交日期', type: 'date' },
+            { key: 'ownerUserId', label: '负责人', type: 'select', required: true },
+            {
+              key: 'type',
+              label: '商机类型',
+              options: dictOptions('crm_opportunity_type', OPPORTUNITY_TYPE_FALLBACK)
+            },
+            {
+              key: 'source',
+              label: '商机来源',
+              options: dictOptions('crm_clue_source', OPPORTUNITY_SOURCE_FALLBACK)
+            },
+            { key: 'description', label: '商机描述', type: 'textarea', max: 500 },
+            { key: 'competitors', label: '竞争对手', type: 'textarea', max: 500 },
+            { key: 'keyFactors', label: '关键决策因素', type: 'textarea', max: 500 },
+            { key: 'remark', label: '备注', type: 'textarea', max: 500 }
+          ]
+  )
+  async function open(action: string, row?: Row) {
+    error.value = ''
+    mode.value = action
+    if (action === 'follow') {
+      const now = new Date()
+      const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
+        .toISOString()
+        .slice(0, 16)
+      form.value = {
+        followType: 'phone',
+        followTime: local,
+        followContent: '',
+        nextTime: '',
+        attachments: '',
+        ...row,
+        nextPlan: row?.nextPlan || row?.nextContent || ''
+      }
+    } else {
+      form.value = {
+        ...props.opportunity,
+        changeReason: '',
+        ...(action === 'stage' ? { remark: '' } : {})
+      }
+      if (action === 'edit') {
+        customers.value = [
+          {
+            value: String(props.opportunity.customerId),
+            label: props.opportunity.customerName || '当前客户'
+          }
+        ]
+        busy.value = true
+        try {
+          users.value = unwrapRows(await fetchUserOptions()).map((u) => ({
+            value: String(u.value),
+            label: u.label
+          }))
+        } catch (e: any) {
+          error.value = e.message || '负责人加载失败'
+        } finally {
+          busy.value = false
+        }
+      }
+    }
+  }
+  function updateForm(next: Row) {
+    if (next.stage !== form.value.stage && mode.value !== 'follow')
+      Object.assign(next, stageSuggestion(next.stage))
+    form.value = next
+  }
+  async function searchCustomers() {
+    busy.value = true
+    try {
+      customers.value = unwrapRows(
+        await fetchCustomerOptions({ keyword: customerKeyword.value, limit: 100 })
+      ).map((c) => ({ value: String(c.id), label: `${c.customerNo || ''} ${c.customerName}` }))
+    } catch (e: any) {
+      error.value = e.message || '客户搜索失败'
+    } finally {
+      busy.value = false
+    }
+  }
+  async function save() {
+    if (busy.value) return
+    error.value = ''
+    if (upload.value?.hasUploading()) {
+      error.value = '附件上传中,请稍候'
+      return
+    }
+    if (fields.value.some((f) => f.required && !String(form.value[f.key] ?? '').trim())) {
+      error.value = '请完整填写必填项'
+      return
+    }
+    if (mode.value === 'edit' && form.value.opportunityName.trim().length < 2) {
+      error.value = '商机名称至少两个字符'
+      return
+    }
+    if (mode.value === 'stage' && form.value.stage === props.opportunity.stage) {
+      error.value = '新阶段与当前阶段相同,无需变更'
+      return
+    }
+    busy.value = true
+    try {
+      if (mode.value === 'follow') {
+        const payload = {
+          ...form.value,
+          opportunityId: String(props.opportunity.id),
+          followType: form.value.followType,
+          followContent: form.value.followContent,
+          followTime: apiTime(form.value.followTime),
+          nextTime: apiTime(form.value.nextTime),
+          nextContent: form.value.nextPlan
+        }
+        await (form.value.id ? updateOpportunityFollow(payload) : addOpportunityFollow(payload))
+      } else if (mode.value === 'stage') {
+        await changeStage({
+          id: String(props.opportunity.id),
+          stage: form.value.stage,
+          stagePercent: Number(form.value.stagePercent),
+          winRate: Number(form.value.winRate),
+          changeReason: form.value.changeReason,
+          remark: form.value.remark
+        })
+      } else {
+        const payload = Object.fromEntries(
+          fields.value.map((f) => [f.key, form.value[f.key] ?? ''])
+        )
+        // 保留接口可写但 Web 表单不展示的关联联系人、金额,不带入只读字段。
+        await updateOpportunity({
+          ...payload,
+          id: String(props.opportunity.id),
+          opportunityName: payload.opportunityName,
+          customerId: payload.customerId,
+          contactId: props.opportunity.contactId,
+          amount: props.opportunity.amount,
+          stagePercent: Number(payload.stagePercent),
+          winRate: Number(payload.winRate)
+        })
+      }
+      mode.value = ''
+      emit('changed')
+    } catch (e: any) {
+      error.value = e.message || '保存失败'
+    } finally {
+      busy.value = false
+    }
+  }
+  async function remove(row?: Row) {
+    if (busy.value || !canDeleteByCreator(row || props.opportunity)) return
+    busy.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: row ? '删除跟进' : '删除商机',
+        message: row ? '确认删除此商机跟进记录?' : '确认删除此商机?'
+      })
+      if (row) {
+        await delOpportunityFollow(String(row.id))
+        emit('changed')
+      } else {
+        await delOpportunity(String(props.opportunity.id))
+        emit('deleted')
+      }
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.toString?.() !== 'cancel') {
+        error.value = e.message || '删除失败'
+      }
+    } finally {
+      busy.value = false
+    }
+  }
+  defineExpose({
+    editFollow: (row: Row) => open('follow', row),
+    deleteFollow: (row: Row) => remove(row)
+  })
+</script>

+ 991 - 0
web-admin/src/views/m/crm/customer/customer.scss

@@ -0,0 +1,991 @@
+/*
+ * Vant 下拉/弹层会 teleport 到 body,页面内 CSS 变量传不进去。
+ * 必须在 :root 覆盖主色,否则选项高亮仍是默认蓝 #1989fa。
+ */
+:root {
+  --van-primary-color: #0a9f6d;
+  --van-success-color: #0a9f6d;
+  --van-danger-color: #dc2626;
+  --van-warning-color: #d97706;
+  --van-button-primary-background: #0a9f6d;
+  --van-button-primary-border-color: #0a9f6d;
+  --van-dropdown-menu-title-active-text-color: #0a9f6d;
+  --van-dropdown-menu-option-active-color: #0a9f6d;
+  --van-tabs-bottom-bar-color: #0a9f6d;
+  --van-tab-active-text-color: #0a9f6d;
+  --van-nav-bar-icon-color: #0a9f6d;
+  --van-nav-bar-text-color: #0a9f6d;
+  --van-checkbox-checked-icon-color: #0a9f6d;
+  --van-switch-on-background: #0a9f6d;
+  --van-radio-checked-icon-color: #0a9f6d;
+  --van-picker-confirm-action-color: #0a9f6d;
+  --van-dialog-confirm-button-text-color: #0a9f6d;
+  --van-loading-spinner-color: #0a9f6d;
+  --van-tag-primary-color: #0a9f6d;
+}
+
+.mp-customer-page,
+.mp-customer-sheet,
+.mp-customer-filter-vant,
+.mp-customer-pool-vant,
+.mp-customer-vant-popup {
+  /* 页面内组件再写一遍,保证未 teleport 的控件也一定吃到三墙绿 */
+  --van-primary-color: #0a9f6d;
+  --van-success-color: #0a9f6d;
+  --van-danger-color: #dc2626;
+  --van-warning-color: #d97706;
+  --van-button-primary-background: #0a9f6d;
+  --van-button-primary-border-color: #0a9f6d;
+  --van-nav-bar-icon-color: #0a9f6d;
+  --van-nav-bar-text-color: #0a9f6d;
+  --van-tabs-bottom-bar-color: #0a9f6d;
+  --van-tab-active-text-color: #0a9f6d;
+  --van-dropdown-menu-title-active-text-color: #0a9f6d;
+  --van-dropdown-menu-option-active-color: #0a9f6d;
+  --van-checkbox-checked-icon-color: #0a9f6d;
+  --van-switch-on-background: #0a9f6d;
+  --van-radio-checked-icon-color: #0a9f6d;
+  --van-slider-active-background: #0a9f6d;
+  --van-tag-primary-color: #0a9f6d;
+  --van-loading-spinner-color: #0a9f6d;
+  --van-picker-confirm-action-color: #0a9f6d;
+
+  font-size: 14px;
+
+  button,
+  summary {
+    min-width: 44px;
+    min-height: 44px;
+  }
+
+  button {
+    cursor: pointer;
+  }
+
+  .mp-customer-required,
+  .mp-customer-error {
+    color: #b42318;
+  }
+
+  .mp-customer-error {
+    padding: 12px;
+    background: #fff2f0;
+    border-radius: 10px;
+  }
+
+  .mp-customer-value {
+    overflow-wrap: anywhere;
+    white-space: pre-wrap;
+  }
+
+  .mp-customer-paste {
+    margin-bottom: 16px;
+
+    .mp-customer-paste__actions {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 12px;
+      padding: 0 16px 4px;
+    }
+
+    .mp-customer-paste__hint {
+      margin: 8px 16px 0;
+      font-size: 12px;
+      line-height: 1.4;
+      color: #6b7b74;
+    }
+  }
+
+  .mp-customer-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    align-items: center;
+    margin: 10px 0;
+  }
+
+  /* 详情 Tab:整块透明,透出页面灰底 */
+  .mp-customer-detail-tabs {
+    margin: 0 -10px 12px;
+    background: transparent !important;
+
+    --van-tabs-line-height: 44px;
+    --van-tabs-nav-background: transparent;
+    --van-tab-text-color: #6b7b74;
+    --van-tab-active-text-color: #087f57;
+    --van-tab-font-size: 13px;
+    --van-tabs-bottom-bar-color: #0a9f6d;
+    --van-tabs-bottom-bar-height: 2px;
+    --van-cell-background: transparent;
+    --van-cell-group-background: transparent;
+    /* Vant 默认 sticky z=99,会盖过绿头(50);压到绿头之下 */
+    --van-sticky-z-index: 40;
+
+    /* 吸顶时仍低于绿头(z=50),并铺实色避免正文透出 */
+    .van-sticky,
+    .van-sticky--fixed {
+      z-index: 40 !important;
+    }
+
+    .van-tabs__wrap {
+      background: #f5f8f7 !important;
+      border-bottom: 1px solid #dcebe4;
+      box-shadow: none;
+    }
+
+    .van-tabs__nav {
+      padding: 0 6px;
+      background: #f5f8f7 !important;
+    }
+
+    .van-tab {
+      flex: 0 0 auto;
+      min-width: 72px;
+      padding: 0 14px;
+      font-size: 13px;
+      font-weight: 600;
+      color: #6b7b74;
+    }
+
+    .van-tab--active {
+      font-weight: 700;
+      color: #087f57;
+    }
+
+    .van-tabs__line {
+      bottom: 2px;
+      width: 28px !important;
+      height: 2px !important;
+      background: #0a9f6d !important;
+      border-radius: 2px;
+    }
+
+    .van-tabs__content,
+    .van-tabs__track,
+    .van-tab__panel {
+      overflow: visible !important;
+      background: transparent !important;
+    }
+
+    /* 详情正文:去掉白底区块 / 字段组 */
+    .mp-customer-content {
+      padding-right: 10px;
+      padding-left: 10px;
+      background: transparent !important;
+
+      .mp-wv-section {
+        padding: 4px 0 12px;
+        background: transparent !important;
+        border: 0 !important;
+        border-radius: 0;
+        box-shadow: none;
+      }
+
+      .mp-customer-fields-vant,
+      .mp-customer-fields-vant.van-cell-group,
+      .mp-customer-fields-vant .van-cell {
+        background: transparent !important;
+      }
+
+      .mp-customer-fields-vant::after,
+      .mp-customer-fields-vant.van-hairline--top-bottom::after {
+        display: none !important;
+      }
+
+      /* 跟进/联系人等单条记录卡仍保留白底 */
+      .mp-customer-panel-card.van-cell-group,
+      .mp-customer-panel-card .van-cell {
+        background: #fff !important;
+      }
+    }
+  }
+
+  .mp-customer-panel-card {
+    margin-bottom: 10px;
+    overflow: hidden;
+
+    .mp-customer-tags,
+    .mp-customer-value,
+    .mp-customer-actions,
+    .mp-wv-card__meta,
+    .mp-customer-attachments,
+    p {
+      padding-left: 16px;
+      padding-right: 16px;
+    }
+
+    .mp-customer-actions {
+      padding-bottom: 12px;
+    }
+  }
+
+  .mp-customer-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin: 8px 0;
+  }
+
+  .mp-customer-content {
+    padding-bottom: calc(24px + env(safe-area-inset-bottom));
+  }
+
+  /* 仅「资料」Tab 有底部操作栏,需预留高度 */
+  .mp-customer-content--fab {
+    padding-bottom: calc(90px + env(safe-area-inset-bottom));
+  }
+
+  .mp-customer-attachments {
+    width: 100%;
+    min-width: 0;
+  }
+
+  .mp-customer-file-input {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    overflow: hidden;
+    clip: rect(0 0 0 0);
+  }
+
+  .mp-customer-attachment-hint {
+    margin: 8px 0 0;
+    font-size: 12px;
+    color: #6b7b74;
+  }
+
+  .mp-customer-pre {
+    font-size: 12px;
+    overflow-wrap: anywhere;
+    white-space: pre-wrap;
+  }
+
+  .mp-customer-loading {
+    display: flex;
+    justify-content: center;
+    padding: 48px 16px;
+  }
+
+  .mp-customer-load-hint {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: center;
+    margin: 12px 0 24px;
+    font-size: 13px;
+    color: #6b7b74;
+  }
+
+  .mp-customer-load-sentinel {
+    height: 1px;
+  }
+
+  .mp-customer-card-title {
+    display: block;
+    width: 100%;
+    padding: 0;
+    margin: 0 0 8px;
+    font: inherit;
+    font-weight: 600;
+    color: #0a9f6d;
+    text-align: left;
+    background: none;
+    border: 0;
+    cursor: pointer;
+  }
+}
+
+.mp-customer-dd {
+  --van-dropdown-menu-height: 36px;
+  --van-dropdown-menu-background: transparent;
+  --van-dropdown-menu-shadow: none;
+  --van-dropdown-menu-title-font-size: 13px;
+  --van-dropdown-menu-option-active-color: #0a9f6d;
+  --van-primary-color: #0a9f6d;
+  margin: 0;
+  background: transparent;
+  border: 0;
+  box-shadow: none;
+
+  .van-dropdown-menu__bar {
+    background: transparent;
+    box-shadow: none;
+  }
+}
+
+.mp-customer-list .mp-wv-header {
+  padding-bottom: 10px;
+}
+
+.mp-customer-list .mp-wv-header .mp-wv-search {
+  margin-bottom: 0;
+}
+
+.mp-customer-list .mp-wv-header .mp-customer-dd {
+  margin-top: 4px;
+}
+
+/* teleport 到 body 的下拉选项:直接兜底选中色 */
+.van-dropdown-item__option--active,
+.van-dropdown-item__option--active .van-dropdown-item__icon {
+  color: #0a9f6d !important;
+}
+
+.mp-customer-batch-bar {
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 55;
+  box-sizing: border-box;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
+  background: #fff;
+  border-top: 1px solid #dcebe4;
+  box-shadow: 0 -8px 24px rgb(34 64 52 / 12%);
+}
+
+.mp-customer-batch-bar > span {
+  flex: 1 0 100%;
+  color: #6b7b74;
+}
+
+.mp-customer-page .mp-wv-fab-bar {
+  /* 壳层 overflow-x: clip 会破坏 sticky;表单/详情底栏固定贴视口底 */
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 40;
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 8px;
+  justify-content: stretch;
+  width: 100%;
+  max-width: min(480px, 100%);
+  margin: 0 auto;
+  padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
+  background: #fff;
+  border-top: 1px solid #dcebe4;
+  box-shadow: 0 -8px 24px rgb(34 64 52 / 12%);
+
+  .van-button {
+    flex: 1 1 0;
+    min-width: 0;
+  }
+}
+
+.mp-customer-page .mp-wv-speed-main {
+  width: 52px !important;
+  height: 52px !important;
+  min-width: 52px;
+  padding: 0;
+}
+
+.mp-customer-sheet {
+  padding: 8px 0 16px;
+}
+
+.mp-customer-filter-vant,
+.mp-customer-pool-vant {
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  height: 100%;
+  min-height: 0;
+  background: #f7faf8;
+}
+
+.mp-customer-filter-vant__body,
+.mp-customer-pool-vant__body {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: auto;
+  -webkit-overflow-scrolling: touch;
+  padding: 8px 0 16px;
+}
+
+/* 弹层内表单字段保持白底,避免透明后「空白」 */
+.mp-customer-pool-vant .mp-customer-fields-vant,
+.mp-customer-vant-popup .mp-customer-fields-vant {
+  margin: 0 12px;
+  overflow: hidden;
+  background: #fff !important;
+  border-radius: 12px;
+
+  .van-cell {
+    background: #fff !important;
+  }
+}
+
+/* 详情 Tab 内列表卡:扁平结构,去掉强调条/KV 色块 */
+.mp-customer-detail-card {
+  cursor: default;
+  padding: 14px 14px 12px;
+  margin-bottom: 10px;
+
+  .mp-customer-detail-card__head {
+    display: flex;
+    gap: 8px;
+    align-items: flex-start;
+    justify-content: space-between;
+  }
+
+  .mp-customer-detail-card__main {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .mp-customer-detail-card__title {
+    font-size: 15px;
+    font-weight: 700;
+    color: #1f2a26;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .mp-customer-detail-card__meta {
+    margin-top: 4px;
+    font-size: 12px;
+    line-height: 1.45;
+    color: #6b7b74;
+  }
+
+  .mp-customer-detail-card__toggle {
+    display: flex;
+    gap: 4px;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    margin-top: 10px;
+    padding: 8px 0 2px;
+    font-size: 13px;
+    font-weight: 600;
+    color: #087f57;
+    background: none;
+    border: 0;
+    border-top: 1px dashed #dcebe4;
+    cursor: pointer;
+  }
+
+  .mp-customer-detail-card__toggle-icon {
+    font-size: 11px;
+    line-height: 1;
+  }
+
+  .mp-customer-detail-card__extra {
+    margin-top: 8px;
+  }
+
+  .mp-customer-detail-card__body {
+    margin: 0 0 8px;
+    font-size: 14px;
+    line-height: 1.55;
+    color: #1f2a26;
+    white-space: pre-wrap;
+    word-break: break-word;
+  }
+
+  .mp-customer-detail-card__rows {
+    margin-top: 0;
+  }
+
+  .mp-customer-detail-card__row {
+    display: flex;
+    gap: 12px;
+    align-items: flex-start;
+    padding: 4px 0;
+    font-size: 13px;
+    line-height: 1.45;
+
+    > span:first-child {
+      flex: 0 0 auto;
+      min-width: 5em;
+      color: #6b7b74;
+      white-space: nowrap;
+    }
+
+    > span:last-child {
+      flex: 1;
+      min-width: 0;
+      color: #1f2a26;
+      text-align: right;
+      word-break: break-word;
+    }
+  }
+
+  .mp-customer-detail-card__actions {
+    display: flex;
+    gap: 8px;
+    justify-content: flex-end;
+    margin-top: 12px;
+    padding-top: 10px;
+    border-top: 1px dashed #dcebe4;
+
+    .mp-wv-btn {
+      flex: 0 0 auto;
+      height: 30px;
+      min-height: 30px;
+      padding: 0 12px;
+      font-size: 12px;
+      line-height: 1;
+      border-radius: 8px;
+    }
+  }
+
+  .status-tag {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    min-height: 24px;
+    padding: 2px 8px;
+    font-size: 11px;
+    font-weight: 600;
+    line-height: 1.2;
+    border: 1px solid transparent;
+    border-radius: 999px;
+  }
+
+  .status-tag--success,
+  .status-tag--primary {
+    color: #087f57;
+    background: #e9f9f2;
+    border-color: #b7ebce;
+  }
+
+  .status-tag--warning {
+    color: #a16207;
+    background: #fffbeb;
+    border-color: #fde68a;
+  }
+
+  .status-tag--danger {
+    color: #b91c1c;
+    background: #fef2f2;
+    border-color: #fecaca;
+  }
+
+  .status-tag--info {
+    color: #475569;
+    background: #f1f5f9;
+    border-color: #dbe4ee;
+  }
+}
+
+.mp-customer-follow-panel {
+  .mp-customer-follow-att {
+    margin-top: 10px;
+  }
+
+  .mp-customer-follow-del {
+    min-width: 88px;
+  }
+}
+
+/* 联系人 / 开票 / 关联 Tab:共用检索+三栏筛选节奏 */
+.mp-customer-contact-panel,
+.mp-customer-invoice-panel,
+.mp-customer-related-panel {
+  position: relative;
+  z-index: 1;
+  overflow: visible;
+
+  .mp-customer-contact-search {
+    width: 100%;
+    margin: 0;
+  }
+
+  /* 检索下紧贴筛选:对齐客户列表三栏结构(灰底页用深绿字) */
+  .mp-customer-contact-dd {
+    position: relative;
+    z-index: 30;
+    margin: 4px 0 12px;
+    background: transparent;
+    border: 0;
+    box-shadow: none;
+
+    --van-dropdown-menu-height: 36px;
+    --van-dropdown-menu-background: transparent;
+    --van-dropdown-menu-shadow: none;
+    --van-dropdown-menu-title-font-size: 13px;
+    --van-dropdown-menu-title-text-color: #6b7b74;
+    --van-dropdown-menu-title-active-text-color: #087f57;
+    --van-dropdown-item-z-index: 40;
+
+    .van-dropdown-menu__bar {
+      position: relative;
+      z-index: 31;
+      background: transparent !important;
+      box-shadow: none !important;
+    }
+
+    .van-dropdown-menu__title {
+      color: #6b7b74;
+    }
+
+    .van-dropdown-menu__title--active {
+      color: #087f57;
+      font-weight: 700;
+    }
+  }
+
+  /* 卡片压低,避免盖住下拉面板 */
+  .mp-customer-detail-card {
+    position: relative;
+    z-index: 0;
+  }
+
+  .mp-customer-contact-actions .mp-wv-btn {
+    min-width: 72px;
+  }
+}
+
+/* 详情弹层内资料风分区(商机/合同/日志);表单弹层仍走上方白底 fields */
+.mp-customer-pool-vant__body {
+  .mp-wv-section {
+    margin: 0 0 14px;
+    padding: 0 12px;
+    background: transparent;
+    border: 0;
+    box-shadow: none;
+
+    > h3 {
+      margin: 0 0 8px;
+      padding: 0 4px;
+      font-size: 15px;
+      font-weight: 700;
+      color: #1f2a26;
+    }
+
+    .mp-customer-fields-vant {
+      margin: 0;
+      overflow: visible;
+      background: transparent !important;
+      border-radius: 0;
+
+      .van-cell {
+        background: transparent !important;
+      }
+    }
+  }
+
+  .mp-customer-detail-card {
+    margin-bottom: 10px;
+  }
+}
+
+/* 联系人筛选 teleport 到 body 后,保证盖住详情列表 */
+body > .van-dropdown-item {
+  z-index: 3000 !important;
+}
+
+.mp-customer-follow-upload {
+  margin: 10px 12px 0;
+  padding: 12px;
+  background: #fff;
+  border: 1px solid #dcebe4;
+  border-radius: 12px;
+}
+
+.mp-customer-filter-vant__foot,
+.mp-customer-pool-vant__foot {
+  display: flex;
+  gap: 8px;
+  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+  background: #fff;
+  border-top: 1px solid #e8f0ec;
+
+  /* 与列表卡片底栏 .mp-wv-card-actions .mp-wv-btn 一致 */
+  .mp-wv-btn,
+  .van-button {
+    flex: 1;
+    height: 30px !important;
+    min-height: 30px !important;
+    padding: 0 10px !important;
+    font-size: 13px !important;
+    line-height: 1 !important;
+    border-radius: 8px !important;
+  }
+
+  .mp-wv-btn--primary,
+  .van-button--primary {
+    font-weight: 600;
+  }
+}
+
+.mp-customer-pool-vant__tip {
+  margin: 0 16px 12px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: #6b7b74;
+}
+
+.mp-customer-filter-vant__multi {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #f7faf8;
+}
+
+.mp-customer-enum-toolbar {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 16px 6px;
+  background: #fff;
+  border-bottom: 1px solid #e8f0ec;
+}
+
+.mp-customer-enum-toolbar__count {
+  font-size: 13px;
+  color: #6b7b74;
+
+  strong {
+    margin: 0 2px;
+    color: #0a9f6d;
+  }
+}
+
+.mp-customer-enum-toolbar__acts {
+  display: flex;
+  gap: 8px;
+
+  .mp-wv-btn {
+    height: 30px;
+    min-height: 30px;
+    padding: 0 10px;
+    font-size: 12px;
+    line-height: 1;
+    border-radius: 8px;
+  }
+}
+
+.mp-customer-enum-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  max-height: 72px;
+  padding: 8px 16px 10px;
+  overflow: auto;
+  background: #fff;
+  border-bottom: 1px solid #e8f0ec;
+}
+
+.mp-customer-filter-vant__checks {
+  flex: 1;
+  overflow: auto;
+  padding: 8px 12px 16px;
+  -webkit-overflow-scrolling: touch;
+
+  .van-cell-group {
+    overflow: hidden;
+    border-radius: 12px;
+  }
+}
+
+.mp-customer-enum-loading {
+  display: flex;
+  justify-content: center;
+  padding: 32px 16px;
+}
+
+.mp-customer-enum-option {
+  align-items: center;
+
+  &.is-checked {
+    background: #ecfdf5;
+
+    .mp-customer-enum-option__label {
+      font-weight: 600;
+      color: #087f57;
+    }
+  }
+}
+
+.mp-customer-enum-option__label {
+  font-size: 14px;
+  color: #1f2a26;
+  line-height: 1.4;
+}
+
+.mp-customer-fields-vant {
+  margin: 0;
+}
+
+.mp-customer-panel-card {
+  margin-bottom: 10px;
+
+  > .mp-customer-tags,
+  > .mp-customer-value,
+  > .mp-wv-card__meta,
+  > p,
+  > .mp-customer-actions,
+  > .mp-customer-attachments {
+    padding-right: var(--van-cell-horizontal-padding, 16px);
+    padding-left: var(--van-cell-horizontal-padding, 16px);
+  }
+
+  > .mp-customer-tags {
+    padding-top: 8px;
+  }
+
+  > .mp-customer-actions:last-child {
+    padding-bottom: 12px;
+  }
+
+  > .van-tag {
+    margin: 8px var(--van-cell-horizontal-padding, 16px) 0;
+  }
+}
+
+/* teleport 到 body 的底部弹层:保证 82% 高度下内容区可滚动 */
+.mp-customer-vant-popup.van-popup {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.mp-customer-vant-popup {
+  --van-primary-color: #0a9f6d;
+  --van-button-primary-background: #0a9f6d;
+  --van-button-primary-border-color: #0a9f6d;
+  --van-nav-bar-icon-color: #0a9f6d;
+  --van-nav-bar-text-color: #0a9f6d;
+  --van-checkbox-checked-icon-color: #0a9f6d;
+  --van-picker-confirm-action-color: #0a9f6d;
+}
+
+.van-picker__confirm,
+.van-dialog__confirm {
+  color: #0a9f6d !important;
+}
+
+/*
+ * 客户管理全站按钮统一为列表卡片 .mp-wv-btn 样式
+ * (含 teleport 弹层 / Dialog;FAB 圆形主按钮除外)
+ */
+@mixin mp-customer-card-btn {
+  box-sizing: border-box;
+  height: 30px !important;
+  min-height: 30px !important;
+  padding: 0 10px !important;
+  font-size: 13px !important;
+  font-weight: 500;
+  line-height: 1 !important;
+  border-radius: 8px !important;
+
+  &.van-button--normal,
+  &.van-button--small,
+  &.van-button--mini,
+  &.van-button--large {
+    height: 30px !important;
+    min-height: 30px !important;
+    padding: 0 10px !important;
+    font-size: 13px !important;
+  }
+
+  &.van-button--round {
+    border-radius: 8px !important;
+  }
+
+  &.van-button--default,
+  &.van-button--plain {
+    color: #1f2a26 !important;
+    background: #fff !important;
+    border: 1px solid #dcebe4 !important;
+  }
+
+  &.van-button--plain.van-button--primary {
+    color: #0a9f6d !important;
+    background: #fff !important;
+    border: 1px solid #dcebe4 !important;
+  }
+
+  &.van-button--primary:not(.van-button--plain) {
+    color: #fff !important;
+    background: #0a9f6d !important;
+    border: 1px solid #0a9f6d !important;
+    font-weight: 600;
+  }
+
+  &.van-button--danger:not(.van-button--plain) {
+    color: #fff !important;
+    background: #dc2626 !important;
+    border: 1px solid #dc2626 !important;
+    font-weight: 600;
+  }
+
+  &.van-button--danger.van-button--plain {
+    color: #dc2626 !important;
+    background: #fff !important;
+    border: 1px solid #fecaca !important;
+  }
+
+  &.van-button--block {
+    width: 100%;
+  }
+
+  &.van-button--disabled,
+  &[disabled] {
+    opacity: 0.45;
+  }
+}
+
+.mp-customer-page .van-button:not(.mp-wv-speed-main),
+.mp-customer-sheet .van-button,
+.mp-customer-filter-vant .van-button,
+.mp-customer-pool-vant .van-button,
+.mp-customer-vant-popup .van-button,
+.mp-customer-enum-popup .van-button,
+.mp-customer-fields-vant .van-button {
+  @include mp-customer-card-btn;
+}
+
+/*
+ * 未走 mp-crm-dialog 的旧 Dialog 仍套卡片按钮;
+ * 已美化的 .mp-crm-dialog 排除,避免压扁方案 A/B 实心键。
+ */
+body .van-dialog:not(.mp-crm-dialog) .van-button {
+  @include mp-customer-card-btn;
+  flex: 1;
+  margin: 0 4px;
+  height: 30px !important;
+  min-height: 30px !important;
+}
+
+.mp-customer-filter-vant__foot,
+.mp-customer-pool-vant__foot {
+  .van-button {
+    flex: 1;
+    @include mp-customer-card-btn;
+    height: 30px !important;
+    min-height: 30px !important;
+    font-size: 13px !important;
+  }
+}
+
+.mp-customer-batch-bar .van-button,
+.mp-customer-page .mp-wv-fab-bar .van-button,
+.mp-customer-page .mp-customer-actions .van-button {
+  @include mp-customer-card-btn;
+}
+
+.mp-customer-page .mp-wv-speed-menu .van-button {
+  @include mp-customer-card-btn;
+  min-width: 96px;
+}
+
+/* FAB 主按钮保持圆形 */
+.mp-customer-page .mp-wv-speed-main.van-button {
+  width: 52px !important;
+  height: 52px !important;
+  min-width: 52px !important;
+  min-height: 52px !important;
+  padding: 0 !important;
+  border-radius: 50% !important;
+}

+ 234 - 0
web-admin/src/views/m/crm/customer/detail.vue

@@ -0,0 +1,234 @@
+<template>
+  <MpWebviewShell @ready="onReady">
+    <div class="mp-customer-page">
+      <MpBusinessHeader title="客户详情" back-label="返回客户列表" @back="back" />
+      <div v-if="loading" class="mp-customer-loading">
+        <van-loading vertical>正在加载客户详情</van-loading>
+      </div>
+      <van-empty v-else-if="error" :description="error">
+        <van-button round type="primary" @click="load()">重试</van-button>
+      </van-empty>
+      <template v-else-if="customer">
+        <van-tabs
+          v-model:active="tab"
+          shrink
+          sticky
+          :offset-top="stickyOffset"
+          swipeable
+          :ellipsis="false"
+          line-width="28px"
+          line-height="2px"
+          title-active-color="#087f57"
+          title-inactive-color="#6b7b74"
+          class="mp-customer-detail-tabs"
+          @change="onTabChange"
+        >
+          <van-tab name="basic" title="资料">
+            <div class="mp-customer-content mp-customer-content--fab">
+              <section class="mp-wv-section">
+                <h3>{{ customer.customerName }}</h3>
+                <CustomerFields :model-value="customer" :fields="customerReadFields" readonly />
+              </section>
+              <section v-for="field in certificateFields" :key="field.key" class="mp-wv-section">
+                <h3>{{ field.label }}</h3>
+                <CustomerAttachments
+                  v-if="customer[field.key]"
+                  :model-value="customer[field.key]"
+                  readonly
+                />
+                <van-empty v-else description="暂无附件" image-size="64" />
+              </section>
+            </div>
+          </van-tab>
+          <van-tab name="follow" title="跟进记录">
+            <div class="mp-customer-content">
+              <FollowPanel :customer-id="id" @changed="refreshInfo" />
+            </div>
+          </van-tab>
+          <van-tab name="contact" title="联系人">
+            <div class="mp-customer-content">
+              <ContactPanel :customer-id="id" @changed="refreshInfo" />
+            </div>
+          </van-tab>
+          <van-tab name="opportunity" title="商机">
+            <div class="mp-customer-content">
+              <RelatedPanel :customer-id="id" kind="opportunity" />
+            </div>
+          </van-tab>
+          <van-tab name="contract" title="合同">
+            <div class="mp-customer-content">
+              <RelatedPanel :customer-id="id" kind="contract" />
+            </div>
+          </van-tab>
+          <van-tab name="invoice" title="开票信息">
+            <div class="mp-customer-content">
+              <InvoicePanel :customer-id="id" />
+            </div>
+          </van-tab>
+          <van-tab name="log" title="操作日志">
+            <div class="mp-customer-content">
+              <RelatedPanel :customer-id="id" kind="log" />
+            </div>
+          </van-tab>
+        </van-tabs>
+        <div v-if="tab === 'basic'" class="mp-wv-fab-bar">
+          <van-button round type="primary" @click="router.push(`${CUSTOMER_PATH}/${id}/edit`)"
+            >编辑</van-button
+          >
+          <van-button round @click="poolOpen = true">放入公海</van-button>
+          <van-button
+            round
+            type="danger"
+            :loading="deleting"
+            :disabled="deleting || !canDeleteByCreator(customer)"
+            :title="CREATOR_DELETE_TIP"
+            @click="remove"
+            >删除</van-button
+          >
+        </div>
+        <CustomerPoolSheet v-model="poolOpen" :ids="[id]" @success="back" />
+      </template>
+    </div>
+  </MpWebviewShell>
+</template>
+<script setup lang="ts">
+  import { showFailToast, showSuccessToast } from 'vant'
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
+  import { fetchCustomerDetail, fetchDeleteCustomer } from '@/api/crm/customer'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import CustomerFields from './CustomerFields.vue'
+  import CustomerAttachments from './CustomerAttachments.vue'
+  import CustomerPoolSheet from './CustomerPoolSheet.vue'
+  import FollowPanel from './FollowPanel.vue'
+  import ContactPanel from './ContactPanel.vue'
+  import InvoicePanel from './InvoicePanel.vue'
+  import RelatedPanel from './RelatedPanel.vue'
+  import {
+    CUSTOMER_PATH,
+    customerReadFields,
+    certificateFields,
+    customerForm,
+    unwrapData,
+    type Row
+  } from './model'
+  import './customer.scss'
+  const route = useRoute()
+  const router = useRouter()
+  const id = computed(() => String(route.params.id || ''))
+  const customer = ref<Row>()
+  const tabKeys = ['basic', 'follow', 'contact', 'opportunity', 'contract', 'invoice', 'log']
+  const normalizeTab = (value: unknown) => {
+    const key = String(value || 'basic')
+    return tabKeys.includes(key) ? key : 'basic'
+  }
+  const tab = ref(normalizeTab(route.query.tab))
+  const loading = ref(true)
+  const deleting = ref(false)
+  const error = ref('')
+  const poolOpen = ref(false)
+  /** sticky Tab 吸顶偏移 = 绿头高度,避免钻进顶栏 */
+  const stickyOffset = ref(0)
+  let shellReady = false
+  let requestSeq = 0
+
+  function syncStickyOffset() {
+    if (typeof document === 'undefined') return
+    const header = document.querySelector('.mp-customer-page > .mp-wv-header') as HTMLElement | null
+    stickyOffset.value = Math.ceil(header?.getBoundingClientRect().height || 0)
+  }
+  async function refreshInfo() {
+    const requestedId = id.value
+    const seq = ++requestSeq
+    try {
+      const info = unwrapData(await fetchCustomerDetail({ id: requestedId }))
+      if (seq !== requestSeq || id.value !== requestedId) return
+      customer.value = customerForm(info)
+    } catch (e: any) {
+      if (seq !== requestSeq || id.value !== requestedId) return
+      showFailToast(e.message || '客户资料刷新失败')
+    }
+  }
+  async function load(requestedId = id.value) {
+    const seq = ++requestSeq
+    customer.value = undefined
+    loading.value = true
+    error.value = ''
+    try {
+      const info = unwrapData(await fetchCustomerDetail({ id: requestedId }))
+      if (seq !== requestSeq || id.value !== requestedId) return
+      if (!info.id) throw new Error('客户不存在或无权查看')
+      customer.value = customerForm(info)
+    } catch (e: any) {
+      if (seq !== requestSeq || id.value !== requestedId) return
+      error.value = e.message || '详情加载失败'
+    } finally {
+      if (seq === requestSeq && id.value === requestedId) loading.value = false
+    }
+  }
+  function selectTab(key: string) {
+    tab.value = normalizeTab(key)
+    const query = { ...route.query }
+    if (tab.value === 'basic') delete query.tab
+    else query.tab = tab.value
+    void router.replace({ path: route.path, query })
+  }
+  function onTabChange(name: string | number) {
+    selectTab(String(name))
+  }
+  function onReady() {
+    shellReady = true
+    tab.value = normalizeTab(route.query.tab)
+    void load(id.value)
+  }
+  watch(
+    () => [loading.value, customer.value?.id] as const,
+    async ([isLoading]) => {
+      if (isLoading) return
+      await nextTick()
+      syncStickyOffset()
+    }
+  )
+  onMounted(() => {
+    syncStickyOffset()
+    if (typeof window !== 'undefined') {
+      window.addEventListener('resize', syncStickyOffset)
+    }
+  })
+  onUnmounted(() => {
+    if (typeof window !== 'undefined') {
+      window.removeEventListener('resize', syncStickyOffset)
+    }
+  })
+  watch(
+    () => [String(route.params.id || ''), String(route.query.tab || '')] as const,
+    ([nextId, nextTab], [previousId]) => {
+      tab.value = normalizeTab(nextTab)
+      if (!shellReady || nextId === previousId) return
+      poolOpen.value = false
+      void load(nextId)
+    }
+  )
+  function back() {
+    void router.replace(CUSTOMER_PATH)
+  }
+  async function remove() {
+    if (deleting.value || !canDeleteByCreator(customer.value)) return
+    deleting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: '删除客户',
+        message: '确认删除该客户吗?'
+      })
+      await fetchDeleteCustomer({ ids: [id.value] })
+      showSuccessToast('删除成功')
+      back()
+    } catch (e: any) {
+      if (e?.message) showFailToast(e.message || '删除失败')
+    } finally {
+      deleting.value = false
+    }
+  }
+</script>

+ 210 - 0
web-admin/src/views/m/crm/customer/form.vue

@@ -0,0 +1,210 @@
+<template>
+  <MpWebviewShell @ready="load">
+    <div class="mp-customer-page">
+      <MpBusinessHeader
+        :title="id ? '编辑客户' : '新增客户'"
+        :back-label="id ? '返回客户详情' : '返回客户列表'"
+        @back="back"
+      />
+      <div v-if="loading" class="mp-customer-loading">
+        <van-loading vertical>{{
+          id ? '正在加载客户资料' : '正在准备客户资料'
+        }}</van-loading>
+      </div>
+      <van-empty v-else-if="loadError" :description="loadError">
+        <van-button round type="primary" @click="load">重试</van-button>
+      </van-empty>
+      <form v-else class="mp-customer-content mp-customer-content--fab" @submit.prevent="save">
+        <p v-if="error" class="mp-customer-error" role="alert">{{ error }}</p>
+        <p v-if="state.customerId" class="mp-customer-error"
+          >客户已创建。请补存开票资料,无需再次新增客户。</p
+        >
+        <fieldset :disabled="saving || !!state.customerId" class="mp-customer-fieldset">
+          <section v-for="group in customerGroups" :key="group.title" class="mp-wv-section">
+            <h3>{{ group.title }}</h3>
+            <CustomerFields v-model="form" :fields="group.fields" :users="users" />
+          </section>
+          <section class="mp-wv-section">
+            <h3>证件信息</h3>
+            <div
+              v-for="field in certificateFields"
+              :key="field.key"
+              class="mp-customer-certificate"
+            >
+              <h4>{{ field.label }}</h4>
+              <CustomerAttachments
+                :ref="(el) => registerUpload(field.key, el)"
+                v-model="form[field.key]"
+                :limit="field.limit"
+                :folder="'crm/customer/' + field.folder"
+                accept="image/*,.pdf"
+                :hint="`支持 JPG、PNG、PDF,最多 ${field.limit} 个`"
+              />
+            </div>
+          </section>
+        </fieldset>
+        <section v-if="!id" class="mp-wv-section">
+          <h3>开票信息(选填)</h3>
+          <InvoiceFields v-model="invoice" optional />
+        </section>
+        <section v-else class="mp-wv-section"
+          >开票信息可在客户详情中维护,支持多条和默认设置。</section
+        >
+        <div class="mp-wv-fab-bar">
+          <van-button round :disabled="saving" @click="back">取消</van-button>
+          <van-button round type="primary" native-type="submit" :loading="saving" :disabled="saving">
+            {{ state.customerId ? '补存开票资料' : '保存客户' }}
+          </van-button>
+        </div>
+      </form>
+    </div>
+  </MpWebviewShell>
+</template>
+<script setup lang="ts">
+  import { showSuccessToast } from 'vant'
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
+  import {
+    fetchCustomerDetail,
+    fetchCreateCustomer,
+    fetchUpdateCustomer,
+    fetchCheckDuplicate
+  } from '@/api/crm/customer'
+  import { addInvoiceInfo } from '@/api/crm/invoiceInfo'
+  import { fetchUserOptions } from '@/api/admin'
+  import { useUserStore } from '@/store/modules/user'
+  import { useDictStore } from '@/store/modules/dict'
+  import CustomerFields from './CustomerFields.vue'
+  import CustomerAttachments from './CustomerAttachments.vue'
+  import InvoiceFields from './InvoiceFields.vue'
+  import {
+    CUSTOMER_PATH,
+    customerGroups,
+    certificateFields,
+    customerForm,
+    saveCustomerAndInvoice,
+    unwrapData,
+    unwrapRows,
+    type Row
+  } from './model'
+  import './customer.scss'
+  const route = useRoute()
+  const router = useRouter()
+  const user = useUserStore()
+  const dict = useDictStore()
+  const id = computed(() => String(route.params.id || ''))
+  const form = ref<Row>(customerForm())
+  const invoice = ref<Row>({})
+  const users = ref<{ value: string; label: string }[]>([])
+  const loading = ref(true)
+  const saving = ref(false)
+  const loadError = ref('')
+  const error = ref('')
+  const state = reactive({ customerId: '' })
+  const uploads = new Map<string, InstanceType<typeof CustomerAttachments>>()
+  function registerUpload(key: string, el: any) {
+    if (el) uploads.set(key, el)
+    else uploads.delete(key)
+  }
+  async function load() {
+    loading.value = true
+    loadError.value = ''
+    try {
+      const [options, detail] = await Promise.all([
+        fetchUserOptions(),
+        id.value ? fetchCustomerDetail({ id: id.value }) : Promise.resolve({})
+      ])
+      users.value = unwrapRows(options).map((u) => ({ value: String(u.value), label: u.label }))
+      form.value = customerForm(unwrapData(detail))
+      if (!id.value) form.value.ownerUserId = String(user.info?.userId ?? user.info?.id ?? '')
+    } catch (e: any) {
+      loadError.value = e.message || '加载失败,请重试'
+    } finally {
+      loading.value = false
+    }
+  }
+  async function save() {
+    if (saving.value) return
+    error.value = ''
+    if ([...uploads.values()].some((u) => u.hasUploading())) {
+      error.value = '附件上传中,请稍候'
+      return
+    }
+    if (
+      !id.value &&
+      Object.values(invoice.value).some((v) => String(v ?? '').trim()) &&
+      !invoice.value.companyName?.trim()
+    ) {
+      error.value = '已填写开票资料时,单位名称不能为空'
+      return
+    }
+    saving.value = true
+    try {
+      if (!state.customerId) {
+        const park = String(form.value.parkArea || '').trim()
+        const knownPark = dict
+          .getOptions('crm_customer_park')
+          .find((o) => o.value === park || o.label === park)
+        if (knownPark) form.value.parkArea = knownPark.value
+        else if (park) {
+          try {
+            await showCrmConfirmDialog({
+              kind: 'default',
+              title: '新增字典项',
+              message: `将「${park}」新增到园区字典,是否继续?`
+            })
+          } catch {
+            return
+          }
+          form.value.parkArea = (await dict.ensure('crm_customer_park', park)).value
+        }
+        const duplicate = unwrapData(
+          await fetchCheckDuplicate({
+            customerName: form.value.customerName.trim(),
+            excludeId: id.value || undefined
+          })
+        )
+        if (duplicate.isDuplicate) {
+          error.value = '客户名称已存在,请核对后再保存'
+          return
+        }
+      }
+      const savedId = await saveCustomerAndInvoice(form.value, invoice.value, state, {
+        create: fetchCreateCustomer,
+        update: fetchUpdateCustomer,
+        invoice: addInvoiceInfo
+      })
+      showSuccessToast('保存成功')
+      await router.replace(`${CUSTOMER_PATH}/${savedId}`)
+    } catch (e: any) {
+      error.value = e.message || '保存失败,请重试'
+    } finally {
+      saving.value = false
+    }
+  }
+  function back() {
+    void router.replace(
+      id.value || state.customerId
+        ? `${CUSTOMER_PATH}/${id.value || state.customerId}`
+        : CUSTOMER_PATH
+    )
+  }
+</script>
+<style scoped>
+  .mp-customer-fieldset {
+    min-width: 0;
+    padding: 0;
+    margin: 0;
+    border: 0;
+  }
+
+  .mp-customer-certificate {
+    margin-bottom: 18px;
+  }
+
+  h4 {
+    margin: 14px 0 8px;
+    font-weight: 500;
+  }
+</style>

+ 535 - 0
web-admin/src/views/m/crm/customer/index.vue

@@ -0,0 +1,535 @@
+<template>
+  <MpWebviewShell @ready="onShellReady">
+    <div class="mp-customer-page mp-customer-list">
+      <!-- 头部与运输计划一致:H5 绿头 ← + 标题 + 搜索 -->
+      <MpBusinessHeader
+        :keyword="keyword"
+        title="客户管理"
+        searchable
+        search-placeholder="客户名称 / 编号 / 手机号 / 拼音首字母"
+        search-label="搜索客户"
+        back-label="返回工作台"
+        @back="goToMiniWorkbench"
+        @search="reload"
+        @update:keyword="onKeywordUpdate"
+      >
+        <template #before-search>
+          <van-tabs
+            v-model:active="activeView"
+            class="mp-customer-view-tabs"
+            shrink
+            :ellipsis="false"
+            aria-label="客户视图"
+            @change="onViewChange"
+          >
+            <van-tab v-for="tab in views" :key="tab.value" :name="tab.value" :title="tab.label" />
+          </van-tabs>
+        </template>
+        <template v-if="!forbidden" #after-search>
+          <van-dropdown-menu class="mp-customer-dd">
+            <van-dropdown-item
+              v-model="typeFilter"
+              :options="typeFilterOptions"
+              @change="onTypeFilterChange"
+            />
+            <van-dropdown-item
+              v-model="levelFilter"
+              :options="levelFilterOptions"
+              @change="onLevelFilterChange"
+            />
+            <van-dropdown-item
+              ref="moreDropdown"
+              :title="moreActive ? '更多·已筛' : '更多'"
+              @open="onMoreOpen"
+            />
+          </van-dropdown-menu>
+        </template>
+      </MpBusinessHeader>
+      <van-empty v-if="forbidden" :description="error || '无权限'">
+        <van-button round type="primary" @click="goToMiniWorkbench">返回工作台</van-button>
+      </van-empty>
+      <template v-else>
+        <p v-if="error && rows.length" class="mp-customer-error" role="alert">
+          {{ error }}
+          <van-button size="small" type="primary" plain @click="retry">重试</van-button>
+        </p>
+        <div class="mp-customer-content mp-wv-pad-speed">
+          <MpListPageSkeleton
+            v-if="loading && !rows.length"
+            variant="body"
+            layout="customer"
+            :card-count="3"
+            aria-label="正在加载客户列表"
+          />
+          <template v-else>
+            <van-empty v-if="error && !rows.length" :description="error">
+              <van-button round type="primary" @click="retry">重试</van-button>
+            </van-empty>
+            <article
+              v-for="row in rows"
+              :key="String(row.id)"
+              class="mp-wv-card mp-customer-list-card"
+              :aria-label="`查看客户:${row.customerName || '未命名客户'}`"
+              @click="onCardClick(row)"
+            >
+              <div class="mp-wv-card__head">
+                <div class="mp-customer-list-card__head-main">
+                  <van-checkbox
+                    v-if="selectMode"
+                    class="mp-customer-list-card__check"
+                    :model-value="selected.includes(String(row.id))"
+                    @click.stop
+                    @update:model-value="(v) => toggleSelected(row.id, Boolean(v))"
+                  />
+                  <div class="mp-customer-list-card__titles">
+                    <div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
+                    <div class="mp-customer-list-card__meta">{{ cardMeta(row) }}</div>
+                  </div>
+                </div>
+                <span class="status-tag" :class="`status-tag--${statusTone(row)}`">{{
+                  statusLabel(row)
+                }}</span>
+              </div>
+
+              <div v-if="previewFields.length" class="mp-customer-list-card__preview">
+                <div
+                  v-for="field in previewFields"
+                  :key="field.key"
+                  class="mp-customer-list-card__line"
+                >
+                  <span>{{ field.label }}</span>
+                  <span>{{ displayValue(row, field) }}</span>
+                </div>
+              </div>
+
+              <button
+                class="mp-customer-list-card__toggle"
+                type="button"
+                :aria-expanded="isExpanded(row.id)"
+                @click="toggleExpand(row.id, $event)"
+              >
+                {{ isExpanded(row.id) ? '收起' : '更多字段' }}
+                <span class="mp-customer-list-card__toggle-icon" aria-hidden="true">{{
+                  isExpanded(row.id) ? '▴' : '▾'
+                }}</span>
+              </button>
+
+              <div v-show="isExpanded(row.id)" class="mp-customer-list-card__extra">
+                <div
+                  v-for="field in extraFields"
+                  :key="field.key"
+                  class="mp-customer-list-card__row"
+                >
+                  <span>{{ field.label }}</span>
+                  <span>{{ displayValue(row, field) }}</span>
+                </div>
+              </div>
+
+              <div v-if="!selectMode" class="mp-wv-card-actions" @click.stop>
+                <button class="mp-wv-btn" type="button" @click="goEdit(row.id)">编辑</button>
+                <button class="mp-wv-btn" type="button" @click="goFollow(row.id)">跟进</button>
+                <button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="goDetail(row.id)">
+                  查看
+                </button>
+              </div>
+            </article>
+
+            <van-empty v-if="!rows.length && !error" description="暂无客户,可调整筛选后重试" />
+            <p v-if="rows.length" class="mp-customer-load-hint" role="status">
+              <van-loading v-if="loading" size="16px" />
+              <span>{{ loadHintText }}</span>
+            </p>
+            <div
+              v-if="hasMore"
+              ref="loadSentinel"
+              class="mp-customer-load-sentinel"
+              aria-hidden="true"
+            />
+          </template>
+        </div>
+
+        <CustomerFilters
+          v-model="filtersOpen"
+          :query="query"
+          :context="{ viewMode: activeView, keyword }"
+          @apply="applyFilters"
+        />
+        <CustomerPoolSheet v-model="poolOpen" :ids="selected" @success="afterBatchSuccess" />
+
+        <div v-if="selectMode" class="mp-customer-batch-bar">
+          <span>已选 {{ selected.length }} 项</span>
+          <van-button size="small" @click="toggleAll">
+            {{ allLoadedSelected ? '取消全选' : '全选已加载' }}
+          </van-button>
+          <van-button
+            size="small"
+            type="primary"
+            :disabled="selected.length !== 1"
+            @click="goEdit(selected[0])"
+          >
+            编辑
+          </van-button>
+          <van-button size="small" :disabled="!selected.length" @click="poolOpen = true"
+            >放入公海</van-button
+          >
+          <van-button
+            size="small"
+            type="danger"
+            :disabled="!canDelete || deleting"
+            :loading="deleting"
+            :title="CREATOR_DELETE_TIP"
+            @click="remove"
+          >
+            删除
+          </van-button>
+          <van-button size="small" @click="leaveSelectMode">完成</van-button>
+        </div>
+
+        <template v-if="!selectMode">
+          <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
+          <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
+            <div class="mp-wv-speed-menu">
+              <van-button round size="small" @click="enterSelectMode">批量选择</van-button>
+              <van-button round size="small" type="primary" @click="goNew">新增客户</van-button>
+            </div>
+            <van-button
+              class="mp-wv-speed-main"
+              type="primary"
+              round
+              icon="plus"
+              aria-label="快捷操作"
+              @click="speedOpen = !speedOpen"
+            />
+          </div>
+        </template>
+      </template>
+    </div>
+  </MpWebviewShell>
+</template>
+
+<script setup lang="ts">
+  import { showSuccessToast } from 'vant'
+  import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
+  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
+  import MpListPageSkeleton from '@/components/mp/MpListPageSkeleton.vue'
+  import {
+    fetchCustomerList,
+    fetchMyCustomerList,
+    fetchToFollowCustomerList,
+    fetchKeyCustomerList,
+    fetchDeleteCustomer
+  } from '@/api/crm/customer'
+  import { goToMiniWorkbench } from '@/utils/mp-webview'
+  import { HttpError } from '@/utils/http/error'
+  import { ApiStatus } from '@/utils/http/status'
+  import { useDictStore } from '@/store/modules/dict'
+  import { allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import CustomerFilters from './CustomerFilters.vue'
+  import CustomerPoolSheet from './CustomerPoolSheet.vue'
+  import {
+    CUSTOMER_PATH,
+    customerListCardMeta,
+    splitCustomerListFields,
+    unwrapData,
+    unwrapRows,
+    type Field,
+    type Row
+  } from './model'
+  import './customer.scss'
+
+  type ViewKey = 'all' | 'mine' | 'toFollow' | 'key'
+
+  const router = useRouter()
+  const dict = useDictStore()
+  const views = [
+    { value: 'all', label: '全部' },
+    { value: 'mine', label: '我的' },
+    { value: 'toFollow', label: '待跟进' },
+    { value: 'key', label: '重点' }
+  ]
+  const activeView = ref<ViewKey>('all')
+  const keyword = ref('')
+  const query = ref<Row>({})
+  const filtersOpen = ref(false)
+  const poolOpen = ref(false)
+  const speedOpen = ref(false)
+  const selectMode = ref(false)
+  const rows = ref<Row[]>([])
+  const selected = ref<string[]>([])
+  const total = ref(0)
+  const page = ref(0)
+  const loading = ref(true)
+  const deleting = ref(false)
+  const forbidden = ref(false)
+  const error = ref('')
+  const failedRequest = ref<'reload' | 'more'>('reload')
+  const expandedIds = ref<Set<string>>(new Set())
+  const listFieldSplit = splitCustomerListFields()
+  const previewFields = listFieldSplit.preview
+  const extraFields = listFieldSplit.extra
+  const selectedRows = computed(() =>
+    rows.value.filter((row) => selected.value.includes(String(row.id)))
+  )
+  const canDelete = computed(
+    () => selected.value.length > 0 && allRowsDeletableByCreator(selectedRows.value)
+  )
+  const allLoadedSelected = computed(
+    () =>
+      rows.value.length > 0 && rows.value.every((row) => selected.value.includes(String(row.id)))
+  )
+  const hasMore = computed(() => rows.value.length > 0 && rows.value.length < total.value)
+  const loadHintText = computed(() => {
+    if (loading.value && rows.value.length) return '加载中…'
+    if (!hasMore.value) return `已加载全部 ${total.value} 位`
+    return `已加载 ${rows.value.length} / ${total.value}`
+  })
+  const loadSentinel = ref<HTMLElement | null>(null)
+  useIntersectionObserver(
+    loadSentinel,
+    ([entry]) => {
+      if (!entry?.isIntersecting || !hasMore.value || loading.value) return
+      void fetchPage(false)
+    },
+    { rootMargin: '160px 0px' }
+  )
+  const typeFilter = ref('')
+  const levelFilter = ref('')
+  const typeFilterOptions = computed(() => [
+    { text: '全部类型', value: '' },
+    ...(dict.getOptions('crm_customer_type') || []).map((o: any) => ({
+      text: o.label,
+      value: String(o.value)
+    }))
+  ])
+  const levelFilterOptions = computed(() => [
+    { text: '全部级别', value: '' },
+    ...(dict.getOptions('crm_customer_level') || []).map((o: any) => ({
+      text: o.label,
+      value: String(o.value)
+    }))
+  ])
+  const moreActive = computed(() =>
+    Object.keys(query.value).some((key) => !['customerTypeIn', 'levelIn'].includes(key))
+  )
+
+  watch(
+    () => query.value.customerTypeIn,
+    (v) => {
+      typeFilter.value = v ? String(v).split(',')[0] : ''
+    },
+    { immediate: true }
+  )
+  watch(
+    () => query.value.levelIn,
+    (v) => {
+      levelFilter.value = v ? String(v).split(',')[0] : ''
+    },
+    { immediate: true }
+  )
+
+  function onTypeFilterChange(value: string) {
+    if (!value) delete query.value.customerTypeIn
+    else query.value.customerTypeIn = value
+    reload()
+  }
+  function onLevelFilterChange(value: string) {
+    if (!value) delete query.value.levelIn
+    else query.value.levelIn = value
+    reload()
+  }
+  const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
+  function onMoreOpen() {
+    filtersOpen.value = true
+    nextTick(() => moreDropdown.value?.toggle?.(false))
+  }
+  let requestSeq = 0
+
+  function requestSnapshot(nextPage: number) {
+    return JSON.stringify({
+      view: activeView.value,
+      keyword: keyword.value.trim(),
+      query: query.value,
+      page: nextPage
+    })
+  }
+
+  async function fetchPage(reset: boolean) {
+    if (!reset && loading.value) return
+    const seq = ++requestSeq
+    const nextPage = reset ? 1 : page.value + 1
+    const snapshot = requestSnapshot(nextPage)
+    if (reset) {
+      rows.value = []
+      total.value = 0
+      selected.value = []
+      page.value = 0
+      expandedIds.value = new Set()
+    }
+    loading.value = true
+    error.value = ''
+    failedRequest.value = reset ? 'reload' : 'more'
+    try {
+      const api =
+        {
+          all: fetchCustomerList,
+          mine: fetchMyCustomerList,
+          toFollow: fetchToFollowCustomerList,
+          key: fetchKeyCustomerList
+        }[activeView.value] || fetchCustomerList
+      const result = unwrapData(
+        await api({
+          ...query.value,
+          page: nextPage,
+          pageSize: 20,
+          keyword: keyword.value.trim() || undefined,
+          viewMode: activeView.value === 'all' ? undefined : activeView.value
+        })
+      )
+      if (seq !== requestSeq || snapshot !== requestSnapshot(nextPage)) return
+      const nextRows = unwrapRows(result)
+      rows.value = reset ? nextRows : [...rows.value, ...nextRows]
+      total.value = Number(result.total ?? rows.value.length)
+      page.value = nextPage
+      forbidden.value = false
+    } catch (e: any) {
+      if (seq !== requestSeq) return
+      if (e instanceof HttpError && e.code === ApiStatus.forbidden) {
+        forbidden.value = true
+        error.value = e.message || '无权限访问客户列表'
+        rows.value = []
+        total.value = 0
+        selected.value = []
+        page.value = 0
+        filtersOpen.value = false
+        poolOpen.value = false
+        speedOpen.value = false
+        selectMode.value = false
+        return
+      }
+      error.value = e.message || '加载失败,请重试'
+    } finally {
+      if (seq === requestSeq) loading.value = false
+    }
+  }
+
+  function reload() {
+    void fetchPage(true)
+  }
+
+  function onShellReady() {
+    reload()
+  }
+  function retry() {
+    void fetchPage(failedRequest.value === 'reload')
+  }
+  function onKeywordUpdate(value: string) {
+    const shouldReload = Boolean(keyword.value.trim()) && !value.trim()
+    keyword.value = value
+    if (shouldReload) reload()
+  }
+  function onViewChange() {
+    // v-model:active 已更新;van-tabs 仅在切换时触发 change
+    reload()
+  }
+  function applyFilters(value: { query: Row; keyword: string; viewMode: ViewKey }) {
+    query.value = value.query
+    keyword.value = value.keyword
+    activeView.value = value.viewMode
+    reload()
+  }
+  function dictLabel(code: string, value: unknown, fallback = '—') {
+    return value ? dict.getLabel(code, String(value)) || String(value) : fallback
+  }
+  function displayValue(row: Row, field: Field) {
+    const value = row[field.key]
+    if (field.dict) return dictLabel(field.dict, value)
+    return value === '' || value == null ? '—' : String(value)
+  }
+  function cardMeta(row: Row) {
+    return customerListCardMeta({
+      statusLabel: statusLabel(row),
+      customerNo: row.customerNo,
+      createTime: row.createTime
+    })
+  }
+  function isExpanded(id: string | number | undefined) {
+    return expandedIds.value.has(String(id))
+  }
+  function toggleExpand(id: string | number | undefined, e?: Event) {
+    e?.stopPropagation()
+    const key = String(id)
+    const next = new Set(expandedIds.value)
+    if (next.has(key)) next.delete(key)
+    else next.add(key)
+    expandedIds.value = next
+  }
+  function statusLabel(row: Row) {
+    return dictLabel('crm_customer_status', row.status, row.status || '正常')
+  }
+  function statusTone(row: Row): 'success' | 'warning' | 'danger' | 'info' {
+    if (['disabled', 'invalid', 'lost'].includes(String(row.status))) return 'danger'
+    if (['pool', 'pending'].includes(String(row.status))) return 'warning'
+    return row.status ? 'success' : 'info'
+  }
+  function onCardClick(row: Row) {
+    if (selectMode.value) {
+      const id = String(row.id)
+      toggleSelected(row.id, !selected.value.includes(id))
+      return
+    }
+    goDetail(row.id)
+  }
+  function goDetail(id?: string | number) {
+    if (id != null) void router.push(`${CUSTOMER_PATH}/${id}`)
+  }
+  function goEdit(id?: string | number) {
+    if (id != null) void router.push(`${CUSTOMER_PATH}/${id}/edit`)
+  }
+  function goFollow(id?: string | number) {
+    if (id != null) void router.push({ path: `${CUSTOMER_PATH}/${id}`, query: { tab: 'follow' } })
+  }
+  function goNew() {
+    speedOpen.value = false
+    void router.push(`${CUSTOMER_PATH}/new`)
+  }
+  function enterSelectMode() {
+    speedOpen.value = false
+    selectMode.value = true
+  }
+  function leaveSelectMode() {
+    selectMode.value = false
+    selected.value = []
+  }
+  function toggleSelected(id: string | number, checked: boolean) {
+    const value = String(id)
+    selected.value = checked
+      ? [...new Set([...selected.value, value])]
+      : selected.value.filter((item) => item !== value)
+  }
+  function toggleAll() {
+    selected.value = allLoadedSelected.value ? [] : rows.value.map((row) => String(row.id))
+  }
+  function afterBatchSuccess() {
+    leaveSelectMode()
+    reload()
+  }
+  async function remove() {
+    if (!canDelete.value || deleting.value) return
+    deleting.value = true
+    try {
+      await showCrmConfirmDialog({
+        kind: 'danger',
+        title: '删除客户',
+        message: `确认删除选中的 ${selected.value.length} 位客户吗?`
+      })
+      await fetchDeleteCustomer({ ids: [...selected.value] })
+      showSuccessToast('删除成功')
+      afterBatchSuccess()
+    } catch (e: any) {
+      if (e !== 'cancel' && e?.message) error.value = e.message || '删除失败'
+    } finally {
+      deleting.value = false
+    }
+  }
+</script>

+ 1 - 116
web-admin/src/views/m/crm/product/product.scss

@@ -291,119 +291,4 @@ body > .van-dropdown-item {
   z-index: 3000 !important;
 }
 
-/* ── 确认弹窗:方案 A / B(挂到 body,须用 className 限定;对齐 Demo)── */
-body .van-dialog.mp-product-dialog {
-  overflow: hidden;
-  width: 300px !important;
-  max-width: calc(100vw - 48px);
-  border-radius: 16px;
-  box-shadow: 0 16px 40px rgb(15 40 30 / 22%);
-}
-
-/* 方案 A:Vant header 居中 */
-body .van-dialog.mp-product-dialog .van-dialog__header {
-  padding: 20px 20px 0;
-  font-size: 16px;
-  font-weight: 700;
-  color: #1f2a26;
-  line-height: 1.4;
-  text-align: center;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__content {
-  padding: 0;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__message {
-  padding: 10px 20px 20px;
-  font-size: 14px;
-  line-height: 1.55;
-  color: #6b7b74;
-  text-align: center;
-}
-
-/* 方案 B:图标 → 标题 → 文案(标题在 HTML 内,无 Vant header) */
-body .van-dialog.mp-product-dialog.mp-product-dialog--danger .van-dialog__header {
-  display: none;
-}
-
-body .van-dialog.mp-product-dialog.mp-product-dialog--danger .van-dialog__message {
-  padding: 20px 20px 20px;
-}
-
-body .van-dialog.mp-product-dialog .mp-product-dialog__warn {
-  display: flex;
-  justify-content: center;
-  margin: 0 0 12px;
-}
-
-body .van-dialog.mp-product-dialog .mp-product-dialog__warn-icon {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 44px;
-  height: 44px;
-  font-size: 22px;
-  font-weight: 700;
-  line-height: 1;
-  color: #dc2626;
-  background: #fef2f2;
-  border-radius: 50%;
-}
-
-body .van-dialog.mp-product-dialog .mp-product-dialog__title {
-  margin: 0 0 8px;
-  font-size: 16px;
-  font-weight: 700;
-  line-height: 1.4;
-  color: #1f2a26;
-  text-align: center;
-}
-
-body .van-dialog.mp-product-dialog .mp-product-dialog__msg {
-  color: #6b7b74;
-  text-align: center;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__footer {
-  display: flex;
-  gap: 10px;
-  padding: 0 16px 16px;
-  border-top: none !important;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__footer::after,
-body .van-dialog.mp-product-dialog .van-hairline--left::after,
-body .van-dialog.mp-product-dialog .van-hairline--top::after {
-  display: none !important;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__cancel,
-body .van-dialog.mp-product-dialog .van-dialog__confirm {
-  flex: 1;
-  height: 40px !important;
-  min-height: 40px !important;
-  margin: 0 !important;
-  padding: 0 10px !important;
-  font-size: 14px !important;
-  font-weight: 600 !important;
-  line-height: 40px !important;
-  border-radius: 10px !important;
-  border: 1px solid #dcebe4 !important;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__cancel {
-  color: #1f2a26 !important;
-  background: #fff !important;
-}
-
-body .van-dialog.mp-product-dialog .van-dialog__confirm {
-  color: #fff !important;
-  background: #0a9f6d !important;
-  border-color: #0a9f6d !important;
-}
-
-body .van-dialog.mp-product-dialog.mp-product-dialog--danger .van-dialog__confirm {
-  background: #dc2626 !important;
-  border-color: #dc2626 !important;
-}
+/* 确认弹窗样式已上收至 @/styles/mp-crm-primitives.scss(.mp-crm-dialog) */