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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-18 18:26:56 +08:00
1 parent 5c791b00cb
commit 4fd178499e
17 files changed
+4186 -206

No files matched your search

@@ -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')
@@ -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
View File
@@ -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;
}
@@ -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
View File
@@ -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))
}
@@ -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
View File
@@ -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'
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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;
}
@@ -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
View File
@@ -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>
@@ -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
View File
@@ -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) */