chore:名片样式的名片模板第一个模板

This commit is contained in:
2026-10-14 16:53:03 +08:00
parent 022a8bb5bb
commit e60b3e2e52
7 changed files with 2691 additions and 0 deletions

View File

@@ -0,0 +1,450 @@
<template>
<view class="admin-contacts">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
<view class="page-switch">
<view class="switch-item" @tap="goManage">名片管理</view>
<view class="switch-item active" @tap="goSelf">人脉管理</view>
<view class="switch-item" @tap="goLeads">潜在客户</view>
</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>
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
<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>
/**
* 商家端 · 人脉管理
*
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
* contact/toLead 转线索 / contact/delete 真删。
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
*/
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
);
export default {
data() {
return {
tabs: SOURCE_TABS,
source: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onShow() {
// 从编辑页保存返回(redirectTo 回本页)时刷新
if (this._loadedOnce) this.refresh();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
goManage() {
this.$util.redirectTo('/pages_tool/card/admin/manage');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/admin/leads');
},
goSelf() {},
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
goEdit(id) {
const url = id
? '/pages_tool/card/contact-edit?from=admin&id=' + id
: '/pages_tool/card/contact-edit?from=admin';
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.$cardApi.contactLists({
data: data,
success: res => {
this.isLoading = false;
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' });
}
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 });
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
this.$cardApi.contactToLead({
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.$cardApi.contactDelete({
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>
.admin-contacts {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
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: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.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

@@ -0,0 +1,398 @@
<template>
<view class="admin-leads">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
<view class="page-switch">
<view class="switch-item" @tap="goManage">名片管理</view>
<view class="switch-item" @tap="goContacts">人脉管理</view>
<view class="switch-item active" @tap="goSelf">潜在客户</view>
</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>
<!-- 跟进状态筛选(与后端 Lead::STATUSES 对齐, setStatus 仅接受 0-3) -->
<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: status === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 线索列表 -->
<view class="lead-list">
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
<view class="lead-top" @tap="callPhone(item.mobile)">
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
</view>
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
<view class="lead-meta">
<text class="meta-card">来自名片:{{ item.card_name }}</text>
<text class="meta-time">{{ item.create_time_txt }}</text>
</view>
<view class="lead-actions">
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
<view class="action-btn primary" @tap="changeStatus(item, index)">更新状态</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="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 潜在客户
*
* 接口全部来自后端已就绪的 /card/api/lead/*(收口在 common/js/card-api.js):
* lead/lists 列表(可见范围=自己名下名片收到的线索 + 自己从人脉转入的线索;
* 没有名片时列表为空属正常现象) / lead/setStatus 更新跟进状态(0-3)。
*/
import { LEAD_STATUS } from '@/common/js/card-api.js';
export default {
data() {
return {
tabs: [{ value: '', label: '全部' }].concat(LEAD_STATUS),
status: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
emptyText: '暂无意向客户,访客在你的名片页留资后会出现在这里'
};
},
onLoad() {
this.refresh();
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
goManage() {
this.$util.redirectTo('/pages_tool/card/admin/manage');
},
goContacts() {
this.$util.redirectTo('/pages_tool/card/admin/contacts');
},
goSelf() {},
switchTab(value) {
if (this.status === value) return;
this.status = 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 };
// 空字符串 = 全部, 后端对 '' 不加 status 条件; status 只传 0-3
if (this.status !== '') data.status = this.status;
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
this.$cardApi.leadLists({
data: data,
success: res => {
this.isLoading = false;
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);
this.emptyText = this.keyword.trim() || this.status !== ''
? '未找到匹配的客户'
: '暂无意向客户,访客在你的名片页留资后会出现在这里';
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
changeStatus(item, index) {
uni.showActionSheet({
itemList: LEAD_STATUS.map(s => s.label),
success: res => {
const target = LEAD_STATUS[res.tapIndex];
if (!target || target.value === item.status) return;
this.doSetStatus(item, index, target.value);
}
});
},
doSetStatus(item, index, status) {
this.$cardApi.leadSetStatus({
data: { id: item.id, status: status },
success: res => {
if (res.code == 0) {
// 就地更新, 不整页刷新
this.list[index].status = status;
this.list[index].status_text = LEAD_STATUS.find(s => s.value === status).label;
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 });
},
copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
}
}
};
</script>
<style lang="scss" scoped>
.admin-leads {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
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: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.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;
}
}
.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);
}
}
}
}
.lead-list {
padding: 20rpx 24rpx 0;
}
.lead-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.lead-top {
display: flex;
align-items: center;
.lead-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.lead-status {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.st-0 {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.st-1 {
color: #e6a23c;
background-color: #fdf3e6;
}
&.st-2 {
color: #1daa39;
background-color: #e8f8ea;
}
&.st-3 {
color: #999;
background-color: #f2f2f2;
}
}
}
.lead-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.lead-note {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.lead-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
.meta-card {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.lead-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 30rpx;
margin-left: 20rpx;
&.copy {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

View File

@@ -0,0 +1,409 @@
<template>
<view class="admin-manage">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo, 禁裸 uni.switchTab) -->
<view class="page-switch">
<view class="switch-item active" @tap="goSelf">名片管理</view>
<view class="switch-item" @tap="goContacts">人脉管理</view>
<view class="switch-item" @tap="goLeads">潜在客户</view>
</view>
<!-- 功能预告: 查重合并/批量制作/导出等依赖后台接口, 就绪后开放 -->
<view class="notice-bar">
<text class="notice-icon">i</text>
<text class="notice-text">查重合并、批量制作、导出 CSV/vCard 等功能即将上线</text>
</view>
<!-- 搜索区 -->
<view class="search-bar">
<view class="search-input-wrap">
<input class="search-input" confirm-type="search" :value="keyword"
placeholder="搜索姓名 / 手机 / 公司" @confirm="onSearch" @input="onKeywordInput" />
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
<text class="iconfont icon-sousuo3" @tap="onSearch"></text>
</view>
</view>
<!-- 名片列表 -->
<view class="card-list">
<view v-for="(item, index) in list" :key="item.card_id || index" class="card-item"
@tap="goDetail(item)">
<view class="card-avatar">
<image v-if="item.avatar" :src="$util.img(item.avatar)" class="avatar-img" mode="aspectFill" />
<text v-else>{{ avatarText(item.name) }}</text>
</view>
<view class="card-info">
<view class="card-name line1">{{ item.name || '未命名名片' }}</view>
<view class="card-sub line1" v-if="item.job_title || item.company">
{{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
</view>
<view class="card-mobile" v-if="item.mobile">📱 {{ item.mobile }}</view>
</view>
<view class="card-side">
<text v-if="sourceLabel(item)" class="source-tag">{{ sourceLabel(item) }}</text>
<view class="card-actions" @tap.stop="">
<text class="act-btn" @tap="goEdit(item)">编辑</text>
<text class="act-btn primary" @tap="goDetail(item)">详情</text>
</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="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 名片管理(基础版)
*
* 范围: 本轮仅开放现有接口可支撑的能力——搜索 / 详情 / 编辑。
* 列表/详情/编辑复用 card/lists + 既有 /pages_tool/card/detail、edit 页
* (路由参数 card_id, 字符串主键)。
* 查重合并 / 批量制作 / 新建数字名片 / 导出 CSV/vCard / 标签·状态·类型·跟进·收藏
* 筛选依赖后台未就绪接口, 后台文档(CARD_API_REFERENCE.md)到位后在本页扩展,
* 端点届时收口进 common/js/card-api.js, 页面只改 this.$cardApi 调用。
*/
import { CARD_SOURCE_TYPES } from '@/common/js/card-api.js';
export default {
data() {
return {
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
emptyText: '暂无名片刻'
};
},
onLoad() {
this.refresh();
},
onShow() {
// 从编辑页返回时刷新(编辑成功后数据已变)
if (this._loadedOnce) this.refresh();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
},
// 来源标签: 优先后端译文(source_type_text), 缺失时按字典兜底(与后端 source_type 对齐)
sourceLabel(item) {
if (!item) return '';
if (item.source_type_text) return item.source_type_text;
const hit = CARD_SOURCE_TYPES.find(s => s.value === item.source_type);
return hit ? hit.label : '';
},
onKeywordInput(e) {
this.keyword = e.detail.value;
},
onSearch() {
this.refresh();
},
clearKeyword() {
this.keyword = '';
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
// page_size 上限 100(后端硬限制), 这里 20 足够
this.$cardApi.cardLists({
data: {
page: pageNo,
page_size: this.pageSize,
keyword: this.keyword.trim()
},
success: res => {
this.isLoading = false;
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);
this.emptyText = this.keyword.trim() ? '未找到匹配的名片' : '暂无名片刻';
} else {
// 后端 code!=0 的 message 可直接 toast
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
/* ---------- 导航 ---------- */
// 名片路由参数一律 card_id(字符串), encodeURIComponent 幂等
goDetail(item) {
if (!item || !item.card_id) return;
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.card_id)
});
},
goEdit(item) {
if (!item || !item.card_id) return;
uni.navigateTo({
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(item.card_id)
});
},
// 平级入口: 鸿蒙元服务上 uni.switchTab 静默失败, 必须走 $util.redirectTo
goContacts() {
this.$util.redirectTo('/pages_tool/card/admin/contacts');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/admin/leads');
},
goSelf() {
// 当前页, 无需跳转
}
}
};
</script>
<style lang="scss" scoped>
.admin-manage {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* 顶部平级切换(三页互链) */
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
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: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
/* 功能预告条 */
.notice-bar {
display: flex;
align-items: center;
margin: 20rpx 24rpx 0;
padding: 16rpx 20rpx;
background-color: var(--main-color-shallow, #e8f1fb);
border-radius: 12rpx;
.notice-icon {
width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
border-radius: 50%;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 22rpx;
flex-shrink: 0;
margin-right: 12rpx;
}
.notice-text {
font-size: 24rpx;
color: var(--main-color, #0054a5);
line-height: 1.4;
}
}
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 24rpx 0;
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 36rpx;
padding: 0 24rpx;
height: 72rpx;
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333;
}
.search-clear {
padding: 0 12rpx;
color: #bbb;
font-size: 28rpx;
}
.iconfont {
color: #999;
font-size: 32rpx;
padding-left: 12rpx;
}
}
}
.card-list {
padding: 20rpx 24rpx 0;
}
.card-item {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 16rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
&:active {
background-color: #f0f2f5;
}
.card-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: var(--main-color-shallow, #e8f1fb);
color: var(--main-color, #0054a5);
font-size: 36rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
overflow: hidden;
.avatar-img {
width: 100%;
height: 100%;
}
}
.card-info {
flex: 1;
min-width: 0;
.card-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.card-sub {
font-size: 26rpx;
color: #666;
margin-top: 8rpx;
}
.card-mobile {
font-size: 26rpx;
color: #999;
margin-top: 8rpx;
}
}
.card-side {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
margin-left: 16rpx;
.source-tag {
font-size: 20rpx;
color: #999;
background-color: #f2f2f2;
border-radius: 20rpx;
padding: 4rpx 14rpx;
margin-bottom: 12rpx;
}
.card-actions {
display: flex;
.act-btn {
font-size: 24rpx;
color: #666;
border: 1rpx solid #ddd;
border-radius: 28rpx;
padding: 8rpx 22rpx;
& + .act-btn {
margin-left: 12rpx;
}
&.primary {
color: var(--main-color, #0054a5);
border-color: var(--main-color, #0054a5);
}
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<view class="ex-page">
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
<view class="ex-tabs">
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
<!-- 交换请求列表 -->
<view class="ex-list">
<view v-for="item in shownList" :key="item.id" class="ex-item">
<view class="ex-top">
<view class="ex-avatar-wrap" @tap.stop="viewCard(item)">
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
<view class="ex-avatar-tag">示例交换</view>
</view>
<view class="ex-info">
<view class="ex-name-row">
<text class="ex-name">{{ item.name }}</text>
<text class="ex-badge iconfont icon-dui"></text>
<text class="ex-badge iconfont icon-dui"></text>
</view>
<text class="ex-job">{{ item.job_title }}</text>
<view class="ex-company-row">
<text class="ex-tag">示例交换</text>
<text class="ex-company">{{ item.company }}</text>
</view>
</view>
<view v-if="item.status === 'pending'" class="ex-agree" @tap.stop="onAgree(item)">同意</view>
<text v-else class="ex-agreed">已同意</text>
</view>
<view class="ex-reason">对方发起了名片交换请求</view>
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
</view>
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
// 名片交换请求列表
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
export default {
data() {
return {
current: 'pending',
cardId: '',
list: [],
page: 1,
pageCount: 1,
loading: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadList();
},
computed: {
tabs() {
const pending = this.list.filter(i => i.status === 'pending').length;
return [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待处理(' + pending + ')' },
{ value: 'agreed', label: '已同意' }
];
},
shownList() {
if (this.current === 'all') return this.list;
return this.list.filter(item => item.status === this.current);
},
emptyText() {
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
}
},
methods: {
switchTab(value) {
if (this.current === value) return;
this.current = value;
},
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
loadList() {
this.loading = true;
this.$cardApi.exchangeLists({
data: { page: this.page, page_size: 20 },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.list = (res.data.list || []).map(it => ({
id: it.id,
from_card_id: it.from_card_id,
name: it.name,
avatar: it.avatar,
job_title: it.job_title,
company: it.company,
create_time_txt: it.create_time_txt,
status: it.status === 0 ? 'pending' : 'agreed'
}));
this.pageCount = res.data.page_count || 1;
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.loading = false;
}
});
},
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
viewCard(item) {
if (!item || !item.from_card_id) return;
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
});
},
onAgree(item) {
this.$cardApi.exchangeAgree({
data: { id: item.id },
success: res => {
if (res.code == 0) {
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
const d = res.data || {};
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
uni.showToast({ title: txt, icon: 'none' });
this.loadList();
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
},
onIgnore(item) {
this.$cardApi.exchangeIgnore({
data: { id: item.id },
success: res => {
if (res.code == 0) {
uni.showToast({ title: '已忽略', icon: 'none' });
this.loadList();
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
}
}
};
</script>
<style lang="scss" scoped>
.ex-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
.ex-tabs {
display: flex;
padding: 20rpx 24rpx;
.ex-tab {
padding: 12rpx 28rpx;
margin-right: 16rpx;
border-radius: 32rpx;
background-color: #ffffff;
font-size: 26rpx;
color: #666666;
&.active {
background-color: #e8f3ff;
color: #2aa5ff;
font-weight: 600;
}
}
}
.ex-list {
padding: 0 24rpx;
}
.ex-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.ex-top {
display: flex;
align-items: center;
}
.ex-avatar-wrap {
position: relative;
width: 100rpx;
height: 100rpx;
flex-shrink: 0;
}
.ex-avatar {
width: 100%;
height: 100%;
border-radius: 16rpx;
background-color: #2aa5ff;
}
.ex-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
}
.ex-avatar-tag {
position: absolute;
left: 50%;
bottom: -10rpx;
transform: translateX(-50%);
padding: 2rpx 10rpx;
border-radius: 6rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 16rpx;
line-height: 1.5;
white-space: nowrap;
}
.ex-info {
flex: 1;
min-width: 0;
margin: 0 16rpx 0 20rpx;
}
.ex-name-row {
display: flex;
align-items: center;
}
.ex-name {
font-size: 30rpx;
font-weight: 600;
color: #333333;
}
.ex-badge {
margin-left: 6rpx;
font-size: 26rpx;
color: #2aa5ff;
}
.ex-job {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #7b8794;
}
.ex-company-row {
display: flex;
align-items: center;
margin-top: 10rpx;
}
.ex-tag {
flex-shrink: 0;
padding: 2rpx 12rpx;
margin-right: 10rpx;
border-radius: 6rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 20rpx;
line-height: 1.5;
}
.ex-company {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #333333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ex-agree {
flex-shrink: 0;
padding: 12rpx 34rpx;
border-radius: 34rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 26rpx;
}
.ex-agreed {
flex-shrink: 0;
font-size: 24rpx;
color: #9aa4b0;
}
.ex-reason {
margin-top: 20rpx;
padding: 20rpx;
border-radius: 10rpx;
background-color: #f5f6f8;
font-size: 24rpx;
color: #666666;
line-height: 1.5;
}
.ex-time {
margin-top: 14rpx;
font-size: 22rpx;
color: #9aa4b0;
}
</style>

609
pages_tool/card/style.vue Normal file
View File

@@ -0,0 +1,609 @@
<template>
<view class="cs-page">
<!-- 顶部段控: 名片模板 / 分享封面 -->
<view class="cs-seg">
<view class="cs-seg-item" :class="{ active: seg === 'tpl' }" @tap="switchSeg('tpl')">名片模板</view>
<view class="cs-seg-item" :class="{ active: seg === 'cover' }" @tap="switchSeg('cover')">分享封面</view>
</view>
<!-- ============ 名片模板 ============ -->
<block v-if="seg === 'tpl'">
<!-- 名片预览: 姓名/资料内容一律保持原样, 只改布局与配色
- 卡面底色跟随「选择背景」
- 选中的模板决定版式(pv-classic = 第一个模板, 参考图的纸感居中版) -->
<view class="cs-preview" :class="'pv-' + previewTplType" :style="previewStyle">
<text class="cs-name">{{ card.name }}</text>
<text class="cs-job">{{ card.job_title }}</text>
<text class="cs-company">{{ card.company }}</text>
<view class="cs-divider"></view>
<view class="cs-row" v-if="card.mobile">
<text class="cs-row-icon iconfont icon-dianhua"></text>
<text class="cs-row-text">{{ card.mobile }}</text>
</view>
<view class="cs-row" v-if="card.email">
<text class="cs-row-icon">@</text>
<text class="cs-row-text">{{ card.email }}</text>
</view>
<view class="cs-row" v-if="card.address">
<text class="cs-row-icon iconfont icon-dizhi"></text>
<text class="cs-row-text">{{ card.address }}</text>
</view>
</view>
<!-- 解锁更多 -->
<view class="cs-unlock" @tap="onUnlock">
<text class="cs-unlock-icon iconfont icon-huiyuan"></text>
<text class="cs-unlock-text">解锁更多新模板、个性化logo,自由排版</text>
<text class="cs-unlock-arrow iconfont icon-right"></text>
</view>
<!-- 选择模板 -->
<view class="cs-section">
<text class="cs-title">选择模板</text>
<scroll-view scroll-x class="cs-scroll">
<view class="cs-thumbs">
<view v-for="tpl in templates" :key="tpl.id" class="cs-thumb"
:class="'tpl-' + tpl.type" @tap="selectTpl(tpl.id)">
<view class="tpl-inner">
<view class="tpl-bar w70"></view>
<view class="tpl-bar w45"></view>
<view class="tpl-bar w55"></view>
</view>
<view v-if="selectedTpl === tpl.id" class="cs-check">
<view class="cs-check-mark"></view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 选择背景 -->
<view class="cs-section">
<text class="cs-title">选择背景</text>
<scroll-view scroll-x class="cs-scroll">
<view class="cs-thumbs">
<view v-for="bg in backgrounds" :key="bg.id" class="cs-thumb"
:style="'background-color:' + bg.color + ';' + (bg.border ? 'border:2rpx solid ' + bg.border + ';' : '')"
@tap="selectBg(bg.id)">
<view v-if="selectedBg === bg.id" class="cs-check">
<view class="cs-check-mark"></view>
</view>
</view>
</view>
</scroll-view>
</view>
</block>
<!-- ============ 分享封面 ============ -->
<block v-else>
<view class="cs-cover-preview">
<view class="cs-cover-inner">
<text class="cs-cover-name">{{ card.name }}</text>
<text class="cs-cover-job">{{ card.job_title }}</text>
<text class="cs-cover-company">{{ card.company }}</text>
</view>
</view>
<view class="cs-section">
<text class="cs-title">选择封面</text>
<scroll-view scroll-x class="cs-scroll">
<view class="cs-thumbs">
<view v-for="cover in covers" :key="cover.id" class="cs-thumb"
:style="'background-color:' + cover.color" @tap="selectCover(cover.id)">
<view v-if="selectedCover === cover.id" class="cs-check">
<view class="cs-check-mark"></view>
</view>
</view>
</view>
</scroll-view>
</view>
</block>
<!-- 底部保存 -->
<view class="cs-footer">
<view class="cs-save" @tap="onSave">保存</view>
</view>
</view>
</template>
<script>
// 名片样式: 本页只改样式(模板/背景), 名片资料一律取 card/detail 的真实数据回填, 不做任何改动。
// 「保存」待后端提供样式保存端点后再接入(card/style/save 之类)。
export default {
data() {
return {
seg: 'tpl',
cardId: '',
// 名片资料(真实数据, 由 loadCard 回填)
card: {
name: '',
job_title: '',
company: '',
mobile: '',
email: '',
address: ''
},
// 模板缩略图示意: type 决定缩略图配色(classic = 参考图的纸感米色块)
templates: [
{ id: 't1', type: 'classic' },
{ id: 't2', type: 'photo' },
{ id: 't3', type: 'lines' },
{ id: 't4', type: 'dark' },
{ id: 't5', type: 'navy' }
],
// 默认选中第一个(参考图)
selectedTpl: 't1',
backgrounds: [
// 第一个背景 = 参考图样式(纸感米色), 选中后改变卡面底色
{ id: 'b1', color: '#ece5db' },
{ id: 'b2', color: '#c9ced6' },
{ id: 'b3', color: '#243b5e' },
{ id: 'b4', color: '#8b95a3' },
{ id: 'b5', color: '#ffffff', border: '#2aa5ff' }
],
selectedBg: 'b1',
covers: [
{ id: 'c1', color: '#5a6473' },
{ id: 'c2', color: '#c9ced6' },
{ id: 'c3', color: '#243b5e' },
{ id: 'c4', color: '#8b95a3' },
{ id: 'c5', color: '#ffffff' }
],
selectedCover: 'c1'
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadCard();
},
computed: {
// 卡面底色跟随「选择背景」; 姓名/资料内容始终原样, 不随模板改变
previewStyle() {
const bg = this.backgrounds.find(item => item.id === this.selectedBg);
return bg ? 'background-color:' + bg.color + ';' : '';
},
// 当前选中模板的类型(classic/photo/lines/dark/navy), 决定预览的版式类 pv-*
previewTplType() {
const tpl = this.templates.find(item => item.id === this.selectedTpl);
return tpl ? tpl.type : 'classic';
}
},
methods: {
// 拉取名片真实资料回填预览(只读展示, 本页不改资料)
loadCard() {
if (!this.cardId) return;
this.$cardApi.cardDetail({
data: { card_id: this.cardId },
success: res => {
if (res.code == 0 && res.data && res.data.relation) {
this.card = this.pickCard(res.data.relation);
}
},
fail: () => {
uni.showToast({ title: '名片资料加载失败', icon: 'none' });
}
});
},
// relation → 预览字段(口径与详情页 fieldRows 一致)
pickCard(r) {
return {
name: r.name || '',
job_title: r.job_title || '',
company: r.company || '',
mobile: r.mobile || r.phone || '',
email: r.email || '',
address: [r.country, r.province, r.city, r.address].filter(Boolean).join(' ')
};
},
switchSeg(value) {
if (this.seg === value) return;
this.seg = value;
},
selectTpl(id) {
this.selectedTpl = id;
},
selectBg(id) {
this.selectedBg = id;
},
selectCover(id) {
this.selectedCover = id;
},
onUnlock() {
uni.showToast({ title: '解锁更多模板(接口待接入)', icon: 'none' });
},
onSave() {
// 接口待接入: 先给反馈, 保证交互可验证
uni.showToast({ title: '已保存(接口待接入)', icon: 'none' });
}
}
};
</script>
<style lang="scss" scoped>
.cs-page {
min-height: 100vh;
background-color: #f6f7f9;
// 底部给固定保存条留位
padding: 0 24rpx 200rpx;
box-sizing: border-box;
}
// 段控: 灰底轨道 + 白色选中胶囊
.cs-seg {
display: flex;
margin-top: 20rpx;
padding: 6rpx;
background-color: #eceef1;
border-radius: 16rpx;
.cs-seg-item {
flex: 1;
height: 68rpx;
line-height: 68rpx;
text-align: center;
font-size: 28rpx;
color: #8a94a6;
border-radius: 12rpx;
&.active {
background-color: #ffffff;
color: #333333;
font-weight: 600;
}
}
}
// 名片预览: 白底 + 蓝色粗描边(姓名/资料原样, 仅底色可被背景覆盖)
.cs-preview {
margin-top: 24rpx;
padding: 32rpx 28rpx;
background-color: #ffffff;
border: 6rpx solid #2aa5ff;
border-radius: 12rpx;
box-sizing: border-box;
.cs-name {
display: block;
font-size: 44rpx;
font-weight: 600;
color: #333333;
line-height: 1.2;
}
.cs-job {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: #555555;
}
.cs-company {
display: block;
margin-top: 6rpx;
font-size: 26rpx;
color: #555555;
}
.cs-divider {
height: 2rpx;
margin: 26rpx 0;
background-color: #eef0f3;
}
.cs-row {
display: flex;
align-items: center;
& + .cs-row {
margin-top: 14rpx;
}
}
.cs-row-icon {
width: 34rpx;
flex-shrink: 0;
margin-right: 12rpx;
font-size: 26rpx;
line-height: 1.2;
color: #4a5568;
text-align: center;
}
.cs-row-text {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #555555;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// ===== 第一个模板「纸感版」(参考图) =====
// 只改版式与配色: 资料内容(姓名/职位/公司/手机号...)一律原样
// 参考图配色: 分割线及以上 = 古铜金; 分割线以下 = 暖灰褐
&.pv-classic {
// 参考图是无描边的纸卡, 只用柔和投影; 保留边框宽度避免尺寸跳动
border-color: transparent;
box-shadow: 0 8rpx 24rpx rgba(122, 104, 78, 0.16);
// 版式: 资料全部居中
.cs-name,
.cs-job,
.cs-company,
.cs-row-text {
text-align: center;
}
// 分割线以上: 姓名最大最粗, 职位/公司同色次之
.cs-name {
font-size: 42rpx;
font-weight: 700;
color: #b5813c;
}
.cs-job {
margin-top: 14rpx;
font-size: 26rpx;
font-weight: 600;
color: #b5813c;
}
.cs-company {
margin-top: 4rpx;
font-size: 26rpx;
font-weight: 600;
color: #b5813c;
}
// 分割线: 参考图是一条很淡的浅茶色细线
.cs-divider {
margin: 5rpx 0;
background-color: rgba(181, 129, 60, 0.3);
}
// 分割线以下: 居中纯文字(参考图无行首图标), 暖灰褐色, 间距紧凑
.cs-row {
justify-content: center;
& + .cs-row {
margin-top: -10rpx;
}
.cs-row-icon {
display: none;
}
.cs-row-text {
flex: none;
font-size: 30rpx;
color: #000000;
}
}
}
}
// 解锁更多新模板横幅
.cs-unlock {
display: flex;
align-items: center;
margin-top: 24rpx;
padding: 24rpx;
background-color: #eaf4ff;
border-radius: 12rpx;
.cs-unlock-icon {
flex-shrink: 0;
margin-right: 14rpx;
font-size: 32rpx;
color: #2aa5ff;
}
.cs-unlock-text {
flex: 1;
min-width: 0;
font-size: 24rpx;
color: #333333;
}
.cs-unlock-arrow {
flex-shrink: 0;
margin-left: 10rpx;
font-size: 26rpx;
color: #9aa4b0;
}
}
// 分区标题 + 横向滚动缩略图
.cs-section {
margin-top: 32rpx;
.cs-title {
display: block;
margin-bottom: 20rpx;
font-size: 28rpx;
font-weight: 600;
color: #333333;
}
}
.cs-scroll {
white-space: nowrap;
.cs-thumbs {
display: inline-flex;
}
}
.cs-thumb {
position: relative;
width: 152rpx;
height: 100rpx;
flex-shrink: 0;
margin-right: 20rpx;
border-radius: 10rpx;
box-sizing: border-box;
overflow: hidden;
background-color: #ffffff;
border: 2rpx solid #e8ebee;
// 模板内部线条示意
.tpl-inner {
padding: 18rpx 16rpx;
.tpl-bar {
height: 8rpx;
border-radius: 4rpx;
background-color: #dfe3e8;
& + .tpl-bar {
margin-top: 10rpx;
}
}
.w70 {
width: 70%;
}
.w45 {
width: 45%;
}
.w55 {
width: 55%;
}
}
// 深色模板示意
&.tpl-dark,
&.tpl-navy {
border-color: transparent;
.tpl-bar {
background-color: rgba(255, 255, 255, 0.45);
}
}
&.tpl-dark {
background-color: #4a5260;
}
&.tpl-navy {
background-color: #1f2a44;
}
// 模板一(参考图): 纸感米色纯色块
&.tpl-classic {
border-color: transparent;
background-color: #ece5db;
.tpl-inner {
display: none;
}
}
// 带人像的模板示意
&.tpl-photo {
.tpl-inner {
padding: 0;
.tpl-bar {
display: none;
}
}
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 56rpx;
height: 100%;
background-color: #6b7280;
}
}
}
// 选中角标(参考图): 白底蓝勾圆, 居中压在缩略图上
.cs-check {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
.cs-check-mark {
width: 14rpx;
height: 7rpx;
margin-top: -4rpx;
border-left: 3rpx solid #2aa5ff;
border-bottom: 3rpx solid #2aa5ff;
transform: rotate(-45deg);
}
}
// 分享封面预览
.cs-cover-preview {
margin-top: 24rpx;
padding: 32rpx;
height: 320rpx;
background-color: #ffffff;
border: 6rpx solid #2aa5ff;
border-radius: 12rpx;
box-sizing: border-box;
.cs-cover-inner {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
.cs-cover-name {
font-size: 40rpx;
font-weight: 600;
color: #333333;
}
.cs-cover-job {
margin-top: 10rpx;
font-size: 26rpx;
color: #555555;
}
.cs-cover-company {
margin-top: 6rpx;
font-size: 26rpx;
color: #555555;
}
}
// 底部固定保存条
.cs-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
.cs-save {
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 44rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 32rpx;
}
}
</style>

186
tests/card-admin.test.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* 名片云 · 商家端(admin)页面测试
*
* 背景:
* 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists),
* 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口):
* - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页)
* - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除)
* - admin/leads 潜在客户(状态筛选/列表/更新跟进状态)
* 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。
*
* 跑法:
* node --test tests/card-admin.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin');
const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8');
const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8');
const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8');
const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8');
// pages.json 含 uni-app 合法 // 注释, 剥后解析
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
const pagesJson = JSON.parse(stripped);
const ADMIN_PAGES = {
'card/admin/manage': '名片管理',
'card/admin/contacts': '人脉管理',
'card/admin/leads': '潜在客户'
};
/** 取 pages_tool 分包已注册路径 */
function toolPaths() {
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
assert.ok(sub, 'pages_tool 分包应存在');
return (sub.pages || []).map(p => p.path);
}
// =============================================================
// 1. 页面注册
// =============================================================
test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => {
const paths = toolPaths();
for (const p of Object.keys(ADMIN_PAGES)) {
assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`);
}
});
test('注册: 商家端页面标题正确且不在 TabBar', () => {
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
for (const [p, title] of Object.entries(ADMIN_PAGES)) {
const page = (sub.pages || []).find(x => x.path === p);
assert.ok(page, `${p} 应存在`);
assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`);
// 名片云全部页面都不是 tabBar 根页
assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`);
}
const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath);
for (const p of Object.keys(ADMIN_PAGES)) {
assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`);
}
});
test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => {
const map = {
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads'
};
for (const [key, val] of Object.entries(map)) {
const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`));
assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`);
assert.equal(m[1], val);
assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)),
`${val} 未在 pages.json 注册`);
}
// 防回归: TAB_BAR_KEYS 不得被误加 admin 路径
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径');
});
// =============================================================
// 2. API 收口(与 card-api 测试同一契约, 覆盖新页面)
// =============================================================
test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => {
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
const src = readAdmin(f);
assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`);
assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`);
}
});
test('收口: 商家端页面从 card-api.js 导入共享字典', () => {
assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')),
'contacts.vue 应导入 CONTACT_SOURCES');
assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')),
'leads.vue 应导入 LEAD_STATUS');
});
test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => {
const src = readAdmin('leads.vue');
// ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值
assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典');
assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值');
});
// =============================================================
// 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败)
// =============================================================
test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => {
const cross = {
'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']],
'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']],
'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']]
};
for (const [f, pairs] of Object.entries(cross)) {
const src = readAdmin(f);
for (const [method, target] of pairs) {
const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`));
assert.ok(m, `${f} 应定义 ${method}`);
assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`);
assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]),
`${f}#${method} 目标应为 ${target}`);
}
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`);
}
});
test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => {
const src = readAdmin('manage.vue');
// 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII)
assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
'goDetail 应 encodeURIComponent(card_id)');
assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
'goEdit 应 encodeURIComponent(card_id)');
});
// =============================================================
// 4. 编辑页复用回跳(from=admin)
// =============================================================
test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => {
const src = readCard('contact-edit.vue');
assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src),
'onLoad 应解析 from=admin');
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
assert.ok(m, '应能提取 backToList 方法体');
assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo');
assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支');
assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"),
'商家端应回 /pages_tool/card/admin/contacts');
assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
'员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)');
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
});
test('复用: admin/contacts 的编辑入口带 from=admin', () => {
const src = readAdmin('contacts.vue');
assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id=');
assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin');
});
// =============================================================
// 5. 条件编译配对平衡(新文件同样纳入)
// =============================================================
test('ifdef 配对平衡: 商家端 3 页', () => {
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
const src = readAdmin(f);
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
const close = (src.match(/#endif/g) || []).length;
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}`);
}
});

