feat: 清单分享编码与加购选规格

详情展示/复制唯一编码,分享链接按编码只读打开;加购须先选规格;列表可复制编码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
李琦
2026-10-21 08:17:58 +08:00
parent cfb128d2f0
commit 39446ca08c
4 changed files with 350 additions and 30 deletions

View File

@@ -5,8 +5,16 @@ const prefix = 'list/'
export const getCartListApi = async () => {
return await get(`${prefix}list`)
}
export const getCartItemApi = async (id) => {
return await get(`${prefix}detail`, {id})
export const getCartItemApi = async (params) => {
const query = typeof params === 'object' && params !== null
? params
: { id: params }
return await get(`${prefix}detail`, query)
}
export const ensureShareCodeApi = async (listId) => {
return await post(`${prefix}ensure-share-code`, {
list_id: listId,
})
}
export const createCartApi = async (params) => {
return await post(`${prefix}create`, params)
@@ -21,10 +29,12 @@ export const deleteCartItemApi = async (id) => {
ids: [id]
})
}
export const toCartApi = async (listId, productId) => {
export const toCartApi = async (listId, productId, priceSheetId, routineIndex) => {
return await post(`${prefix}to-cart`, {
list_id: listId,
product_id: productId
product_id: productId,
price_sheet_id: priceSheetId,
routine_index: routineIndex,
})
}
export const submitHighIntentApi = async (list_id, intent_remark = '') => {

View File

@@ -45,6 +45,14 @@
<view class="card-body">
<view class="body-left">
<text class="item-name">{{ item.name }}</text>
<view
v-if="item.share_code"
class="share-code-row"
@tap.stop="copyShareCode(item.share_code)"
>
<text class="share-code-text">编码 {{ item.share_code }}</text>
<text class="share-code-copy">复制</text>
</view>
<view class="item-remark" v-if="item.remark">
<text class="remark-line"></text>
<text class="remark-text">{{ item.remark }}</text>
@@ -154,6 +162,15 @@ export default {
navigateBack() {
uni.navigateBack()
},
copyShareCode(code) {
if (!code) return;
uni.setClipboardData({
data: String(code),
success: () => {
uni.showToast({ title: '编码已复制', icon: 'success' });
},
});
},
showCreateModal() {
this.showModal = true
},
@@ -375,11 +392,34 @@ view, scroll-view, text, image {
font-size: 36rpx;
font-weight: 500;
color: #222;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
display: block;
font-family: serif;
}
.share-code-row {
display: inline-flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
padding: 4rpx 0;
}
.share-code-text {
font-size: 22rpx;
color: #666;
font-family: Arial, Helvetica, sans-serif;
letter-spacing: 1rpx;
}
.share-code-copy {
font-size: 20rpx;
color: #B4854D;
padding: 2rpx 12rpx;
border: 1rpx solid #B4854D;
border-radius: 8rpx;
}
.item-remark {
display: flex;
align-items: center;

View File

@@ -1,18 +1,40 @@
<template>
<view class="container">
<!-- 悬浮分享:无编码先生成;有编码再走微信分享 -->
<button
v-if="isOwner && shareCode"
class="share-fixed-btn"
open-type="share"
hover-class="none"
>
<u-icon name="share-fill" color="#333" size="20"></u-icon>
</button>
<button
v-else-if="isOwner"
class="share-fixed-btn"
hover-class="none"
:loading="sharePreparing"
@tap="prepareShare"
>
<u-icon name="share-fill" color="#333" size="20"></u-icon>
</button>
<view class="detail-header">
<view class="header-content">
<view class="title-group">
<text class="page-title">{{ listName }}</text>
<text class="page-subtitle">高端臻选系列</text>
<text class="page-subtitle">{{ isOwner ? '高端臻选系列' : '好友分享的清单' }}</text>
</view>
<view class="header-badges">
<view class="status-badge-row" v-if="is_high_intent || quote_status === 2">
<text v-if="is_high_intent" class="status-badge intent">已申请</text>
<text v-if="isOwner && is_high_intent" class="status-badge intent">已申请</text>
<text v-if="quote_status === 2" class="status-badge quoted">已报价</text>
</view>
<text class="count-badge">共计 {{ info.items ? info.items.length : 0 }} 件</text>
<text class="id-badge">清单号: {{ listId }}</text>
<view class="code-badge-row" @tap="copyShareCode">
<text class="id-badge">编码: {{ shareCode || '加载中…' }}</text>
<text v-if="shareCode" class="copy-badge">复制</text>
</view>
</view>
</view>
</view>
@@ -32,7 +54,11 @@
</view>
<view class="top-right">
<view class="action-btn delete" @tap.stop="handleDelete(index, item.id)">
<view
v-if="isOwner"
class="action-btn delete"
@tap.stop="handleDelete(index, item.id)"
>
<u-icon name="trash" color="#999" size="18"></u-icon>
</view>
<view class="action-btn arrow">
@@ -104,13 +130,27 @@
@tap="goToQuote"
>查看报价</button>
<button
v-if="!is_high_intent"
v-if="isOwner && !is_high_intent"
class="bar-btn outline"
@tap="openApplyPopup"
>申请报价</button>
<view v-else-if="!quote_visible" class="intent-status-bar">
<view v-else-if="isOwner && !quote_visible" class="intent-status-bar">
<text>已申请报价</text>
</view>
<view v-else-if="!isOwner && !quote_visible" class="intent-status-bar">
<text>仅浏览</text>
</view>
<button
v-if="isOwner && shareCode"
class="bar-btn outline share-bar-btn"
open-type="share"
>分享清单</button>
<button
v-else-if="isOwner"
class="bar-btn outline share-bar-btn"
:loading="sharePreparing"
@tap="prepareShare"
>分享清单</button>
</view>
</view>
@@ -128,7 +168,7 @@
/>
</view>
<view v-if="quote_apply_require_sku" class="apply-sku-section">
<view v-if="!quote_visible" class="apply-sku-section">
<text class="apply-label">请确认规格</text>
<scroll-view scroll-y class="apply-sku-list">
<view
@@ -179,29 +219,38 @@
</template>
<script>
import {deleteCartItemApi, getCartItemApi, submitHighIntentApi, savePreferSkuApi} from "@/api/page/cart";
import {
deleteCartItemApi,
getCartItemApi,
submitHighIntentApi,
savePreferSkuApi,
ensureShareCodeApi,
} from "@/api/page/cart";
export default {
data() {
return {
listId: '',
shareCodeQuery: '',
listName: '清单详情',
shareCode: '',
isOwner: true,
info: { items: [] },
is_high_intent: false,
quote_status: 0,
quote_visible: false,
quote_final_total: null,
quote_apply_require_sku: false,
applyPopupShow: false,
intentRemark: '',
skuPickerShow: false,
skuPickerItem: null,
skuPickerOptions: [],
sharePreparing: false,
}
},
computed: {
canSelectSku() {
return this.quote_apply_require_sku && !this.quote_visible;
return this.isOwner && !this.quote_visible;
},
skuPickerTitle() {
const t = this.skuPickerItem && this.skuPickerItem.product
@@ -211,10 +260,49 @@ export default {
},
},
onLoad(options) {
this.listId = options.id
this.listId = options.id || '';
this.shareCodeQuery = (options.code || options.share_code || '').toUpperCase();
this.getInfo();
},
onShareAppMessage() {
const code = this.shareCode || '';
const name = this.listName || '清单';
const path = code
? `/pages/cart/detail?code=${code}`
: `/pages/cart/detail?id=${this.listId}`;
return {
title: code ? `${name}(编码 ${code})` : name,
path,
};
},
methods: {
async prepareShare() {
if (!this.isOwner || this.sharePreparing) return;
if (this.shareCode) return;
this.sharePreparing = true;
try {
const res = await ensureShareCodeApi(this.listId);
this.shareCode = (res && res.share_code) || '';
if (this.shareCode) {
uni.showToast({ title: '编码已生成,请再点分享', icon: 'none' });
} else {
uni.showToast({ title: '生成编码失败', icon: 'none' });
}
} catch (e) {
uni.showToast({ title: '生成编码失败', icon: 'none' });
} finally {
this.sharePreparing = false;
}
},
copyShareCode() {
if (!this.shareCode) return;
uni.setClipboardData({
data: this.shareCode,
success: () => {
uni.showToast({ title: '编码已复制', icon: 'success' });
},
});
},
specStatusText(item) {
if (this.quote_visible && item.has_prefer_sku) {
return '报价规格';
@@ -305,6 +393,9 @@ export default {
return options;
},
openSkuPicker(item) {
if (!this.canSelectSku) {
return;
}
this.skuPickerItem = item;
this.skuPickerOptions = this.buildSkuOptions(item);
this.skuPickerShow = true;
@@ -349,17 +440,24 @@ export default {
},
getInfo() {
uni.showLoading({ title: '加载中...' })
getCartItemApi(this.listId).then((res) => {
const params = this.shareCodeQuery
? { share_code: this.shareCodeQuery }
: { id: this.listId }
getCartItemApi(params).then((res) => {
const data = res || { items: [] };
this.info = data;
this.listId = data.id || this.listId;
this.listName = data.name || '清单详情';
this.shareCode = data.share_code || '';
this.isOwner = data.is_owner !== false;
this.is_high_intent = !!data.is_high_intent;
this.quote_status = data.quote_status || 0;
this.quote_visible = !!data.quote_visible;
this.quote_final_total = data.quote_final_total;
this.quote_apply_require_sku = Number(data.quote_apply_require_sku || 0) === 1;
this.intentRemark = data.intent_remark || '';
uni.hideLoading()
}).catch(() => {
uni.hideLoading()
})
},
goToQuote() {
@@ -374,13 +472,11 @@ export default {
this.applyPopupShow = false;
},
confirmApply() {
if (this.quote_apply_require_sku) {
const items = this.info.items || [];
const missing = items.some((it) => !it.selected_sku);
if (missing) {
uni.showToast({ title: '请先选择全部规格', icon: 'none' });
return;
}
const items = this.info.items || [];
const missing = items.some((it) => !it.selected_sku);
if (missing) {
uni.showToast({ title: '请先选择全部规格', icon: 'none' });
return;
}
uni.showLoading({ title: '提交中...' });
submitHighIntentApi(this.listId, this.intentRemark || '').then(() => {
@@ -495,6 +591,20 @@ view, scroll-view, text, image {
letter-spacing: 1rpx;
}
.code-badge-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.copy-badge {
font-size: 20rpx;
color: #B4854D;
padding: 2rpx 12rpx;
border: 1rpx solid #B4854D;
border-radius: 8rpx;
}
.list-container {
flex: 1;
width: 100%;
@@ -722,6 +832,34 @@ view, scroll-view, text, image {
height: 200rpx;
}
.share-fixed-btn {
position: fixed;
top: 40rpx;
right: 30rpx;
width: 72rpx;
height: 72rpx;
padding: 0;
margin: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
z-index: 999;
border: none;
line-height: 1;
}
.share-fixed-btn::after {
border: none;
}
.share-bar-btn {
flex: 0.85;
}
.action-bar-wrapper {
position: fixed;
bottom: 0;

View File

@@ -66,19 +66,36 @@
</view>
</view>
<!-- 添加清单弹窗 -->
<!-- 添加清单弹窗:先选规格,再选清单 -->
<up-popup
:show="listModal"
title="添加到清单"
mode="bottom"
:round="24"
@close="listModal = false"
@close="closeListModal"
:safeAreaInsetTop="true"
:closeable="true"
bgColor="#F7F8FA"
>
<view class="popup-content">
<scroll-view scroll-y style="max-height: 600rpx;">
<view class="sku-pick-section">
<text class="sku-pick-label">请选择规格</text>
<scroll-view scroll-y class="sku-pick-scroll">
<view
v-for="(opt, idx) in addSkuOptions"
:key="idx"
class="sku-pick-item"
:class="{ 'sku-pick-item--active': isAddSkuSelected(opt) }"
@tap="selectAddSku(opt)"
>
<text class="sku-pick-name">{{ opt.displayLabel }}</text>
<text class="sku-pick-price" v-if="opt.priceText">{{ opt.priceText }}</text>
</view>
<view v-if="!addSkuOptions.length" class="sku-pick-empty">暂无可选规格</view>
</scroll-view>
</view>
<text class="sku-pick-label">选择清单</text>
<scroll-view scroll-y style="max-height: 360rpx;">
<view class="list-options">
<view
v-for="(item, index) in listOption"
@@ -167,6 +184,9 @@ export default {
avatar: ''
},
listOption: [],
addSkuOptions: [],
selectedPriceSheetId: 0,
selectedRoutineIndex: 0,
product: {
title: '',
cover: '',
@@ -204,12 +224,71 @@ export default {
uni.navigateBack();
},
showModal() {
this.selectedPriceSheetId = 0;
this.selectedRoutineIndex = 0;
this.addSkuOptions = this.buildAddSkuOptions();
this.getListOption();
this.listModal = true;
},
closeListModal() {
this.listModal = false;
},
parseRoutineLabel(raw) {
const s = String(raw || '').trim();
if (!s) return '常规';
const m = s.match(/^(.*?)[::](.+)$/);
if (m) {
return (m[1] || '').trim() || '常规';
}
return '常规';
},
buildAddSkuOptions() {
const sheets = (this.product && this.product.price_sheet) || [];
const options = [];
sheets.forEach((spec) => {
const parts = [spec.specification, spec.dimension].filter(Boolean);
const sheetTitle = parts.join(' / ') || '规格';
const routines = Array.isArray(spec.routine) ? spec.routine : [];
if (!routines.length) {
options.push({
price_sheet_id: spec.id,
routine_index: 0,
displayLabel: sheetTitle,
priceText: '',
});
return;
}
routines.forEach((raw, pIndex) => {
const label = this.parseRoutineLabel(raw);
const displayLabel = label && label !== '常规'
? `${sheetTitle} / ${label}`
: sheetTitle;
options.push({
price_sheet_id: spec.id,
routine_index: pIndex,
displayLabel,
priceText: String(raw || ''),
});
});
});
return options;
},
selectAddSku(opt) {
this.selectedPriceSheetId = Number(opt.price_sheet_id);
this.selectedRoutineIndex = Number(opt.routine_index);
},
isAddSkuSelected(opt) {
return Number(this.selectedPriceSheetId) === Number(opt.price_sheet_id)
&& Number(this.selectedRoutineIndex) === Number(opt.routine_index)
&& Number(this.selectedPriceSheetId) > 0;
},
addToCart(id) {
toCartApi(id, this.id).then(() => {
this.listModal = false;
if (!this.selectedPriceSheetId) {
uni.showToast({ title: '请先选择规格', icon: 'none' });
return;
}
toCartApi(id, this.id, this.selectedPriceSheetId, this.selectedRoutineIndex).then(() => {
this.closeListModal();
uni.showToast({
title: '已添加到清单',
icon: 'success'
@@ -500,6 +579,59 @@ export default {
padding: 30rpx;
}
.sku-pick-section {
margin-bottom: 24rpx;
}
.sku-pick-label {
display: block;
font-size: 26rpx;
color: #666;
font-weight: 600;
margin-bottom: 16rpx;
}
.sku-pick-scroll {
max-height: 280rpx;
margin-bottom: 8rpx;
}
.sku-pick-item {
background: #fff;
padding: 20rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
border: 2rpx solid transparent;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
}
.sku-pick-item--active {
border-color: #B4854D;
background: rgba(180, 133, 77, 0.08);
}
.sku-pick-name {
font-size: 28rpx;
color: #333;
flex: 1;
}
.sku-pick-price {
font-size: 24rpx;
color: #B4854D;
flex-shrink: 0;
}
.sku-pick-empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 24rpx 0;
}
.list-options {
display: flex;
flex-direction: column;