添加新的工作流和临时文件
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.viaeon.com
|
||||
IMAGE_NAME: git.viaeon.com/admin/sale
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.GITEA_REGISTRY_USER }}
|
||||
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -0,0 +1,931 @@
|
||||
<template>
|
||||
<div class="cart-page">
|
||||
<BackNav />
|
||||
|
||||
<el-empty v-if="!items.length" description="购物车是空的" :image-size="120">
|
||||
<template #image>
|
||||
<el-icon :size="80" color="rgba(255,255,255,0.2)"><ShoppingCart /></el-icon>
|
||||
</template>
|
||||
<el-button type="primary" @click="$router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else class="cart-container">
|
||||
<div class="cart-left">
|
||||
<div class="cart-main">
|
||||
<div class="cart-header">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<span class="header-product">商品信息</span>
|
||||
<span class="header-price">单价</span>
|
||||
<span class="header-quantity">数量</span>
|
||||
<span class="header-subtotal">小计</span>
|
||||
<span class="header-action">操作</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cart-item"
|
||||
:class="{ 'item-disabled': !item.product?.is_active }"
|
||||
>
|
||||
<el-checkbox v-model="selectedIds" :value="item.id" />
|
||||
|
||||
<div class="item-product" @click="$router.push(`/products/${item.product?.id}`)">
|
||||
<el-image
|
||||
:src="getFirstImage(item.product?.images)"
|
||||
fit="cover"
|
||||
class="item-image"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="item-info">
|
||||
<h4 class="item-name">{{ item.product?.name }}</h4>
|
||||
<div class="item-tags">
|
||||
<el-tag v-if="item.product?.require_credit" size="small" type="warning">
|
||||
需要资格: {{ item.product?.credit_cost }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.product?.credit_reward > 0" size="small" type="success">
|
||||
奖励: +{{ item.product?.credit_reward }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p v-if="!item.product?.is_active" class="item-off">已下架</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-price">
|
||||
<span class="price-current">¥{{ item.product?.price }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-quantity">
|
||||
<el-input-number
|
||||
v-model="item.quantity"
|
||||
:min="1"
|
||||
:max="getMaxQuantity(item.product)"
|
||||
size="small"
|
||||
@change="(val: number) => updateQuantity(item, val)"
|
||||
/>
|
||||
<span v-if="item.product?.stock !== undefined && item.product?.stock <= 10" class="stock-tip">
|
||||
仅剩 {{ item.product?.stock }} 件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="item-subtotal">
|
||||
<span class="subtotal-price">¥{{ ((item.product?.price || 0) * item.quantity).toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-action">
|
||||
<el-button link type="danger" size="small" @click="removeItem(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section">
|
||||
<h3 class="section-title">
|
||||
<el-icon><Location /></el-icon>
|
||||
收货地址
|
||||
</h3>
|
||||
<div class="address-list" v-if="addresses.length">
|
||||
<div
|
||||
v-for="addr in addresses"
|
||||
:key="addr.id"
|
||||
class="address-item"
|
||||
:class="{ active: selectedAddressId === addr.id }"
|
||||
@click="selectedAddressId = addr.id"
|
||||
>
|
||||
<div class="address-radio">
|
||||
<el-icon v-if="selectedAddressId === addr.id" class="checked"><CircleCheckFilled /></el-icon>
|
||||
<span v-else class="unchecked"></span>
|
||||
</div>
|
||||
<div class="address-content">
|
||||
<div class="address-header">
|
||||
<span class="address-name">{{ addr.name }}</span>
|
||||
<span class="address-phone">{{ addr.phone }}</span>
|
||||
<el-tag v-if="addr.is_default" size="small" type="success">默认</el-tag>
|
||||
</div>
|
||||
<div class="address-detail">
|
||||
{{ addr.province }}{{ addr.city }}{{ addr.district }}{{ addr.address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-address" v-else>
|
||||
<p>您还没有添加收货地址</p>
|
||||
<el-button type="primary" @click="showAddAddress = true">添加地址</el-button>
|
||||
</div>
|
||||
<el-button class="add-address-btn" link type="primary" @click="showAddAddress = true" v-if="addresses.length">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加新地址
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section" v-if="availablePayments.length > 0">
|
||||
<h3 class="section-title">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
支付方式
|
||||
</h3>
|
||||
<div class="payment-methods" :class="{ 'single': availablePayments.length === 1 }">
|
||||
<div
|
||||
v-for="method in availablePayments"
|
||||
:key="method.value"
|
||||
class="payment-item"
|
||||
:class="{ active: selectedPayment === method.value }"
|
||||
@click="selectedPayment = method.value"
|
||||
>
|
||||
<el-icon class="payment-icon"><component :is="method.icon" /></el-icon>
|
||||
<span class="payment-name">{{ method.label }}</span>
|
||||
<el-icon v-if="selectedPayment === method.value" class="check-icon"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-summary-panel">
|
||||
<h3 class="summary-title">订单摘要</h3>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>商品金额</span>
|
||||
<span>¥{{ selectedTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>运费</span>
|
||||
<span v-if="shippingFee > 0">¥{{ shippingFee.toFixed(2) }}</span>
|
||||
<span v-else class="free-shipping">免运费</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="serviceFee > 0">
|
||||
<span>服务费 ({{ settings.service_fee_rate || 0 }}%)</span>
|
||||
<span>¥{{ serviceFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="taxFee > 0">
|
||||
<span>税费 ({{ settings.tax_rate || 0 }}%)</span>
|
||||
<span>¥{{ taxFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-divider"></div>
|
||||
|
||||
<div class="summary-row summary-total">
|
||||
<span>应付总额</span>
|
||||
<span class="total-amount">¥{{ grandTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-count">
|
||||
已选择 <strong>{{ selectedIds.length }}</strong> 件商品
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="checkout-btn"
|
||||
:disabled="!canCheckout"
|
||||
:loading="submitting"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
提交订单
|
||||
</el-button>
|
||||
|
||||
<div class="summary-actions">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<el-button link type="danger" size="small" @click="removeSelected" :disabled="!selectedIds.length">
|
||||
删除选中
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAddAddress" title="添加收货地址" width="500px">
|
||||
<el-form :model="addressForm" label-width="100px">
|
||||
<el-form-item label="收货人" required><el-input v-model="addressForm.name" placeholder="请输入收货人姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" required><el-input v-model="addressForm.phone" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="省份"><el-input v-model="addressForm.province" placeholder="省/直辖市" /></el-form-item>
|
||||
<el-form-item label="城市"><el-input v-model="addressForm.city" placeholder="市/区" /></el-form-item>
|
||||
<el-form-item label="区县"><el-input v-model="addressForm.district" placeholder="区/县" /></el-form-item>
|
||||
<el-form-item label="详细地址" required><el-input v-model="addressForm.address" type="textarea" :rows="2" placeholder="街道、门牌号等" /></el-form-item>
|
||||
<el-form-item><el-switch v-model="addressForm.is_default" active-text="设为默认地址" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddAddress = false">取消</el-button>
|
||||
<el-button type="primary" @click="addAddress">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ShoppingCart, Picture, Location, CreditCard, Plus, CircleCheckFilled,
|
||||
Wallet, Coin, ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useCartStore } from '../../store/cart'
|
||||
import { systemApi, addressApi, orderApi, paymentChannelApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const items = computed(() => cartStore.items)
|
||||
const selectedIds = ref<number[]>([])
|
||||
const settings = ref<Record<string, string>>({})
|
||||
const addresses = ref<any[]>([])
|
||||
const selectedAddressId = ref<number>()
|
||||
const selectedPayment = ref('')
|
||||
const showAddAddress = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const addressForm = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
is_default: false
|
||||
})
|
||||
|
||||
const typeIconMap: Record<string, any> = {
|
||||
balance: markRaw(Wallet),
|
||||
alipay: markRaw(Coin),
|
||||
wechat: markRaw(ChatDotRound),
|
||||
bepusdt: markRaw(Coin),
|
||||
bank: markRaw(CreditCard),
|
||||
other: markRaw(Coin),
|
||||
}
|
||||
|
||||
const paymentChannels = ref<any[]>([])
|
||||
|
||||
const availablePayments = computed(() => {
|
||||
return paymentChannels.value.map(ch => ({
|
||||
value: ch.code,
|
||||
label: ch.name,
|
||||
icon: typeIconMap[ch.type] || markRaw(Coin),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectAll = computed({
|
||||
get: () => {
|
||||
const activeItems = items.value.filter(i => i.product?.is_active)
|
||||
return activeItems.length > 0 && selectedIds.value.length === activeItems.length
|
||||
},
|
||||
set: () => {}
|
||||
})
|
||||
|
||||
const selectedTotal = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0)
|
||||
})
|
||||
|
||||
const selectedQuantity = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + item.quantity, 0)
|
||||
})
|
||||
|
||||
const shippingFee = computed(() => {
|
||||
const firstWeight = parseFloat(settings.value.shipping_fee_first_weight || '0')
|
||||
const perGram = parseFloat(settings.value.shipping_fee_per_gram || '0')
|
||||
if (selectedTotal.value >= 99 || firstWeight === 0) return 0
|
||||
return firstWeight + perGram * Math.max(0, selectedQuantity.value - 500)
|
||||
})
|
||||
|
||||
const serviceFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.service_fee_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const taxFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.tax_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const grandTotal = computed(() => {
|
||||
return selectedTotal.value + shippingFee.value + serviceFee.value + taxFee.value
|
||||
})
|
||||
|
||||
const canCheckout = computed(() => {
|
||||
if (selectedIds.value.length === 0) return false
|
||||
if (!selectedAddressId.value) return false
|
||||
const selectedItems = items.value.filter(item => selectedIds.value.includes(item.id))
|
||||
return selectedItems.every(item => item.product?.is_active)
|
||||
})
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res: any = await systemApi.getPublicSettings()
|
||||
settings.value = res.data || {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchPaymentChannels() {
|
||||
try {
|
||||
const res: any = await paymentChannelApi.list()
|
||||
paymentChannels.value = res.data || []
|
||||
if (paymentChannels.value.length > 0 && !selectedPayment.value) {
|
||||
selectedPayment.value = paymentChannels.value[0].code
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
const res: any = await addressApi.list()
|
||||
addresses.value = res.data || []
|
||||
const defaultAddr = addresses.value.find((a: any) => a.is_default)
|
||||
if (defaultAddr && !selectedAddressId.value) {
|
||||
selectedAddressId.value = defaultAddr.id
|
||||
} else if (addresses.value.length > 0 && !selectedAddressId.value) {
|
||||
selectedAddressId.value = addresses.value[0].id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function addAddress() {
|
||||
if (!addressForm.name || !addressForm.phone || !addressForm.address) {
|
||||
ElMessage.warning('请填写必要信息')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addressApi.create(addressForm)
|
||||
ElMessage.success('地址已添加')
|
||||
showAddAddress.value = false
|
||||
Object.assign(addressForm, { name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
|
||||
await fetchAddresses()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleSelectAll(val: boolean) {
|
||||
if (val) {
|
||||
selectedIds.value = items.value.filter(i => i.product?.is_active).map(i => i.id)
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxQuantity(product: any) {
|
||||
if (!product) return 99
|
||||
let max = 999
|
||||
if (product.stock !== undefined && product.stock !== null) {
|
||||
max = product.stock
|
||||
}
|
||||
if (product.max_purchase && product.max_purchase > 0) {
|
||||
max = Math.min(max, product.max_purchase)
|
||||
}
|
||||
return Math.max(1, max)
|
||||
}
|
||||
|
||||
async function updateQuantity(item: any, quantity: number) {
|
||||
if (quantity < 1) return
|
||||
await cartStore.updateItem(item.id, quantity)
|
||||
}
|
||||
|
||||
async function removeItem(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', { type: 'warning' })
|
||||
await cartStore.removeItem(id)
|
||||
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 件商品吗?`, '提示', { type: 'warning' })
|
||||
await cartStore.removeItems(selectedIds.value)
|
||||
selectedIds.value = []
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
if (!canCheckout.value) {
|
||||
if (!selectedAddressId.value) {
|
||||
ElMessage.warning('请选择收货地址')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const unavailableItems = items.value
|
||||
.filter(item => selectedIds.value.includes(item.id) && !item.product?.is_active)
|
||||
|
||||
if (unavailableItems.length > 0) {
|
||||
ElMessage.warning('请移除已下架的商品')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res: any = await orderApi.create({
|
||||
shipping_address_id: selectedAddressId.value,
|
||||
payment_method: selectedPayment.value,
|
||||
cart_item_ids: selectedIds.value
|
||||
})
|
||||
ElMessage.success('订单创建成功')
|
||||
await cartStore.fetchCart()
|
||||
const orderId = res.data?.id
|
||||
const selectedChannel = paymentChannels.value.find(ch => ch.code === selectedPayment.value)
|
||||
if (selectedChannel?.type === 'bepusdt' && orderId) {
|
||||
router.push(`/checkout/${orderId}`)
|
||||
} else {
|
||||
router.push('/orders')
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.error || '订单创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
selectedIds.value = selectedIds.value.filter(id =>
|
||||
items.value.some(item => item.id === id)
|
||||
)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
cartStore.fetchCart()
|
||||
fetchSettings()
|
||||
fetchAddresses()
|
||||
fetchPaymentChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cart-page {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.cart-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cart-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-main {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
|
||||
.el-checkbox__label {
|
||||
font-size: 13px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-product { flex: 1; min-width: 200px; }
|
||||
.header-price { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
.header-quantity { width: 140px; text-align: center; flex-shrink: 0; }
|
||||
.header-subtotal { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
.header-action { width: 60px; text-align: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.item-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .el-checkbox { width: 60px; flex-shrink: 0; }
|
||||
> .item-product { flex: 1; min-width: 200px; }
|
||||
> .item-price { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
> .item-quantity { width: 140px; display: flex; flex-direction: column; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
> .item-subtotal { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
> .item-action { width: 60px; display: flex; justify-content: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.item-product {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-off {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.price-current {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.stock-tip {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.item-subtotal {
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.subtotal-price {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.item-action {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkout-section {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
.el-icon {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.address-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
|
||||
.checked {
|
||||
color: #4e6ef2;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.unchecked {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.address-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.address-phone {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-address {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-address-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
&.single {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 28px;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
span:last-child {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.free-shipping {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
margin-bottom: 16px;
|
||||
|
||||
span:first-child {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-count {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: #4e6ef2;
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.cart-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
position: static;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<template>
|
||||
<div class="cart-page">
|
||||
<BackNav />
|
||||
|
||||
<el-empty v-if="!items.length" description="购物车是空的" :image-size="120">
|
||||
<template #image>
|
||||
<el-icon :size="80" color="rgba(255,255,255,0.2)"><ShoppingCart /></el-icon>
|
||||
</template>
|
||||
<el-button type="primary" @click="$router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else class="cart-container">
|
||||
<div class="cart-left">
|
||||
<div class="cart-main">
|
||||
<div class="cart-header">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<span class="header-product">商品信息</span>
|
||||
<span class="header-price">单价</span>
|
||||
<span class="header-quantity">数量</span>
|
||||
<span class="header-subtotal">小计</span>
|
||||
<span class="header-action">操作</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cart-item"
|
||||
:class="{ 'item-disabled': !item.product?.is_active }"
|
||||
>
|
||||
<el-checkbox v-model="selectedIds" :value="item.id" />
|
||||
|
||||
<div class="item-product" @click="$router.push(`/products/${item.product?.id}`)">
|
||||
<el-image
|
||||
:src="getFirstImage(item.product?.images)"
|
||||
fit="cover"
|
||||
class="item-image"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="item-info">
|
||||
<h4 class="item-name">{{ item.product?.name }}</h4>
|
||||
<div class="item-tags">
|
||||
<el-tag v-if="item.product?.require_credit" size="small" type="warning">
|
||||
需要资格: {{ item.product?.credit_cost }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.product?.credit_reward > 0" size="small" type="success">
|
||||
奖励: +{{ item.product?.credit_reward }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p v-if="!item.product?.is_active" class="item-off">已下架</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-price">
|
||||
<span class="price-current">¥{{ item.product?.price }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-quantity">
|
||||
<el-input-number
|
||||
v-model="item.quantity"
|
||||
:min="1"
|
||||
:max="getMaxQuantity(item.product)"
|
||||
size="small"
|
||||
@change="(val: number) => updateQuantity(item, val)"
|
||||
/>
|
||||
<span v-if="item.product?.stock !== undefined && item.product?.stock <= 10" class="stock-tip">
|
||||
仅剩 {{ item.product?.stock }} 件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="item-subtotal">
|
||||
<span class="subtotal-price">¥{{ ((item.product?.price || 0) * item.quantity).toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-action">
|
||||
<el-button link type="danger" size="small" @click="removeItem(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section">
|
||||
<h3 class="section-title">
|
||||
<el-icon><Location /></el-icon>
|
||||
收货地址
|
||||
</h3>
|
||||
<div class="address-list" v-if="addresses.length">
|
||||
<div
|
||||
v-for="addr in addresses"
|
||||
:key="addr.id"
|
||||
class="address-item"
|
||||
:class="{ active: selectedAddressId === addr.id }"
|
||||
@click="selectedAddressId = addr.id"
|
||||
>
|
||||
<div class="address-radio">
|
||||
<el-icon v-if="selectedAddressId === addr.id" class="checked"><CircleCheckFilled /></el-icon>
|
||||
<span v-else class="unchecked"></span>
|
||||
</div>
|
||||
<div class="address-content">
|
||||
<div class="address-header">
|
||||
<span class="address-name">{{ addr.name }}</span>
|
||||
<span class="address-phone">{{ addr.phone }}</span>
|
||||
<el-tag v-if="addr.is_default" size="small" type="success">默认</el-tag>
|
||||
</div>
|
||||
<div class="address-detail">
|
||||
{{ addr.province }}{{ addr.city }}{{ addr.district }}{{ addr.address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-address" v-else>
|
||||
<p>您还没有添加收货地址</p>
|
||||
<el-button type="primary" @click="showAddAddress = true">添加地址</el-button>
|
||||
</div>
|
||||
<el-button class="add-address-btn" link type="primary" @click="showAddAddress = true" v-if="addresses.length">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加新地址
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="checkout-section" v-if="availablePayments.length > 0">
|
||||
<h3 class="section-title">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
支付方式
|
||||
</h3>
|
||||
<div class="payment-methods" :class="{ 'single': availablePayments.length === 1 }">
|
||||
<div
|
||||
v-for="method in availablePayments"
|
||||
:key="method.value"
|
||||
class="payment-item"
|
||||
:class="{ active: selectedPayment === method.value }"
|
||||
@click="selectedPayment = method.value"
|
||||
>
|
||||
<el-icon class="payment-icon"><component :is="method.icon" /></el-icon>
|
||||
<span class="payment-name">{{ method.label }}</span>
|
||||
<el-icon v-if="selectedPayment === method.value" class="check-icon"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-summary-panel">
|
||||
<h3 class="summary-title">订单摘要</h3>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>商品金额</span>
|
||||
<span>¥{{ selectedTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>运费</span>
|
||||
<span v-if="shippingFee > 0">¥{{ shippingFee.toFixed(2) }}</span>
|
||||
<span v-else class="free-shipping">免运费</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="serviceFee > 0">
|
||||
<span>服务费 ({{ settings.service_fee_rate || 0 }}%)</span>
|
||||
<span>¥{{ serviceFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row" v-if="taxFee > 0">
|
||||
<span>税费 ({{ settings.tax_rate || 0 }}%)</span>
|
||||
<span>¥{{ taxFee.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-divider"></div>
|
||||
|
||||
<div class="summary-row summary-total">
|
||||
<span>应付总额</span>
|
||||
<span class="total-amount">¥{{ grandTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-count">
|
||||
已选择 <strong>{{ selectedIds.length }}</strong> 件商品
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="checkout-btn"
|
||||
:disabled="!canCheckout"
|
||||
:loading="submitting"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
提交订单
|
||||
</el-button>
|
||||
|
||||
<div class="summary-actions">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
|
||||
<el-button link type="danger" size="small" @click="removeSelected" :disabled="!selectedIds.length">
|
||||
删除选中
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAddAddress" title="添加收货地址" width="500px">
|
||||
<el-form :model="addressForm" label-width="100px">
|
||||
<el-form-item label="收货人" required><el-input v-model="addressForm.name" placeholder="请输入收货人姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" required><el-input v-model="addressForm.phone" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="省份"><el-input v-model="addressForm.province" placeholder="省/直辖市" /></el-form-item>
|
||||
<el-form-item label="城市"><el-input v-model="addressForm.city" placeholder="市/区" /></el-form-item>
|
||||
<el-form-item label="区县"><el-input v-model="addressForm.district" placeholder="区/县" /></el-form-item>
|
||||
<el-form-item label="详细地址" required><el-input v-model="addressForm.address" type="textarea" :rows="2" placeholder="街道、门牌号等" /></el-form-item>
|
||||
<el-form-item><el-switch v-model="addressForm.is_default" active-text="设为默认地址" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddAddress = false">取消</el-button>
|
||||
<el-button type="primary" @click="addAddress">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ShoppingCart, Picture, Location, CreditCard, Plus, CircleCheckFilled,
|
||||
Wallet, Coin, ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useCartStore } from '../../store/cart'
|
||||
import { systemApi, addressApi, orderApi, paymentChannelApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const items = computed(() => cartStore.items)
|
||||
const selectedIds = ref<number[]>([])
|
||||
const settings = ref<Record<string, string>>({})
|
||||
const addresses = ref<any[]>([])
|
||||
const selectedAddressId = ref<number>()
|
||||
const selectedPayment = ref('')
|
||||
const showAddAddress = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const addressForm = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
is_default: false
|
||||
})
|
||||
|
||||
const typeIconMap: Record<string, any> = {
|
||||
balance: markRaw(Wallet),
|
||||
alipay: markRaw(Coin),
|
||||
wechat: markRaw(ChatDotRound),
|
||||
bepusdt: markRaw(Coin),
|
||||
bank: markRaw(CreditCard),
|
||||
other: markRaw(Coin),
|
||||
}
|
||||
|
||||
const paymentChannels = ref<any[]>([])
|
||||
|
||||
const availablePayments = computed(() => {
|
||||
return paymentChannels.value.map(ch => ({
|
||||
value: ch.code,
|
||||
label: ch.name,
|
||||
icon: typeIconMap[ch.type] || markRaw(Coin),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectAll = computed({
|
||||
get: () => {
|
||||
const activeItems = items.value.filter(i => i.product?.is_active)
|
||||
return activeItems.length > 0 && selectedIds.value.length === activeItems.length
|
||||
},
|
||||
set: () => {}
|
||||
})
|
||||
|
||||
const selectedTotal = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0)
|
||||
})
|
||||
|
||||
const selectedQuantity = computed(() => {
|
||||
return items.value
|
||||
.filter(item => selectedIds.value.includes(item.id))
|
||||
.reduce((sum, item) => sum + item.quantity, 0)
|
||||
})
|
||||
|
||||
const shippingFee = computed(() => {
|
||||
const firstWeight = parseFloat(settings.value.shipping_fee_first_weight || '0')
|
||||
const perGram = parseFloat(settings.value.shipping_fee_per_gram || '0')
|
||||
if (selectedTotal.value >= 99 || firstWeight === 0) return 0
|
||||
return firstWeight + perGram * Math.max(0, selectedQuantity.value - 500)
|
||||
})
|
||||
|
||||
const serviceFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.service_fee_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const taxFee = computed(() => {
|
||||
const rate = parseFloat(settings.value.tax_rate || '0')
|
||||
return selectedTotal.value * rate / 100
|
||||
})
|
||||
|
||||
const grandTotal = computed(() => {
|
||||
return selectedTotal.value + shippingFee.value + serviceFee.value + taxFee.value
|
||||
})
|
||||
|
||||
const canCheckout = computed(() => {
|
||||
if (selectedIds.value.length === 0) return false
|
||||
if (!selectedAddressId.value) return false
|
||||
const selectedItems = items.value.filter(item => selectedIds.value.includes(item.id))
|
||||
return selectedItems.every(item => item.product?.is_active)
|
||||
})
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res: any = await systemApi.getPublicSettings()
|
||||
settings.value = res.data || {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchPaymentChannels() {
|
||||
try {
|
||||
const res: any = await paymentChannelApi.list()
|
||||
paymentChannels.value = res.data || []
|
||||
if (paymentChannels.value.length > 0 && !selectedPayment.value) {
|
||||
selectedPayment.value = paymentChannels.value[0].code
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
const res: any = await addressApi.list()
|
||||
addresses.value = res.data || []
|
||||
const defaultAddr = addresses.value.find((a: any) => a.is_default)
|
||||
if (defaultAddr && !selectedAddressId.value) {
|
||||
selectedAddressId.value = defaultAddr.id
|
||||
} else if (addresses.value.length > 0 && !selectedAddressId.value) {
|
||||
selectedAddressId.value = addresses.value[0].id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function addAddress() {
|
||||
if (!addressForm.name || !addressForm.phone || !addressForm.address) {
|
||||
ElMessage.warning('请填写必要信息')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addressApi.create(addressForm)
|
||||
ElMessage.success('地址已添加')
|
||||
showAddAddress.value = false
|
||||
Object.assign(addressForm, { name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
|
||||
await fetchAddresses()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleSelectAll(val: boolean) {
|
||||
if (val) {
|
||||
selectedIds.value = items.value.filter(i => i.product?.is_active).map(i => i.id)
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxQuantity(product: any) {
|
||||
if (!product) return 99
|
||||
let max = 999
|
||||
if (product.stock !== undefined && product.stock !== null) {
|
||||
max = product.stock
|
||||
}
|
||||
if (product.max_purchase && product.max_purchase > 0) {
|
||||
max = Math.min(max, product.max_purchase)
|
||||
}
|
||||
return Math.max(1, max)
|
||||
}
|
||||
|
||||
async function updateQuantity(item: any, quantity: number) {
|
||||
if (quantity < 1) return
|
||||
await cartStore.updateItem(item.id, quantity)
|
||||
}
|
||||
|
||||
async function removeItem(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', { type: 'warning' })
|
||||
await cartStore.removeItem(id)
|
||||
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 件商品吗?`, '提示', { type: 'warning' })
|
||||
await cartStore.removeItems(selectedIds.value)
|
||||
selectedIds.value = []
|
||||
ElMessage.success('已删除')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
if (!canCheckout.value) {
|
||||
if (!selectedAddressId.value) {
|
||||
ElMessage.warning('请选择收货地址')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const unavailableItems = items.value
|
||||
.filter(item => selectedIds.value.includes(item.id) && !item.product?.is_active)
|
||||
|
||||
if (unavailableItems.length > 0) {
|
||||
ElMessage.warning('请移除已下架的商品')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res: any = await orderApi.create({
|
||||
shipping_address_id: selectedAddressId.value,
|
||||
payment_method: selectedPayment.value,
|
||||
cart_item_ids: selectedIds.value
|
||||
})
|
||||
ElMessage.success('订单创建成功')
|
||||
await cartStore.fetchCart()
|
||||
const orderId = res.data?.id
|
||||
const selectedChannel = paymentChannels.value.find(ch => ch.code === selectedPayment.value)
|
||||
if (selectedChannel?.type === 'bepusdt' && orderId) {
|
||||
router.push(`/checkout/${orderId}`)
|
||||
} else {
|
||||
router.push('/orders')
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.error || '订单创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
selectedIds.value = selectedIds.value.filter(id =>
|
||||
items.value.some(item => item.id === id)
|
||||
)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
cartStore.fetchCart()
|
||||
fetchSettings()
|
||||
fetchAddresses()
|
||||
fetchPaymentChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cart-page {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.cart-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cart-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-main {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
|
||||
.el-checkbox__label {
|
||||
font-size: 13px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-product { flex: 1; min-width: 200px; }
|
||||
.header-price { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
.header-quantity { width: 140px; text-align: center; flex-shrink: 0; }
|
||||
.header-subtotal { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
.header-action { width: 60px; text-align: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.item-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .el-checkbox { width: 60px; flex-shrink: 0; }
|
||||
> .item-product { flex: 1; min-width: 200px; }
|
||||
> .item-price { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
> .item-quantity { width: 140px; display: flex; flex-direction: column; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
> .item-subtotal { width: 100px; text-align: center; flex-shrink: 0; }
|
||||
> .item-action { width: 60px; display: flex; justify-content: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.item-product {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-off {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.price-current {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.stock-tip {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.item-subtotal {
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.subtotal-price {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.item-action {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkout-section {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
.el-icon {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
|
||||
.address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.address-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
|
||||
.checked {
|
||||
color: #4e6ef2;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.unchecked {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.address-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.address-phone {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-address {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-address-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
&.single {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(78, 110, 242, 0.3);
|
||||
background: rgba(78, 110, 242, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #4e6ef2;
|
||||
background: rgba(78, 110, 242, 0.1);
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 28px;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
span:last-child {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.free-shipping {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
margin-bottom: 16px;
|
||||
|
||||
span:first-child {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-count {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
color: #4e6ef2;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: #4e6ef2;
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.cart-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
position: static;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
Submodule
+1
Submodule license added at 76035c43cc
Reference in New Issue
Block a user