Compare commits

...

6 Commits

Author SHA1 Message Date
22b33c45ad chore:名片删除按钮功能正常 2026-10-20 08:48:50 +08:00
a2bf8d6c25 chore:增加名片删除按钮 2026-10-19 16:58:04 +08:00
8635f9795f chore:title改成名片 2026-10-19 16:44:02 +08:00
166450f6ce chore:浏览 2026-10-19 15:28:02 +08:00
b4509e3092 chore: 2026-10-19 15:18:30 +08:00
8fa16d9b14 chore:保留test 2026-10-19 10:12:44 +08:00
15 changed files with 917 additions and 17 deletions

498
common/js/card-api.js Normal file
View File

@@ -0,0 +1,498 @@
/**
* 名片云(card addon)API 收口层
*
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
*
* 收口要求(来自后端 checklist):
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
* 8. 分页参数 page_size 不超过 100;
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
*
* 用法(页面内 this.$cardApi):
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
*
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
*/
import Http from './http.js';
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
export const CARD_API_URL = {
// === 名片夹 /card/api/card/* ===
CARD_LISTS: '/card/api/card/lists',
CARD_DETAIL: '/card/api/card/detail',
CARD_UPDATE: '/card/api/card/update',
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
CARD_CLAIM: '/card/api/card/claim',
CARD_RECOGNIZE: '/card/api/card/recognize',
CARD_RETRY: '/card/api/card/retry',
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
// === 跟进完成态 2.11.3 /card/api/card/* ===
CARD_FOLLOW_DONE: '/card/api/card/followDone',
// === 访客轨迹 2.11.4 /card/api/card/* ===
CARD_VIEWS: '/card/api/card/views',
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
APPLY_SUBMIT: '/card/api/apply/submit',
APPLY_STATUS: '/card/api/apply/status',
// === 人脉库 /card/api/contact/* ===
CONTACT_LISTS: '/card/api/contact/lists',
CONTACT_ADD: '/card/api/contact/add',
CONTACT_INFO: '/card/api/contact/info',
CONTACT_UPDATE: '/card/api/contact/update',
CONTACT_DELETE: '/card/api/contact/delete',
CONTACT_TO_LEAD: '/card/api/contact/toLead',
// === 潜在客户 /card/api/lead/* ===
LEAD_LISTS: '/card/api/lead/lists',
LEAD_SET_STATUS: '/card/api/lead/setStatus',
// === 交换名片 /card/api/exchange/* ===
EXCHANGE_LISTS: '/card/api/exchange/lists',
EXCHANGE_AGREE: '/card/api/exchange/agree',
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
CARD_DESIGN_SAVE: '/card/api/design/save' // 应用预设/保存本卡样式
};
/**
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
* type 非法或 value 为空的条目会被后端静默丢弃。
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
*/
export const SOCIAL_TYPES = [
{ key: 'alipay', label: '支付宝' },
{ key: 'douyin', label: '抖音' },
{ key: 'wxvideo', label: '视频号' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'weibo', label: '微博' },
{ key: 'kuaishou', label: '快手' },
{ key: 'bilibili', label: '哔哩哔哩' },
{ key: 'qq', label: 'QQ' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'facebook', label: 'Facebook' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'x', label: 'X' },
{ key: 'telegram', label: 'Telegram' },
{ key: 'messenger', label: 'Messenger' },
{ key: 'other', label: '其他' }
];
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
export const RICH_FIELD_LIMITS = {
MAX_MOBILES: 5,
MAX_SOCIALS: 10,
MAX_CUSTOM: 10
};
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
export const LEAD_STATUS = [
{ value: 0, label: '新客户' },
{ value: 1, label: '已联系' },
{ value: 2, label: '已成交' },
{ value: 3, label: '无效' }
];
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
export const CONTACT_SOURCES = [
{ value: 'manual', label: '手工录入' },
{ value: 'lead_in', label: '潜在客户转入' },
{ value: 'scan', label: '扫描' },
{ value: 'exchange', label: '交换' }
];
/** 名片来源:与后端 relation.source_type 一致 */
export const CARD_SOURCE_TYPES = [
{ value: 'manual', label: '手动创建' },
{ value: 'manual_batch', label: '批量制作' },
{ value: 'scan', label: '扫描识别' },
{ value: 'import_csv', label: 'CSV 导入' },
{ value: 'import_xlsx', label: 'Excel 导入' },
{ value: 'clone', label: '克隆' },
{ value: 'api', label: '接口创建' }
];
/**
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
*/
export const CARD_FIELDS = [
'name', 'company', 'job_title', 'avatar',
'mobile', 'phone', 'email', 'website', 'wechat',
'country', 'province', 'city', 'address', 'postal_code',
'bio'
];
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
export const CONTACT_FIELDS = [
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
];
const CardApi = {
url: CARD_API_URL,
/* ================= 名片夹 ================= */
/**
* 我的名片列表
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
* 返回 data: { list, count, page_count }
*/
cardLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
},
/**
* 卡片详情
* @param {Object} p { card_id, success, fail }
* 返回 data: { relation, source, share_list }
*/
cardDetail(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
},
/**
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
* @param {Object} p { card_id, fields, success, fail }
*/
cardUpdate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
},
/**
* 新建数字名片(商家端批量制作/单张新建入口)
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
*/
cardCreate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
},
/**
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
* @param {Object} p { data: { claim_code | card_id }, success, fail }
*/
cardClaim(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
},
/**
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
* @param {Object} p { data: { card_id }, success, fail }
* 返回 data: { card_id, is_favorite: 0|1 }
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
*/
cardToggleFavorite(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
},
/**
* 删除自己的名片(软删, 2.11.91)
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
* @param {Object} p { data: { card_id }, success, fail }
*/
cardDelete(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
},
/**
* 识别建卡
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
*/
cardRecognize(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
},
/**
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
* @param {Object} p { source_id, success, fail }
*/
cardRetry(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
},
/**
* 批量识别(1~10 条,逐项独立成败)
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
* 返回 data: { items: [{ ok, message, data }], ok, failed }
*/
cardRecognizeBatch(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
},
/**
* 标记跟进完成(2.11.3 跟进闭环)
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
* 成功 data: { card_id, follow_up_done: 1 }
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
* @param {Object} p { data: { card_id }, success, fail }
*/
cardFollowDone(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
},
/**
* 访客轨迹(2.11.4 谁看了我)
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
* summary: { today_pv, today_uv, total_pv, total_uv } }
* @param {Object} p { data: {...}, success, fail }
*/
cardViews(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
},
/* ================= 人脉库 ================= */
/**
* 人脉列表
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
* 返回 data: { list, count, page_count }
*/
contactLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
},
/**
* 新增人脉
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
*/
contactAdd(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
},
/**
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
* @param {Object} p { id, success, fail }
*/
contactInfo(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
},
/**
* 更新人脉(兼容 fields 对象或顶层平铺字段)
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
*/
contactUpdate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
},
/**
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
* @param {Object} p { id, success, fail }
*/
contactDelete(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
},
/**
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
* @param {Object} p { id, success, fail }
*/
contactToLead(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
},
/* ================= 潜在客户 ================= */
/**
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
* 返回 data: { list, count, page_count }
*/
leadLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
},
/**
* 更新跟进状态(status 只能是 0/1/2/3)
* @param {Object} p { id, status, success, fail }
* 返回 data: { id, status }
*/
leadSetStatus(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
},
/* ================= 交换名片 ================= */
/**
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
* @param {Object} p { page, page_size(<=100), success, fail }
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
* create_time, create_time_txt, status }], count, page_count }
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
*/
exchangeLists(p) {
const opt = Object.assign({}, p || {});
const src = opt.data || opt;
const page = src.page || 1;
const pageSize = src.page_size || 20;
delete opt.data;
delete opt.page;
delete opt.page_size;
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
Http.sendRequest(opt);
},
/**
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
* @param {Object} p { data: { id }, success, fail }
* 返回 data: { id, status:1, receiver_created, initiator_created }
*/
exchangeAgree(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
},
/**
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
* @param {Object} p { data: { id }, success, fail }
* 返回 data: { id, status:2 }
*/
exchangeIgnore(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
},
/**
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
* @param {Object} p { fromCardId, success, fail }
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
*/
ensureExchangeContact(p) {
const self = this;
const done = p.success, fail = p.fail;
const finish = (d) => { typeof done === 'function' && done(d); };
const fromCardId = p.fromCardId;
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
this.cardDetail({
data: { card_id: fromCardId },
success: res => {
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
const r = (res.data && (res.data.relation || res.data)) || {};
const mobile = r.mobile || '';
const fields = {
name: r.name || '',
company: r.company || '',
job_title: r.job_title || '',
mobile: mobile,
source: 'exchange'
};
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
if (r.wechat) fields.wechat = r.wechat;
if (r.email) fields.email = r.email;
if (r.website) fields.website = r.website;
if (r.address) fields.address = r.address;
const add = () => self.contactAdd({
data: { source: 'exchange', fields: fields },
success: res2 => {
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
const ok = res2 && res2.code == 0;
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
},
fail: err => { typeof fail === 'function' && fail(err); }
});
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
if (mobile) {
self.contactLists({
data: { page: 1, page_size: 20, keyword: mobile },
success: res3 => {
const d3 = res3.data || {};
const list = Array.isArray(d3) ? d3 : (d3.list || []);
const hit = list.find(c => (c.mobile || '') === mobile);
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
add();
},
fail: () => add()
});
} else {
add();
}
},
fail: err => { typeof fail === 'function' && fail(err); }
});
},
/* ================= 员工建卡申请 2.11.15 ================= */
/**
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
* 入参: { data: { name, mobile } }
* @param {Object} p { data: { name, mobile }, success, fail }
*/
applySubmit(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
},
/**
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
* @param {Object} p { success, fail }
*/
applyStatus(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
},
/* ================= 名片设计(员工端可选样式) ================= */
/**
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
* 成功 data: { list:[{ key, name, desc, thumb,
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
* @param {Object} p { success, fail }
*/
cardDesignPresets(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
},
/**
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
* @param {Object} p { data: { card_id, preset_key }, success, fail }
*/
cardDesignSave(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
}
};
export default CardApi;

