feat(card): 员工端「我的人脉」页 + 与潜在客户导航互链

- 新增 contacts.vue: 搜索/来源筛选/列表卡(来源徽标)/编辑/转潜在客户/删除
- 新增 contact-edit.vue: 新增编辑复用, 姓名红星必填, 其余(选填)
- leads.vue 顶部互链「我的人脉 ›」(导航合并第一步)
- pages.json 注册两页; PAGE_KEY_MAP 补 PAGE_CARD_CONTACTS
- card-platform.test.js 追加 4 测试(25/25)
This commit is contained in:
2026-10-12 17:35:16 +08:00
parent 0859765efb
commit b92f474ac8
6 changed files with 744 additions and 0 deletions

View File

@@ -203,6 +203,7 @@ export const PAGE_KEY_MAP = {
// === 名片云(card 插件) ===
PAGE_CARD_LIST: '/pages_tool/card/list',
PAGE_CARD_LEADS: '/pages_tool/card/leads',
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
// === VR ===
PAGE_VR_INDEX: '/pages_tool/vr/index',

View File

@@ -1493,6 +1493,19 @@
"enablePullDownRefresh": true
}
},
{
"path": "card/contacts",
"style": {
"navigationBarTitleText": "我的人脉",
"enablePullDownRefresh": true
}
},
{
"path": "card/contact-edit",
"style": {
"navigationBarTitleText": "编辑人脉"
}
},
{
"path": "card/share",
"style": {

View File

@@ -0,0 +1,229 @@
<template>
<view class="contact-edit">
<view class="form-group">
<view class="group-title">{{ isEdit ? '编辑人脉' : '添加人脉' }}</view>
<view class="form-row">
<view class="row-label"><text class="req-star">*</text>姓名</view>
<input class="row-input" :value="form.name" placeholder="请输入姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">手机</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="手机号(选填)"
@input="e => form.mobile = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">公司</view>
<input class="row-input" :value="form.company" placeholder="公司名称(选填)"
@input="e => form.company = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">职位</view>
<input class="row-input" :value="form.job_title" placeholder="职位(选填)"
@input="e => form.job_title = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">微信号</view>
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
@input="e => form.wechat = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮箱</view>
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
@input="e => form.email = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">网站</view>
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
@input="e => form.website = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">地址</view>
<input class="row-input" :value="form.address" placeholder="地址(选填)"
@input="e => form.address = e.detail.value" />
</view>
<view class="form-row remark-row">
<view class="row-label">备注</view>
<textarea class="remark-input" :value="form.remark" placeholder="备注(选填)"
@input="e => form.remark = e.detail.value" />
</view>
</view>
<!-- 保存 -->
<view class="save-bar">
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
</view>
</view>
</template>
<script>
// 与后端 Contact::EDITABLE_FIELDS 对齐(status 由列表归档操作管理, 编辑页不放)
const FIELDS = ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark'];
export default {
data() {
return {
id: 0,
isEdit: false,
form: {
name: '', mobile: '', company: '', job_title: '',
wechat: '', email: '', website: '', address: '', remark: ''
},
saving: false
};
},
onLoad(options) {
this.id = parseInt(options.id || 0, 10) || 0;
this.isEdit = this.id > 0;
if (this.isEdit) {
this.loadDetail();
}
},
methods: {
loadDetail() {
this.$api.sendRequest({
url: '/card/api/contact/info',
data: { id: this.id },
success: res => {
if (res.code == 0) {
const r = res.data || {};
FIELDS.forEach(k => {
if (r[k] !== undefined && r[k] !== null) {
this.form[k] = String(r[k]);
}
});
} else {
uni.showModal({
title: '提示',
content: res.message || '人脉不存在',
showCancel: false,
success: () => this.backToList()
});
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
save() {
if (this.saving) return;
if (!this.form.name.trim()) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
this.saving = true;
const data = { id: this.id, fields: {} };
FIELDS.forEach(k => { data.fields[k] = this.form[k]; });
this.$api.sendJson({
url: this.isEdit ? '/card/api/contact/update' : '/card/api/contact/add',
data: this.isEdit ? data : { fields: data.fields },
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => this.backToList(), 600);
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
// 鸿蒙安全回列表: $util.redirectTo(禁裸 uni.switchTab/uni.navigateBack 兜底)
backToList() {
this.$util.redirectTo('/pages_tool/card/contacts');
}
}
};
</script>
<style lang="scss" scoped>
.contact-edit {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
padding: 24rpx 24rpx 8rpx;
}
.form-row {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
}
/* 必填红星(与 card/edit.vue 同款) */
.req-star {
color: #f5222d;
margin-right: 4rpx;
font-weight: 600;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
.remark-row {
align-items: flex-start;
.remark-input {
flex: 1;
min-height: 120rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
}
.save-bar {
padding: 20rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<view class="contact-page">
<!-- 平级入口: 与潜在客户页互链(导航合并第一步) -->
<view class="page-switch" @tap="goLeads">
<text class="switch-label">潜在客户</text>
<text class="switch-arrow">›</text>
</view>
<!-- 搜索栏 + 添加入口 -->
<view class="search-bar">
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
<view class="search-btn" @tap="doSearch">搜索</view>
<view class="add-btn" @tap="goEdit()">+ 添加</view>
</view>
<!-- 来源筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 人脉列表 -->
<view class="contact-list">
<view v-for="item in list" :key="item.id" class="contact-item">
<view class="item-top" @tap="callPhone(item.mobile)">
<view class="item-name">{{ item.name }}</view>
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
</view>
<view class="item-company" v-if="item.company || item.job_title">
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
</view>
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
<view class="item-meta">
<text class="meta-time">{{ item.create_time_txt }}</text>
<text class="meta-status" v-if="item.status == 1">已归档</text>
</view>
<view class="item-actions">
<view class="action-btn plain" @tap="goEdit(item.id)">编辑</view>
<view class="action-btn primary" @tap="convertToLead(item)">转为潜在客户</view>
<view class="action-btn danger" @tap="delContact(item)">删除</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" isIndex="false"
:text="'暂无人脉,点右上角添加'"></ns-empty>
</view>
</view>
</template>
<script>
// 来源筛选: 与后端 Contact::SOURCES 一致; scan/exchange 暂无产生入口, 本轮只放出 3 个 tab
const SOURCE_TABS = [
{ value: '', label: '全部' },
{ value: 'manual', label: '手工录入' },
{ value: 'lead_in', label: '潜在客户转入' }
];
export default {
data() {
return {
tabs: SOURCE_TABS,
source: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onShow() {
// 从编辑页保存后 redirectTo 回来会重走 onLoad; 从其它页返回时兜底刷新
if (this.list.length > 0) {
this.refresh();
}
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
goLeads() {
this.$util.redirectTo('/pages_tool/card/leads');
},
goEdit(id) {
const url = id ? '/pages_tool/card/contact-edit?id=' + id : '/pages_tool/card/contact-edit';
this.$util.redirectTo(url);
},
switchTab(value) {
if (this.source === value) return;
this.source = value;
this.refresh();
},
doSearch() {
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 source 条件
if (this.source !== '') {
data.source = this.source;
}
if (this.keyword.trim() !== '') {
data.keyword = this.keyword.trim();
}
this.$api.sendRequest({
url: '/card/api/contact/lists',
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
convertToLead(item) {
uni.showModal({
title: '转为潜在客户',
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
success: res => {
if (!res.confirm) return;
this.doConvert(item);
}
});
},
doConvert(item) {
uni.showLoading({ title: '转换中…', mask: true });
this.$api.sendRequest({
url: '/card/api/contact/toLead',
data: { id: item.id },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
} else {
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
delContact(item) {
uni.showModal({
title: '删除人脉',
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
confirmColor: '#f5222d',
success: res => {
if (!res.confirm) return;
this.doDelete(item);
}
});
},
doDelete(item) {
this.$api.sendRequest({
url: '/card/api/contact/delete',
data: { id: item.id },
success: res => {
if (res.code == 0) {
this.list = this.list.filter(c => c.id !== item.id);
uni.showToast({ title: '已删除', icon: 'success' });
} else {
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
}
}
};
</script>
<style lang="scss" scoped>
.contact-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16rpx 24rpx 0;
.switch-label {
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.switch-arrow {
font-size: 26rpx;
color: var(--main-color, #0054a5);
margin-left: 4rpx;
}
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
.search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
background-color: #ffffff;
border-radius: 32rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
}
.search-btn {
font-size: 26rpx;
color: #666;
padding: 0 24rpx;
}
.add-btn {
font-size: 26rpx;
color: #ffffff;
background-color: var(--main-color, #0054a5);
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.contact-list {
padding: 20rpx 24rpx 0;
}
.contact-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.item-top {
display: flex;
align-items: center;
.item-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.item-source {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.src-manual {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.src-lead_in {
color: #1daa39;
background-color: #e8f8ea;
}
&.src-scan,
&.src-exchange {
color: #999;
background-color: #f2f2f2;
}
}
}
.item-company {
font-size: 26rpx;
color: #666;
margin-top: 10rpx;
}
.item-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.item-remark {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
}
.item-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 26rpx;
margin-left: 16rpx;
&.plain {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
&.danger {
color: #f5222d;
border: 1rpx solid #f5222d;
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<view class="lead-page">
<!-- 平级入口: 与我的人脉页互链(导航合并第一步) -->
<view class="page-switch" @tap="goContacts">
<text class="switch-label">我的人脉</text>
<text class="switch-arrow">›</text>
</view>
<!-- 状态筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
@@ -74,6 +80,10 @@ export default {
this.loadPage(this.page + 1);
},
methods: {
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
goContacts() {
this.$util.redirectTo('/pages_tool/card/contacts');
},
switchTab(value) {
if (this.status === value) return;
this.status = value;
@@ -164,6 +174,24 @@ export default {
box-sizing: border-box;
}
.page-switch {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16rpx 24rpx 0;
.switch-label {
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.switch-arrow {
font-size: 26rpx;
color: var(--main-color, #0054a5);
margin-left: 4rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;

View File

@@ -343,3 +343,53 @@ test('选择链接: card 页面不在 TabBar(NO_TAB_PAGES 语义)不影响导航
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
assert.ok(!/card/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 card 路径(card 非 tabBar 页)');
});
// ============================================================
// 2.0.0 人脉库: contacts / contact-edit 注册 + 互链 + 鸿蒙安全
// ============================================================
test('人脉库: PAGE_KEY_MAP 含 PAGE_CARD_CONTACTS', () => {
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
const m = utilSrc.match(/PAGE_CARD_CONTACTS:\s*'([^']+)'/);
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_CONTACTS');
assert.equal(m[1], '/pages_tool/card/contacts');
});
test('人脉库: contacts/contact-edit 已注册进 pages.json 且不在 TabBar', () => {
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
const pages = JSON.parse(stripped);
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
assert.ok(sub, 'pages_tool 分包应存在');
const paths = (sub.pages || []).map(p => p.path);
assert.ok(paths.includes('card/contacts'), `card/contacts 未注册: ${paths.join(',')}`);
assert.ok(paths.includes('card/contact-edit'), `card/contact-edit 未注册: ${paths.join(',')}`);
// TabBar 根页不在 pages_tool 分包, 天然不在 TabBar; 防回归: tabBar 列表不得含 card
const tabPages = (pages.tabBar && pages.tabBar.list ? pages.tabBar.list : []).map(p => p.pagePath);
assert.ok(!tabPages.some(p => /card/.test(p)), 'TabBar 不应含 card 页面');
});
test('人脉库: contacts/leads 顶部互链都走 $util.redirectTo(鸿蒙静默失败回归)', () => {
const contactsSrc = read('contacts.vue');
const leadsSrc = read('leads.vue');
// contacts → leads
assert.ok(/goLeads\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/leads'\)/.test(contactsSrc),
'contacts.vue 顶部「潜在客户」链接必须走 $util.redirectTo');
// leads → contacts
assert.ok(/goContacts\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(leadsSrc),
'leads.vue 顶部「我的人脉」链接必须走 $util.redirectTo');
// 两文件不得出现裸 uni.switchTab 调用
for (const [name, src] of [['contacts.vue', contactsSrc], ['leads.vue', leadsSrc]]) {
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${name} 不得直接调 uni.switchTab`);
}
});
test('人脉库: contact-edit 保存成功回 contacts 走 $util.redirectTo(鸿蒙安全)', () => {
const src = read('contact-edit.vue');
assert.ok(/backToList\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(src),
'保存成功回列表必须走 $util.redirectTo');
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
// 姓名必填: 模板有 req-star + 保存前有空名校验
assert.ok(/req-star/.test(src), '姓名必填应有 req-star 红星');
assert.ok(/请填写姓名/.test(src), '保存前应校验姓名非空');
});