Files
lucky_shop/pages_tool/card/list.vue
2026-10-20 10:06:16 +08:00

1069 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="card-wallet">
<!-- 搜索区 -->
<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 class="claim-btn create-btn" @tap="goCreate">新建</view>
<view class="claim-btn" @tap="onClaimTap">认领</view>
<view class="claim-btn" @tap="goViews">访客</view>
</view>
<!-- 筛选条: 仅看收藏 -->
<view class="filter-row">
<view class="fav-filter" :class="{ active: onlyFav }" @tap="toggleFavFilter">
<text class="fav-filter-icon">{{ onlyFav ? '★' : '☆' }}</text>
<text>{{ onlyFav ? '仅看收藏' : '全部名片' }}</text>
</view>
</view>
<!-- 筛选条: 跟进(全部 / 待跟进 / 已完成, 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>
<!-- 列表 -->
<view class="card-list">
<view v-for="(item, index) in list" :key="item.id || index" :data-index="index"
class="card-item" @tap="goDetail">
<view class="card-avatar">
<text>{{ 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 class="card-follow" v-if="item.follow_up_done == 1">
<text class="card-follow-done">✓ 已完成</text>
</view>
<view class="card-follow" v-else-if="followTab === 'due'">
<text class="card-follow-due">跟进中</text>
</view>
</view>
<!-- 收藏星标: 实心=已收藏, 空心=未收藏; 单独点击不进详情 -->
<text class="card-fav" :class="{ 'is-fav': item.is_favorite == 1 }" :data-index="index"
@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>
<!-- 加载状态 -->
<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 class="first-loading" v-if="!booted">
<text>加载中…</text>
</view>
<!-- 空态: 建卡申请 / 申请状态(2.11.15); booted 未置位说明还没拿到数据, 不展示空态 -->
<view v-if="booted && !isLoading && list.length === 0" class="card-empty">
<!-- 分组/搜索/收藏筛选为空: 只是当下没有符合条件的名片, 不能当成"还没建卡"去引导申请 -->
<block v-if="emptyScene">
<view class="empty-icon">{{ emptyScene.icon }}</view>
<view class="empty-title">{{ emptyScene.title }}</view>
<view class="empty-sub">{{ emptyScene.sub }}</view>
</block>
<block v-else-if="apply && apply.status === 0">
<view class="empty-icon">⏳</view>
<view class="empty-title">申请审核中</view>
<view class="empty-sub">您的建卡申请正在等待商家审批</view>
</block>
<block v-else-if="apply && apply.status === 1">
<view class="empty-icon">✅</view>
<view class="empty-title">已通过</view>
<view class="empty-sub">下拉刷新查看我的名片</view>
</block>
<block v-else>
<view class="empty-icon">📇</view>
<view class="empty-title" v-if="apply && apply.status === 2">已拒绝:{{ apply.note || '商家未填写理由' }}</view>
<view class="empty-title" v-else>还没有名片</view>
<!-- 未登录(游客)不展示建卡申请表单, 改为引导登录 -->
<view class="empty-sub" v-if="isLogin">提交信息,申请商家为您建卡</view>
<view class="empty-sub" v-else>登录后即可申请商家为您建卡</view>
<view class="apply-form" v-if="isLogin">
<input class="apply-input" :value="applyName" @input="onApplyNameInput"
placeholder="您的姓名" />
<input class="apply-input" :value="applyMobile" @input="onApplyMobileInput"
placeholder="手机号" type="number" />
<view class="apply-submit" :class="{ disabled: applying }" @tap="submitApply">
{{ applying ? '提交中…' : '提交申请' }}
</view>
</view>
</block>
</view>
</view>
<!-- 认领弹层: 支持领取码/名片编号 + 扫码带参预填(与 card_claim_api 文档同步) -->
<view v-if="showClaim" class="claim-mask" @tap="closeClaim">
<view class="claim-pop" @tap.stop>
<view class="claim-pop-title">认领名片</view>
<view class="claim-pop-tabs">
<view class="claim-tab" :class="{ active: claimMode === 'code' }" @tap="claimMode = 'code'">领取码</view>
<view class="claim-tab" :class="{ active: claimMode === 'id' }" @tap="claimMode = 'id'">名片编号</view>
</view>
<view class="claim-input-wrap">
<input class="claim-input" :value="claimInput"
:placeholder="claimMode === 'code' ? '输入 8 位领取码(大写字母数字)' : '输入名片编号'"
@input="onClaimInput" confirm-type="done" @confirm="submitClaim" />
<text v-if="claimInput" class="claim-clear" @tap="claimInput = ''">✕</text>
</view>
<view class="claim-scan" @tap="scanClaim">
<text class="iconfont icon-saomafu"></text>
<text>扫码认领(自动识别领取码 / 编号)</text>
</view>
<view class="claim-pop-actions">
<view class="claim-btn-cancel" @tap="closeClaim">取消</view>
<view class="claim-btn-confirm" @tap="submitClaim">确认认领</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
count: 0,
keyword: '',
onlyFav: false,
isLoading: false,
emptyText: '暂无名片,可点击右上角「新建」或「认领」添加',
// 认领弹层状态(与 card_claim_api 文档同步)
showClaim: false,
claimInput: '',
claimMode: 'code', // code=领取码(主通道), id=名片编号(旧入口兼容)
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: '',
applyMobile: '',
applying: false,
// 首次数据请求是否已结束: 未结束前不渲染空态,
// 否则重新编译/刷新时会先闪一下「还没有名片 + 申请表单」(列表其实有数据)
booted: false
};
},
computed: {
/**
* 筛选态下的空态文案。
* 重要: 只有「全部 + 无搜索 + 无收藏筛选」才代表"用户还没有名片"(这时才引导建卡申请);
* 待跟进/已完成/搜索/收藏 只是当下没有符合条件的卡, 不能弹申请表单。
*/
emptyScene() {
if (this.followTab === 'due') {
return { icon: '📇', title: '暂无待跟进名片', sub: '有跟进中的名片会出现在这里' };
}
if (this.followTab === 'done') {
return { icon: '📇', title: '暂无已完成名片', sub: '标记完成的名片会出现在这里' };
}
if (this.onlyFav) {
return { icon: '☆', title: '暂无收藏的名片', sub: '点名片右侧的星标即可收藏' };
}
if (this.keyword.trim()) {
return { icon: '🔍', title: '未找到匹配的名片', sub: '换个关键词试试' };
}
return null;
},
/**
* 是否已登录。
*
* 未登录(游客)时不应展示「建卡申请」表单: 提交必然失败(接口要求登录态),
* 而且用户连自己是谁都没确定, 让他填姓名/手机号没有意义。
* token 就绪后这里会自动变为 true, 表单随之出现(bootstrap 已等 token 再拉列表)。
*/
isLogin() {
return !!this.$store.state.token;
}
},
onLoad(options) {
// 扫码带参预填(文档 §5): 二维码/链接带 claim_code 或 card_id 直接进入认领
if (options && (options.claim_code || options.card_id)) {
this.pendingClaimCode = (options.claim_code || options.card_id || '')
.toUpperCase().replace(/\s+/g, '');
this.claimMode = options.claim_code ? 'code' : 'id';
}
this.bootstrap();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从详情页返回时走 onShow(不会再触发 onReady), 微信可能把标题恢复成旧值,
// 这里每次显示都重设一次, 保证导航栏始终是「名片」
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
// 首次 onShow 紧跟 onLoad(由 bootstrap 负责首屏加载), 这里只处理"返回本页"的刷新
if (this._entered) this.refresh();
this._entered = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
/**
* 首屏加载: 等 member token 就绪后再请求。
*
* 背景(为什么需要): App.vue 的 onLaunch 是 async, token 在多个 await
* (i18n / 主题 / 远端配置 / 隐私弹窗) 之后才从 storage 恢复到 store。
* 页面 onLoad 在 onLaunch 第一次 await 让出后就执行, 此时 store.state.token
* 仍是 null → card/lists 以未登录身份请求, 后端返回空列表;
* 本页原本又没有 onShow 重拉, 于是表现为「刷新/重新编译后没有名片,
* 必须退出页面重新进入才看得到」。
*/
bootstrap() {
// 等 token 期间也处于"加载中", 否则首屏会先渲染空态
this.isLoading = true;
this.waitTokenReady().then(() => this.refresh());
},
/**
* 等 token 就绪:
* - store 已有 token → 直接放行;
* - storage 有 token 但 store 还没有 → onLaunch 还在跑, 轮询等待(最多 timeout);
* - storage 也没有 → 未登录/游客, 不等, 用空 token 请求(由后端决定返回)。
*/
waitTokenReady(timeout = 3000) {
if (this.$store.state.token) return Promise.resolve(true);
let stored = '';
try { stored = uni.getStorageSync('token') || ''; } catch (e) { stored = ''; }
if (!stored) return Promise.resolve(false);
return new Promise(resolve => {
const start = Date.now();
const timer = setInterval(() => {
if (this.$store.state.token) {
clearInterval(timer);
resolve(true);
} else if (Date.now() - start >= timeout) {
clearInterval(timer);
resolve(false);
}
}, 50);
});
},
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
},
onKeywordInput(e) {
this.keyword = e.detail.value;
},
onSearch() {
this.refresh();
},
clearKeyword() {
this.keyword = '';
this.refresh();
},
// 仅看收藏筛选开关(列表筛选, favorite=1; 可与 keyword 叠加)
toggleFavFilter() {
this.onlyFav = !this.onlyFav;
this.refresh();
},
// 跟进筛选切换(全部/待跟进/已完成, 2.11.3)
switchFollow(tab) {
this.followTab = tab;
this.refresh();
},
// 删除模式开关(2.11.91): 开启后各行显示删除按钮; 再次点按退出
toggleDelMode() {
this.delMode = !this.delMode;
},
// 标记跟进完成(2.11.3): followDone, 成功后该卡从待跟进消失
markDone(payload) {
const item = this.resolveItem(payload);
const cardId = this.cardIdOf(item);
if (!cardId) return;
uni.showLoading({ title: '标记中…' });
this.$cardApi.cardFollowDone({
data: { card_id: cardId },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已标记完成', icon: 'success' });
this.refresh();
} else {
this.handleApiError(res);
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
/**
* 删除名片(软删, 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({
success: res => {
if (res.code == 0) this.apply = res.data || null;
},
fail: () => {}
});
},
// 提交建卡申请(2.11.15)
// 模板内联箭头函数(`@input="e => x = e.detail.value"`)在 H5 能跑,
// 但小程序端编译不进 wxml, 输入会失效, 统一收成方法
onApplyNameInput(e) {
this.applyName = (e && e.detail && e.detail.value) || '';
},
onApplyMobileInput(e) {
this.applyMobile = (e && e.detail && e.detail.value) || '';
},
submitApply() {
const name = (this.applyName || '').trim();
const mobile = (this.applyMobile || '').trim();
if (!name) { uni.showToast({ title: '请输入姓名', icon: 'none' }); return; }
if (!mobile) { uni.showToast({ title: '请输入手机号', icon: 'none' }); return; }
this.applying = true;
this.$cardApi.applySubmit({
data: { name: name, mobile: mobile },
success: res => {
this.applying = false;
if (res.code == 0) {
uni.showToast({ title: '已提交,等待审核', icon: 'none' });
this.apply = { status: 0, status_text: '待审核' };
this.applyName = '';
this.applyMobile = '';
} else {
this.handleApiError(res);
}
},
fail: () => {
this.applying = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
/**
* 切换收藏(双向 toggle): 直接调, 用返回的 is_favorite 回填。
* 乐观更新 + 失败回滚; 仅看收藏模式下取消收藏则从列表移除该行。
*/
toggleFavorite(payload) {
const item = this.resolveItem(payload);
if (!item) return;
const cardId = this.cardIdOf(item);
if (!cardId) {
uni.showToast({ title: '该名片缺少编号,无法收藏', icon: 'none' });
return;
}
const prev = item.is_favorite == 1 ? 1 : 0;
// 乐观翻转
this.$set(item, 'is_favorite', prev ? 0 : 1);
this.$cardApi.cardToggleFavorite({
data: { card_id: cardId },
success: res => {
if (res.code == 0) {
const d = res.data || {};
const next = d.is_favorite == 1 ? 1 : 0;
this.$set(item, 'is_favorite', next);
// 仅看收藏模式下取消收藏 → 从列表移除
if (this.onlyFav && next === 0) {
const idx = this.list.indexOf(item);
if (idx >= 0) this.list.splice(idx, 1);
}
} else {
// 「名片不存在」/「非自己名下卡」等拒绝场景: 回滚 + 直接 toast message
this.$set(item, 'is_favorite', prev);
if (res.message) uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.$set(item, 'is_favorite', prev);
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
// 列表筛选: favorite=1 只返回星标卡(后端 2.9.12); 不传/非1 返回全部。
// 与现有 page/keyword 一并放 body(TP6 param() 合并 query+body, 等效 ?favorite=1)。
const reqData = {
page: pageNo,
page_size: this.pageSize,
keyword: this.keyword.trim()
};
if (this.onlyFav) reqData.favorite = 1;
if (this.followTab === 'due') reqData.follow_up = 'due';
else if (this.followTab === 'done') reqData.follow_up = 'done';
this.$cardApi.cardLists({
data: reqData,
success: res => {
if (res.code == 0) {
const data = res.data || {};
const rows = data.list || [];
this.pageCount = data.page_count || 1;
this.count = data.count || 0;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
// 空态(首页无卡): 拉取建卡申请状态, 驱动空态申请 UI(2.11.15)
// 仅「全部 + 无搜索 + 无收藏筛选 + 已登录」时才需要
// (其余筛选为空不代表用户没卡; 未登录时申请表单纯粹不展示, 不必请求)
if (pageNo === 1 && rows.length === 0 && !this.emptyScene && this.isLogin) this.loadApplyStatus();
this.emptyText = this.keyword.trim()
? '未找到匹配的名片'
: '暂无名片,可点击右上角「新建」或「认领」添加';
} else {
this.handleApiError(res);
}
this.isLoading = false;
// 首次请求结束(无论成功/失败)才允许渲染空态
this.booted = true;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
this.booted = true;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
// 详情路由只认字符串 card_id(后端文档 1.3.2), 行数据里可能写成 card_id / cardId
cardIdOf(item) {
if (!item) return '';
const raw = item.card_id || item.cardId;
return raw ? String(raw) : '';
},
/**
* 取事件对应的行数据。
*
* 为什么需要: 列表内联事件传对象(`@tap="goDetail(item)"`)在 H5 由 Vue 直接求值没问题,
* 但小程序端要经 data-event-opts 索引回填, 实测会拿到 undefined(点击后提示"缺少编号",
* console 打印 可用字段: undefined), 导致小程序点不进详情。
* 现在模板统一改成 `:data-index="index"` + 不传参, 这里从 dataset 取索引回表拿行。
* 兼容三种入参: 数字索引 / 事件对象 / 行对象本身。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.list[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
// 兜底: 已经是行对象(旧写法/内部调用)
return payload.card_id || payload.cardId || payload.name ? payload : null;
},
goDetail(payload) {
const item = this.resolveItem(payload);
const cardId = this.cardIdOf(item);
if (!cardId) {
// 之前这里是静默 return: 命中时点名片毫无反应, 用户只会认为"点不进去"
console.warn('[card/list] 该行缺少 card_id, 无法打开详情; 可用字段:',
item ? Object.keys(item) : item);
uni.showToast({ title: '该名片缺少编号,无法打开', icon: 'none' });
return;
}
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(cardId),
// navigateTo 默认无界面反馈(页面未注册/栈满都会静默失败), 显式暴露错误
fail: err => {
console.error('[card/list] 打开名片详情失败:', err);
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
}
});
},
goCreate() {
uni.navigateTo({
url: '/pages_tool/card/edit'
});
},
// 访客轨迹(2.11.4): 进入「谁看了我」总入口(不带 card_id)
goViews() {
uni.navigateTo({
url: '/pages_tool/card/views'
});
},
// 认领: 打开弹层(支持领取码/名片编号 + 扫码带参预填, 与 card_claim_api 文档同步)
onClaimTap() {
this.claimInput = this.pendingClaimCode || '';
this.showClaim = true;
},
onClaimInput(e) {
// 输入框自动转大写并过滤空格(文档 §5 checklist)
this.claimInput = (e.detail.value || '').toUpperCase().replace(/\s+/g, '');
},
closeClaim() {
this.showClaim = false;
},
submitClaim() {
const code = (this.claimInput || '').trim().toUpperCase().replace(/\s+/g, '');
this.claimInput = code;
if (!code) {
uni.showToast({
title: this.claimMode === 'code' ? '请输入领取码' : '请输入名片编号',
icon: 'none'
});
return;
}
this.doClaim(code);
this.closeClaim();
},
// 扫码认领: 解析二维码(URL 参数或整串), 自动识别领取码/名片编号
scanClaim() {
// #ifndef H5
uni.scanCode({
success: r => {
const code = this.parseClaimParam(r.result);
if (!code) {
uni.showToast({ title: '二维码未包含领取码或名片编号', icon: 'none' });
return;
}
this.claimInput = code;
this.claimMode = /^\d+$/.test(code) ? 'id' : 'code';
this.doClaim(code);
this.closeClaim();
},
fail: () => {}
});
// #endif
// #ifdef H5
uni.showToast({ title: '请在微信/小程序内扫码', icon: 'none' });
// #endif
},
parseClaimParam(val) {
const s = (val || '').trim();
if (!s) return '';
const m = s.match(/[?&](claim_code|card_id)=([^&]+)/i);
if (m) return decodeURIComponent(m[2]).toUpperCase().replace(/\s+/g, '');
return s.toUpperCase().replace(/\s+/g, '');
},
doClaim(code) {
uni.showLoading({ title: '认领中…' });
// 文档 §2: claim_code 主通道, card_id 旧入口兼容; 二者二选一
const payload = this.claimMode === 'code'
? { claim_code: code }
: { card_id: code };
this.$cardApi.cardClaim({
data: payload,
success: res => {
uni.hideLoading();
if (res.code == 0) {
// claimed=true=新认领; false=早已是自己的(幂等)
const isNew = res.data && res.data.claimed === true;
uni.showToast({
title: isNew ? '认领成功' : '该名片已在您名下',
icon: 'success'
});
this.refresh(); // 认领后与员工自建/扫描卡同池展示
} else {
this.handleApiError(res);
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
handleApiError(res) {
const msg = (res && res.message) || '操作失败';
// 未登录/凭证失效时引导登录(与全站约定一致:code < 0)
if (res && res.code < 0) {
uni.showToast({ title: msg, icon: 'none' });
return;
}
uni.showToast({ title: msg, icon: 'none' });
}
}
};
</script>
<style lang="scss" scoped>
.card-wallet {
min-height: 100vh;
background-color: #f6f7f9;
padding: 20rpx 24rpx 40rpx;
box-sizing: border-box;
}
.search-bar {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.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;
}
}
.claim-btn {
margin-left: 20rpx;
height: 72rpx;
line-height: 72rpx;
padding: 0 30rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 28rpx;
border-radius: 36rpx;
/* 「新建」次级按钮: 主按钮同构, 淡底弱化一档 */
&.create-btn {
background-color: var(--main-color-shallow, #e8f1fb);
color: var(--main-color, #0054a5);
}
}
}
/* 筛选条: 仅看收藏 */
.filter-row {
display: flex;
margin-bottom: 20rpx;
}
.fav-filter {
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background-color: #ffffff;
color: #666;
font-size: 26rpx;
border: 1rpx solid #e5e5e5;
.fav-filter-icon {
margin-right: 8rpx;
font-size: 30rpx;
color: #ccc;
}
&.active {
background-color: #fff6e0;
border-color: #f5b301;
color: #c8860a;
.fav-filter-icon {
color: #f5b301;
}
}
}
/* 删除模式入口(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;
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;
}
.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-arrow {
color: #ccc;
font-size: 40rpx;
margin-left: 16rpx;
}
/* 收藏星标: 空心=未收藏(灰), 实心=已收藏(金) */
.card-fav {
flex-shrink: 0;
font-size: 44rpx;
line-height: 1;
color: #ccc;
margin-left: 16rpx;
padding: 4rpx;
&.is-fav {
color: #f5b301;
}
}
}
}
.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;
}
/* 列表行: 跟进徽标 + 完成按钮 */
.card-follow {
margin-top: 8rpx;
.card-follow-done {
font-size: 22rpx;
color: #1aad19;
}
.card-follow-due {
font-size: 22rpx;
color: #f5a623;
}
}
.card-done-btn {
flex-shrink: 0;
margin-left: 16rpx;
padding: 8rpx 20rpx;
background-color: var(--main-color, #0054a5);
color: #fff;
font-size: 24rpx;
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;
color: #999;
font-size: 26rpx;
padding: 160rpx 0;
}
/* 空态: 建卡申请 */
.card-empty {
text-align: center;
padding: 120rpx 48rpx;
.empty-icon {
font-size: 96rpx;
}
.empty-title {
margin-top: 24rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.empty-sub {
margin-top: 12rpx;
font-size: 26rpx;
color: #999;
}
.apply-form {
margin-top: 48rpx;
.apply-input {
height: 84rpx;
background-color: #fff;
border-radius: 14rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
margin-bottom: 24rpx;
text-align: left;
}
.apply-submit {
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2aa5ff 0%, #0d7bf0 100%);
color: #fff;
font-size: 30rpx;
font-weight: 600;
&.disabled {
opacity: 0.6;
}
}
}
}
/* 认领弹层(与 card_claim_api 文档同步) */
.claim-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.claim-pop {
width: 600rpx;
background-color: #ffffff;
border-radius: 24rpx;
padding: 40rpx 36rpx;
box-sizing: border-box;
.claim-pop-title {
font-size: 34rpx;
font-weight: 600;
color: #1f2329;
text-align: center;
}
.claim-pop-tabs {
display: flex;
margin: 32rpx 0 24rpx;
background-color: #f3f4f6;
border-radius: 14rpx;
padding: 6rpx;
.claim-tab {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #6b7280;
padding: 16rpx 0;
border-radius: 10rpx;
&.active {
background-color: #ffffff;
color: var(--main-color, #0054a5);
font-weight: 600;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
}
}
.claim-input-wrap {
display: flex;
align-items: center;
background-color: #f7f8fa;
border: 2rpx solid #e5e7eb;
border-radius: 14rpx;
padding: 0 20rpx;
height: 84rpx;
.claim-input {
flex: 1;
height: 84rpx;
font-size: 30rpx;
color: #1f2329;
letter-spacing: 2rpx;
}
.claim-clear {
color: #bbb;
font-size: 30rpx;
padding: 0 8rpx;
}
}
.claim-scan {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24rpx;
color: var(--main-color, #0054a5);
font-size: 26rpx;
.iconfont {
margin-right: 10rpx;
font-size: 32rpx;
}
}
.claim-pop-actions {
display: flex;
margin-top: 36rpx;
.claim-btn-cancel,
.claim-btn-confirm {
flex: 1;
height: 84rpx;
line-height: 84rpx;
text-align: center;
border-radius: 14rpx;
font-size: 30rpx;
}
.claim-btn-cancel {
background-color: #f3f4f6;
color: #6b7280;
margin-right: 20rpx;
}
.claim-btn-confirm {
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-weight: 600;
}
}
}
</style>