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

642 lines
16 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>
<!-- 筛选条: 仅看收藏 -->
<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>
<!-- 列表 -->
<view class="card-list">
<view v-for="(item, index) in list" :key="item.id || index" class="card-item"
@tap="goDetail(item)">
<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>
<!-- 收藏星标: 实心=已收藏, 空心=未收藏; 单独点击不进详情 -->
<text class="card-fav" :class="{ 'is-fav': item.is_favorite == 1 }"
@tap.stop="toggleFavorite(item)">{{ item.is_favorite == 1 ? '★' : '☆' }}</text>
<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>
<!-- 空态 -->
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="emptyText"></ns-empty>
</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: '' // 扫码带参预填的码
};
},
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.refresh();
},
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();
},
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();
},
/**
* 切换收藏(双向 toggle): 直接调, 用返回的 is_favorite 回填。
* 乐观更新 + 失败回滚; 仅看收藏模式下取消收藏则从列表移除该行。
*/
toggleFavorite(item) {
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;
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);
this.emptyText = this.keyword.trim()
? '未找到匹配的名片'
: '暂无名片,可点击右上角「新建」或「认领」添加';
} else {
this.handleApiError(res);
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
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) : '';
},
goDetail(item) {
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'
});
},
// 认领: 打开弹层(支持领取码/名片编号 + 扫码带参预填, 与 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;
}
}
}
.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_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>