feat(web-admin): unify CRM confirm dialogs for customer and product.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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],
|
||||
|
||||
@@ -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), /删 <b>除<\/b>/)
|
||||
assert.match(String(opts.message), /a <b>x<\/b> & "y"/)
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 方案 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), /删 <b>除<\/b>/)
|
||||
assert.match(String(opts.message), /a <b>x<\/b> & "y"/)
|
||||
})
|
||||
|
||||
it('allows override confirmButtonText', () => {
|
||||
const opts = buildProductConfirmOptions({
|
||||
kind: 'default',
|
||||
title: '上架产品',
|
||||
message: '确认上架该产品?',
|
||||
confirmButtonText: '立即上架'
|
||||
})
|
||||
assert.equal(opts.confirmButtonText, '立即上架')
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 方案 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>
|
||||
@@ -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>
|
||||
@@ -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) */
|
||||
Reference in new issue
Block a user