312
tests/card-api.test.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md
*
* 背景:
* 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求:
* "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面
* 12 处直连, 路径不可变)"。本测试把该契约固化为断言:
*
* 覆盖:
* 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差)
* 2. card-api.js 不得出现文档之外的 /card/api/ 路径
* 3. 每个端点都有对应 helper 方法(收口不是只建常量表)
* 4. 7 个业务页面零字面量直连(全部走 this.$cardApi)
* 5. main.js 注册 $cardApi(Vue.prototype)
* 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES
* 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、
* recognizeBatch), 其余走 sendRequest
* 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里
* (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃)
*
* 跑法:
* node --test tests/card-api.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const CARD_DIR = path.join(ROOT, 'pages_tool/card');
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8');
const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8');
// 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个)
const DOC_URLS = [
// 名片夹 /card/api/card/*
'/card/api/card/lists',
'/card/api/card/detail',
'/card/api/card/update',
'/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准
'/card/api/card/claim',
'/card/api/card/recognize',
'/card/api/card/retry',
'/card/api/card/recognizeBatch',
// 人脉库 /card/api/contact/*
'/card/api/contact/lists',
'/card/api/contact/add',
'/card/api/contact/info',
'/card/api/contact/update',
'/card/api/contact/delete',
'/card/api/contact/toLead',
// 潜在客户 /card/api/lead/*
'/card/api/lead/lists',
'/card/api/lead/setStatus',
// 交换名片 /card/api/exchange/*
'/card/api/exchange/lists',
'/card/api/exchange/agree',
'/card/api/exchange/ignore'
];
// helper 方法名 ↔ URL 常量名(收口层必须两端齐全)
const HELPER_URL_PAIRS = [
['cardLists', 'CARD_LISTS'],
['cardDetail', 'CARD_DETAIL'],
['cardUpdate', 'CARD_UPDATE'],
['cardCreate', 'CARD_CREATE'],
['cardClaim', 'CARD_CLAIM'],
['cardRecognize', 'CARD_RECOGNIZE'],
['cardRetry', 'CARD_RETRY'],
['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'],
['contactLists', 'CONTACT_LISTS'],
['contactAdd', 'CONTACT_ADD'],
['contactInfo', 'CONTACT_INFO'],
['contactUpdate', 'CONTACT_UPDATE'],
['contactDelete', 'CONTACT_DELETE'],
['contactToLead', 'CONTACT_TO_LEAD'],
['leadLists', 'LEAD_LISTS'],
['leadSetStatus', 'LEAD_SET_STATUS'],
['exchangeLists', 'EXCHANGE_LISTS'],
['exchangeAgree', 'EXCHANGE_AGREE'],
['exchangeIgnore', 'EXCHANGE_IGNORE']
];
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore'];
// =============================================================
// 1. 端点与后端文档逐一比对
// =============================================================
test('收口: card-api.js 含文档全部 18 个端点', () => {
for (const u of DOC_URLS) {
assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`);
}
});
test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => {
const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || [];
const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u));
assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`);
assert.equal(found.length, DOC_URLS.length,
`端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`);
});
// =============================================================
// 2. 每个端点都有 helper(收口不是只建常量表)
// =============================================================
test('收口: 15 个端点均有对应 helper 方法', () => {
for (const [helper, urlConst] of HELPER_URL_PAIRS) {
assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC),
`缺少 helper ${helper}(p)`);
assert.ok(new RegExp(`url:\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC),
`helper ${helper} 未绑定 ${urlConst}`);
}
});
test('收口: 嵌套入参 helper 走 sendJson 通道', () => {
for (const helper of JSON_BODY_HELPERS) {
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
assert.ok(m, `应能提取 ${helper} 方法体`);
assert.ok(/Http\.sendJson\(/.test(m[1]),
`${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`);
}
});
test('收口: 普通入参 helper 走 sendRequest 通道', () => {
const formHelpers = HELPER_URL_PAIRS
.map(p => p[0])
.filter(h => !JSON_BODY_HELPERS.includes(h));
for (const helper of formHelpers) {
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
assert.ok(m, `应能提取 ${helper} 方法体`);
assert.ok(/Http\.sendRequest\(/.test(m[1]),
`${helper} 应走 Http.sendRequest`);
assert.ok(!/Http\.sendJson\(/.test(m[1]),
`${helper} 不应走 sendJson(无嵌套入参)`);
}
});
// =============================================================
// 3. 页面零字面量直连
// =============================================================
test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => {
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue'];
for (const f of files) {
const src = read(f);
assert.ok(!/['"]\/card\/api\//.test(src),
`${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`);
}
});
test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => {
// share.vue 是 web-view 中转页, 无业务接口调用, 不在此列
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue'];
for (const f of files) {
const src = read(f);
assert.ok(/this\.\$cardApi\./.test(src),
`${f} 未使用 this.$cardApi`);
}
});
test('收口: main.js 注册 Vue.prototype.$cardApi', () => {
assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC),
'main.js 应 import card-api.js');
assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC),
'main.js 应注册 Vue.prototype.$cardApi');
});
// =============================================================
// 4. 字典与后端白名单一致
// =============================================================
test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => {
const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]);
// 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单
const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou',
'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other'];
assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步');
});
test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => {
const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/);
assert.ok(block, 'CARD_FIELDS 应存在');
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS');
assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS');
// 其余标量不受影响
for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email',
'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) {
assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`);
}
});
test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => {
const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8');
assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)');
assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)');
assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc),
'edit.vue 保存时不得再提交 telegram/messenger 标量');
assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc),
'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS');
// 模板里不得再有 Telegram / Messenger 独立输入行
assert.ok(!/<view class="row-label">Telegram<\/view>/.test(editSrc),
'模板不得再有 Telegram 独立输入行');
assert.ok(!/<view class="row-label">Messenger<\/view>/.test(editSrc),
'模板不得再有 Messenger 独立输入行');
});
test('软下线: detail.vue 不再单独渲染 telegram/messenger 行(旧值已并入 socials_list)', () => {
const detailSrc = fs.readFileSync(path.join(CARD_DIR, 'detail.vue'), 'utf8');
assert.ok(!/push\('Telegram'/.test(detailSrc), 'detail.vue 不得再单独渲染 Telegram 行');
assert.ok(!/push\('Messenger'/.test(detailSrc), 'detail.vue 不得再单独渲染 Messenger 行');
// 社交账号统一走 socials_list(后端已合并旧值)
assert.ok(/r\.socials_list/.test(detailSrc), 'detail.vue 应继续消费 socials_list');
});
test('字典: LEAD_STATUS 与后端 Lead::STATUSES 一致(0-3)', () => {
const block = API_SRC.match(/export const LEAD_STATUS = \[([\s\S]*?)\];/);
assert.ok(block, 'LEAD_STATUS 应存在');
const values = [...block[1].matchAll(/value:\s*(\d)/g)].map(m => Number(m[1]));
assert.deepEqual(values, [0, 1, 2, 3]);
// setStatus 只接受 0/1/2/3, 其它值后端报「参数不合法」
assert.ok(/status\?\(0-3\)/.test(API_SRC), 'leadLists 文档注释应标注 status 0-3');
});
test('字典: CONTACT_SOURCES 与后端 Contact::SOURCES 一致', () => {
const block = API_SRC.match(/export const CONTACT_SOURCES = \[([\s\S]*?)\];/);
assert.ok(block, 'CONTACT_SOURCES 应存在');
const values = [...block[1].matchAll(/value:\s*'([a-z_]+)'/g)].map(m => m[1]);
assert.deepEqual(values, ['manual', 'lead_in', 'scan', 'exchange']);
});
test('字典: CONTACT_FIELDS 与后端 Contact::EDITABLE_FIELDS 一致', () => {
const block = API_SRC.match(/export const CONTACT_FIELDS = \[([\s\S]*?)\];/);
assert.ok(block, 'CONTACT_FIELDS 应存在');
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
assert.deepEqual(values,
['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status']);
});
// =============================================================
// 5. 文档关键语义在收口层有注释固化(防后人改错)
// =============================================================
test('语义: card/update 的 normalize 规则写在收口层注释里', () => {
// 键存在才写 / 空串=清空 / 键缺席=不擦除(历史事故定稿)
assert.ok(/键存在才写/.test(API_SRC) && /空串=清空/.test(API_SRC) && /键缺席=不擦除/.test(API_SRC),
'cardUpdate 注释必须固化 normalize 三语义');
});
test('语义: recognize 的 deduped 提示写在收口层注释里', () => {
assert.ok(/deduped=true 表示站点内已有同手机号名片/.test(API_SRC),
'cardRecognize 注释必须提示 deduped 语义');
});
test('语义: 名片用 card_id 字符串 / 人脉线索用 id 数字的区分写在注释里', () => {
assert.ok(/card_id(字符串),人脉\/线索用 id(数字)/.test(API_SRC),
'收口层注释必须固化主键类型区分(后端 checklist 第 9 条)');
});
// =============================================================
// 6. 走 sendJson 的 helper: 页面必须把 payload 包在 data 里
// =============================================================
/**
* 提取 `$cardApi.<helper>` 调用的实参文本(括号配对)。
* 兼容两种写法: `this.$cardApi.cardUpdate({...})` 与三元分支
* `(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({...})`
* —— 后者 helper 名后紧跟的是 ')' 而不是 '(', 故先定位名字再找其后第一个 '('。
*/
function callArgs(src, helper) {
const at = src.indexOf('$cardApi.' + helper);
if (at < 0) return null;
const start = src.indexOf('(', at);
if (start < 0) return null;
let depth = 0;
for (let i = start; i < src.length; i++) {
const c = src[i];
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (depth === 0) return src.slice(start, i + 1);
}
}
return null;
}
test('入参: 走 sendJson 的 helper 必须把 payload 包在 data 里', () => {
// 历史事故(2026-10-13): edit.vue 保存时把 fields 挂在顶层传参, 而
// Http.sendJson 只 Object.assign(data, params.data)(见 common/js/http.js),
// 顶层键被整体丢弃 → 请求体里没有 fields → 后端按空 name 校验,
// 前端弹「保存失败」、Network 里看到 {code:-1, data:"请填写姓名"}。
const CALLERS = {
'edit.vue': ['cardCreate', 'cardUpdate'],
'contact-edit.vue': ['contactAdd', 'contactUpdate']
};
for (const [f, helpers] of Object.entries(CALLERS)) {
const src = read(f);
for (const h of helpers) {
const args = callArgs(src, h);
assert.ok(args, `${f} 应能定位 $cardApi.${h} 调用`);
assert.ok(/data\s*:/.test(args),
`${f} 的 $cardApi.${h} 必须传 data: {...}; 顶层挂 fields 会被 sendJson 丢弃`);
}
}
});