View File

@@ -1470,7 +1470,7 @@
{
"path": "card/list",
"style": {
"navigationBarTitleText": "我的名片夹",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
@@ -1485,52 +1485,52 @@
{
"path": "card/exchange",
"style": {
"navigationBarTitleText": "名片交换请求"
"navigationBarTitleText": "名片"
}
},
{
"path": "card/style",
"style": {
"navigationBarTitleText": "名片样式"
"navigationBarTitleText": "名片"
}
},
{
"path": "card/edit",
"style": {
"navigationBarTitleText": "编辑名片"
"navigationBarTitleText": "名片"
}
},
{
"path": "card/apply",
"style": {
"navigationBarTitleText": "申请名片"
"navigationBarTitleText": "名片"
}
},
{
"path": "card/leads",
"style": {
"navigationBarTitleText": "潜在客户",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/contacts",
"style": {
"navigationBarTitleText": "我的人脉",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/views",
"style": {
"navigationBarTitleText": "谁看了我",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/contact-edit",
"style": {
"navigationBarTitleText": "编辑人脉"
"navigationBarTitleText": "名片"
}
},
{
@@ -1548,21 +1548,21 @@
{
"path": "card/admin/manage",
"style": {
"navigationBarTitleText": "名片管理",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/admin/contacts",
"style": {
"navigationBarTitleText": "人脉管理",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/admin/leads",
"style": {
"navigationBarTitleText": "潜在客户",
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},

View File

@@ -93,6 +93,13 @@ export default {
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页保存返回(redirectTo 回本页)时刷新
if (this._loadedOnce) this.refresh();

View File

@@ -82,6 +82,13 @@ export default {
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},

View File

@@ -88,6 +88,13 @@ export default {
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页返回时刷新(编辑成功后数据已变)
if (this._loadedOnce) this.refresh();

View File

@@ -68,9 +68,16 @@ export default {
}
},
onLoad() {
uni.setNavigationBarTitle({ title: '申请名片' });
uni.setNavigationBarTitle({ title: '名片' });
this.loadStatus();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadStatus() {
this.$api.sendRequest({

View File

@@ -82,6 +82,13 @@ export default {
this.loadDetail();
}
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadDetail() {
this.$cardApi.contactInfo({

View File

@@ -83,6 +83,13 @@ export default {
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页保存后 redirectTo 回来会重走 onLoad; 从其它页返回时兜底刷新
if (this.list.length > 0) {

View File

@@ -197,11 +197,18 @@ export default {
this.cardId = decodeURIComponent(options.card_id || '');
this.isCreate = this.cardId === '';
if (this.isCreate) {
uni.setNavigationBarTitle({ title: '新建名片' });
uni.setNavigationBarTitle({ title: '名片' });
} else {
this.loadDetail();
}
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadDetail() {
if (!this.cardId) {

View File

@@ -60,6 +60,13 @@ export default {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadList();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
computed: {
tabs() {
const pending = this.list.filter(i => i.status === 'pending').length;

View File

@@ -76,6 +76,13 @@ export default {
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},

View File

@@ -21,11 +21,13 @@
</view>
</view>
<!-- 筛选条: 跟进(全部 / 待跟进 / 已完成, follow_up 传 due/done) -->
<!-- 筛选条: 跟进(全部 / 待跟进 / 已完成, follow_up 传 due/done) + 删除模式开关 -->
<view class="filter-row">
<view class="fav-filter" :class="{ active: followTab === 'all' }" @tap="switchFollow('all')">全部</view>
<view class="fav-filter" :class="{ active: followTab === 'due' }" @tap="switchFollow('due')">待跟进</view>
<view class="fav-filter" :class="{ active: followTab === 'done' }" @tap="switchFollow('done')">已完成</view>
<!-- 删除名片入口(2.11.91): 点按进入删除模式, 行内才显示删除按钮, 避免平时误触 -->
<view class="fav-filter del-filter" :class="{ active: delMode }" @tap="toggleDelMode">删除</view>
</view>
<!-- 列表 -->
@@ -53,6 +55,7 @@
@tap.stop="toggleFavorite">{{ item.is_favorite == 1 ? '★' : '☆' }}</text>
<view v-if="followTab === 'due' && item.follow_up_done != 1" class="card-done-btn"
:data-index="index" @tap.stop="markDone">完成</view>
<view v-if="delMode" class="card-del-btn" :data-index="index" @tap.stop="deleteCard">删除</view>
<view class="card-arrow">›</view>
</view>
@@ -153,6 +156,8 @@ export default {
pendingClaimCode: '', // 扫码带参预填的码
// 跟进筛选(2.11.3): all / due(待跟进) / done(已完成)
followTab: 'all',
// 删除模式(2.11.91): 由筛选条「删除」入口开关, 开启后行内才显示删除按钮
delMode: false,
// 建卡申请状态(2.11.15)
apply: null, // { status, status_text, note, has_card }
applyName: '',
@@ -204,6 +209,13 @@ export default {
}
this.bootstrap();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 首次 onShow 紧跟 onLoad(由 bootstrap 负责首屏加载), 这里只处理"返回本页"的刷新
if (this._entered) this.refresh();
@@ -280,6 +292,10 @@ export default {
this.followTab = tab;
this.refresh();
},
// 删除模式开关(2.11.91): 开启后各行显示删除按钮; 再次点按退出
toggleDelMode() {
this.delMode = !this.delMode;
},
// 标记跟进完成(2.11.3): followDone, 成功后该卡从待跟进消失
markDone(payload) {
const item = this.resolveItem(payload);
@@ -303,6 +319,50 @@ export default {
}
});
},
/**
* 删除名片(软删, 2.11.91, 与 CARD_DELETE_API.md 同步)
* 行级归属: 只能删自己名下卡; 删他人/已删/不存在统一后端报「卡片不存在」。
* 流程: 二次确认(danger 样式) → cardDelete → 成功 toast「已删除」并刷新列表
* (列表接口立即不再返回该卡, 刷新即移除, 无需手动 splice)。
* 失败口径同收口规范第 3 条: 统一 toast(message), 不刷新。
*/
deleteCard(payload) {
const item = this.resolveItem(payload);
if (!item) return;
const cardId = this.cardIdOf(item);
if (!cardId) {
uni.showToast({ title: '该名片缺少编号,无法删除', icon: 'none' });
return;
}
const name = item.name || '未命名名片';
uni.showModal({
title: '删除名片',
content: '删除后名片将立即失效,分享链接也会不可访问。确定删除「' + name + '」?',
confirmText: '删除',
confirmColor: '#e54d42',
success: m => {
if (!m.confirm) return;
uni.showLoading({ title: '删除中…' });
this.$cardApi.cardDelete({
data: { card_id: cardId },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已删除', icon: 'success' });
this.refresh();
} else {
// 「卡片不存在」模糊口径: 统一按 message 提示, 不特殊处理
this.handleApiError(res);
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
});
},
// 空态: 查询我的建卡申请状态(2.11.15)
loadApplyStatus() {
this.$cardApi.applyStatus({
@@ -679,6 +739,17 @@ export default {
}
}
/* 删除模式入口(2.11.91): 与跟进 chips 同排, 激活态用 danger 红 */
.fav-filter.del-filter {
margin-left: 16rpx;
&.active {
background-color: #fdeceb;
border-color: #e54d42;
color: #e54d42;
}
}
.card-list {
.card-item {
display: flex;
@@ -794,6 +865,21 @@ export default {
border-radius: 24rpx;
}
/* 删除名片按钮(2.11.91): danger 红, 与完成按钮同构的小药丸 */
.card-del-btn {
flex-shrink: 0;
margin-left: 16rpx;
padding: 8rpx 20rpx;
background-color: #fdeceb;
color: #e54d42;
font-size: 24rpx;
border-radius: 24rpx;
&:active {
background-color: #f9d6d3;
}
}
/* 首屏加载占位(替代"先闪空态") */
.first-loading {
text-align: center;

View File

@@ -363,6 +363,13 @@ export default {
this.loadDetail();
this.loadPresets();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
loadDetail() {
if (!this.cardId) {
this.isLoading = false;

244
pages_tool/card/views.vue Normal file
View File

@@ -0,0 +1,244 @@
<template>
<view class="views-page">
<!-- 汇总卡(2.11.4): 今日/累计 的 PV 与 UV -->
<view class="summary">
<view class="sum-card">
<view class="sum-num">{{ summary.today_pv || 0 }}</view>
<view class="sum-label">今日浏览 PV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.today_uv || 0 }}</view>
<view class="sum-label">今日访客 UV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.total_pv || 0 }}</view>
<view class="sum-label">累计浏览 PV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.total_uv || 0 }}</view>
<view class="sum-label">累计访客 UV</view>
</view>
</view>
<!-- 列表: time / name / channel / device / ip -->
<view class="view-list">
<view v-for="(item, idx) in list" :key="item.id || idx" class="view-item">
<view class="view-avatar">{{ avatarText(item) }}</view>
<view class="view-info">
<view class="view-name">{{ item.viewer_name || item.name || '匿名访客' }}</view>
<view class="view-meta">
<text class="view-channel">{{ item.channel || '未知渠道' }}</text>
<text class="view-dot">·</text>
<text class="view-device">{{ item.device || '' }}</text>
<text class="view-dot" v-if="item.ip">·</text>
<text class="view-ip">{{ item.ip || '' }}</text>
</view>
</view>
<view class="view-time">{{ item.view_time || item.time || '' }}</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>
<view v-if="!isLoading && list.length === 0" class="views-empty">暂无访客记录</view>
</view>
</view>
</template>
<script>
/**
* 访客轨迹 / 谁看了我(2.11.4,建议新页面)
* 入口: 我的名片「访客」总入口(不带 card_id,查名下全部名片)
* 或某张名片「浏览」入口(带 card_id,查该卡访客)
* 接口: POST /card/api/card/views { card_id?, page?, page_size?(<=50, 默认20) }
* 响应: { list, total, page, page_size, summary: { today_pv, today_uv, total_pv, total_uv } }
*/
export default {
data() {
return {
cardId: '', // 带 card_id=查该卡访客; 空=名下全部
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
summary: {}
};
},
onLoad(options) {
this.cardId = (options && options.card_id) ? String(options.card_id) : '';
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
avatarText(item) {
const name = item.viewer_name || item.name || '?';
return name.trim().charAt(0).toUpperCase();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
if (this.cardId) data.card_id = this.cardId;
this.$cardApi.cardViews({
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.summary = d.summary || {};
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
this.handleApiError(res);
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
handleApiError(res) {
const msg = (res && res.message) || '加载失败';
uni.showToast({ title: msg, icon: 'none' });
}
}
};
</script>
<style lang="scss" scoped>
.views-page {
min-height: 100vh;
background-color: #f6f7f9;
padding: 20rpx 24rpx 40rpx;
box-sizing: border-box;
}
.summary {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
.sum-card {
flex: 1;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx 0;
text-align: center;
margin: 0 8rpx;
&:first-child { margin-left: 0; }
&:last-child { margin-right: 0; }
.sum-num {
font-size: 40rpx;
font-weight: 700;
color: var(--main-color, #0054a5);
line-height: 1.2;
}
.sum-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
}
}
}
.view-list {
.view-item {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
.view-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: var(--main-color-shallow, #e8f1fb);
color: var(--main-color, #0054a5);
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 20rpx;
}
.view-info {
flex: 1;
min-width: 0;
.view-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.view-meta {
margin-top: 8rpx;
font-size: 24rpx;
color: #999;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.view-dot {
margin: 0 10rpx;
}
}
}
.view-time {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 24rpx;
color: #bbb;
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end { color: #ccc; }
}
.views-empty {
text-align: center;
color: #999;
font-size: 28rpx;
padding: 120rpx 0;
}
</style>

View File

@@ -41,6 +41,7 @@ const DOC_URLS = [
'/card/api/card/lists',
'/card/api/card/detail',
'/card/api/card/update',
'/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
'/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准
'/card/api/card/claim',
'/card/api/card/recognize',
@@ -73,6 +74,7 @@ const HELPER_URL_PAIRS = [
['cardLists', 'CARD_LISTS'],
['cardDetail', 'CARD_DETAIL'],
['cardUpdate', 'CARD_UPDATE'],
['cardToggleFavorite', 'CARD_FAVORITE'],
['cardCreate', 'CARD_CREATE'],
['cardClaim', 'CARD_CLAIM'],
['cardRecognize', 'CARD_RECOGNIZE'],
@@ -98,7 +100,7 @@ const HELPER_URL_PAIRS = [
];
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'cardToggleFavorite', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
// =============================================================
// 1. 端点与后端文档逐一比对
@@ -126,7 +128,7 @@ 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),
assert.ok(new RegExp(`(?:url\\s*:|url\\s*=)\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC),
`helper ${helper} 未绑定 ${urlConst}`);
}
});