Initial commit: 商品售卖网站

This commit is contained in:
2026-04-13 07:20:09 +08:00
commit c6154273f2
865 changed files with 26573 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
<template>
<div class="addresses-page">
<BackNav />
<div class="page-header">
<el-button type="primary" @click="showAdd = true">{{ $t('address.addAddress') }}</el-button>
</div>
<div v-for="a in addresses" :key="a.id" class="address-card">
<div class="address-info">
<strong>{{ a.name }}</strong> {{ a.phone }}
<p>{{ a.province }}{{ a.city }}{{ a.district }}{{ a.address }}</p>
</div>
<div class="address-actions">
<el-tag v-if="a.is_default" type="success" size="small">默认</el-tag>
<el-button v-else link size="small" @click="setDefault(a.id)">{{ $t('address.setDefault') }}</el-button>
<el-button link type="danger" size="small" @click="deleteAddr(a.id)">{{ $t('common.delete') }}</el-button>
</div>
</div>
<el-dialog v-model="showAdd" :title="$t('address.addAddress')" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item :label="$t('address.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="$t('address.phone')"><el-input v-model="form.phone" /></el-form-item>
<el-form-item :label="$t('address.province')"><el-input v-model="form.province" /></el-form-item>
<el-form-item :label="$t('address.city')"><el-input v-model="form.city" /></el-form-item>
<el-form-item :label="$t('address.district')"><el-input v-model="form.district" /></el-form-item>
<el-form-item :label="$t('address.detail')"><el-input v-model="form.address" type="textarea" /></el-form-item>
<el-form-item><el-switch v-model="form.is_default" active-text="默认地址" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="addAddress">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { addressApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const addresses = ref<any[]>([])
const showAdd = ref(false)
const form = reactive({ name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
async function fetchAddresses() {
const res: any = await addressApi.list()
addresses.value = res.data || []
}
async function addAddress() {
await addressApi.create(form)
ElMessage.success('地址已添加')
showAdd.value = false
Object.assign(form, { name: '', phone: '', province: '', city: '', district: '', address: '', is_default: false })
await fetchAddresses()
}
async function setDefault(id: number) {
await addressApi.setDefault(id)
await fetchAddresses()
}
async function deleteAddr(id: number) {
await addressApi.delete(id)
ElMessage.success('地址已删除')
await fetchAddresses()
}
onMounted(fetchAddresses)
</script>
<style scoped lang="scss">
.addresses-page { padding: 0; }
.page-header { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 24px; }
.address-card {
display: flex; justify-content: space-between; align-items: center;
background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; margin-bottom: 12px;
}
.address-info strong { color: #fff; margin-right: 8px; }
.address-info p { color: rgba(255, 255, 255, 0.5); margin-top: 4px; font-size: 14px; }
.address-actions { display: flex; align-items: center; gap: 8px; }
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="article-detail-page">
<BackNav />
<div v-if="article" class="detail-card">
<div class="article-meta">
<el-tag v-if="article.is_pinned" type="danger" size="small">置顶</el-tag>
<span class="article-time">{{ formatDate(article.created_at) }}</span>
</div>
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-content" v-html="article.content"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { articleApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const article = ref<any>(null)
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(async () => {
const res: any = await articleApi.getById(Number(route.params.id))
article.value = res.data
})
</script>
<style scoped lang="scss">
.article-detail-page { padding: 0; }
.article-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.article-time { color: rgba(255, 255, 255, 0.4); font-size: 14px; }
.article-title { font-size: 28px; font-weight: 700; color: #fff; margin-bottom: 24px; line-height: 1.4; }
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
.article-content { font-size: 16px; line-height: 1.8; color: rgba(255, 255, 255, 0.8); }
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="articles-page">
<BackNav />
<div class="article-list">
<div v-for="a in articles" :key="a.id" class="article-card" @click="$router.push(`/articles/${a.id}`)">
<div class="article-cover" v-if="a.cover_image">
<img :src="getImageUrl(a.cover_image)" alt="" />
</div>
<div class="article-body">
<div class="article-header">
<el-tag v-if="a.is_pinned" type="danger" size="small">置顶</el-tag>
<h3>{{ a.title }}</h3>
</div>
<p class="article-summary">{{ a.summary || a.content?.slice(0, 100) }}</p>
<span class="article-time">{{ formatDate(a.created_at) }}</span>
</div>
</div>
</div>
<el-empty v-if="!articles.length && !loading" />
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchArticles" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { articleApi } from '../../api'
import { getImageUrl } from '../../utils/image'
import BackNav from '../../components/BackNav.vue'
const articles = ref<any[]>([])
const page = ref(1)
const pageSize = 10
const total = ref(0)
const loading = ref(false)
async function fetchArticles() {
loading.value = true
const res: any = await articleApi.list({ page: page.value, page_size: pageSize })
articles.value = res.data || []
total.value = res.pagination?.total || 0
loading.value = false
}
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(fetchArticles)
</script>
<style scoped lang="scss">
.articles-page { padding: 0; }
.article-list { display: flex; flex-direction: column; gap: 16px; }
.article-card {
background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; overflow: hidden; cursor: pointer; transition: all 0.3s; display: flex;
&:hover { transform: translateY(-2px); border-color: rgba(78, 110, 242, 0.3); }
}
.article-cover { width: 200px; min-height: 140px; background: rgba(255, 255, 255, 0.04); img { width: 100%; height: 100%; object-fit: cover; } }
.article-body { flex: 1; padding: 20px; }
.article-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; h3 { font-size: 18px; font-weight: 600; color: #fff; } }
.article-summary { color: rgba(255, 255, 255, 0.5); font-size: 14px; line-height: 1.6; margin-bottom: 8px; }
.article-time { color: rgba(255, 255, 255, 0.3); font-size: 13px; }
.pagination-wrap {
margin-top: 32px;
display: flex;
justify-content: center;
}
.pagination-wrap :deep(.el-pagination) {
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-hover-color: #4e6ef2;
}
.pagination-wrap :deep(.el-pagination .el-pager li) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
margin: 0 4px;
}
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
background: #4e6ef2;
color: #fff;
}
.pagination-wrap :deep(.el-pagination .btn-prev),
.pagination-wrap :deep(.el-pagination .btn-next) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
}
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
background: rgba(78, 110, 242, 0.2);
color: #4e6ef2;
}
</style>
+959
View File
@@ -0,0 +1,959 @@
<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 } 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 allPaymentMethods = [
{ value: 'balance', label: '余额支付', icon: markRaw(Wallet) },
{ value: 'alipay', label: '支付宝', icon: markRaw(Coin) },
{ value: 'wechat', label: '微信支付', icon: markRaw(ChatDotRound) },
]
const availablePayments = computed(() => {
const enabledStr = settings.value.enabled_payments
if (!enabledStr) return [allPaymentMethods[0]]
try {
const enabled = JSON.parse(enabledStr)
return allPaymentMethods.filter(m => enabled.includes(m.value))
} catch {
return [allPaymentMethods[0]]
}
})
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 || {}
if (availablePayments.value.length > 0 && !selectedPayment.value) {
selectedPayment.value = availablePayments.value[0].value
}
} 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 {
await orderApi.create({
shipping_address_id: selectedAddressId.value,
payment_method: selectedPayment.value
})
ElMessage.success('订单创建成功')
await cartStore.fetchCart()
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()
})
</script>
<style scoped lang="scss">
.cart-page {
max-width: 1200px;
margin: 0 auto;
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: 50px;
flex-shrink: 0;
margin-right: 0;
.el-checkbox__label {
font-size: 13px;
padding-left: 8px;
}
}
.header-product { flex: 1; min-width: 200px; padding-left: 12px; }
.header-price { width: 80px; text-align: center; flex-shrink: 0; }
.header-quantity { width: 120px; text-align: center; flex-shrink: 0; }
.header-subtotal { width: 90px; 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: 50px; flex-shrink: 0; }
> .item-product { flex: 1; min-width: 200px; padding-left: 12px; }
> .item-price { width: 80px; text-align: center; flex-shrink: 0; }
> .item-quantity { width: 120px; display: flex; flex-direction: column; align-items: center; gap: 4px; flex-shrink: 0; }
> .item-subtotal { width: 90px; 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;
padding-left: 12px;
}
.item-image {
width: 70px;
height: 70px;
border-radius: 8px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.06);
}
.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: 80px;
flex-shrink: 0;
.price-current {
font-size: 15px;
font-weight: 600;
color: #fff;
}
}
.item-quantity {
width: 120px;
flex-shrink: 0;
.stock-tip {
font-size: 11px;
color: #f59e0b;
}
}
.item-subtotal {
text-align: center;
width: 90px;
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;
order: -1;
margin-bottom: 20px;
}
}
@media (max-width: 768px) {
.cart-header {
display: none;
}
.cart-item {
flex-wrap: wrap;
gap: 12px;
padding: 14px;
> .el-checkbox { width: 24px; order: 1; }
> .item-product { flex: 1; min-width: 0; order: 2; }
> .item-price { width: auto; order: 4; margin-left: 36px; }
> .item-quantity { width: auto; order: 5; }
> .item-subtotal { width: auto; order: 3; margin-left: auto; }
> .item-action { width: auto; order: 6; }
}
.item-product {
flex-direction: column;
gap: 8px;
.item-image {
width: 100%;
height: 120px;
}
}
.payment-methods {
grid-template-columns: 1fr;
}
.cart-left {
gap: 16px;
}
.checkout-section {
padding: 16px;
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="create-ticket-page">
<BackNav />
<div class="form-card">
<el-form :model="form" label-width="100px">
<el-form-item :label="$t('ticket.subject')"><el-input v-model="form.title" /></el-form-item>
<el-form-item :label="$t('ticket.category')">
<el-select v-model="form.category" popper-class="dark-select-dropdown">
<el-option value="order" label="订单" />
<el-option value="account" label="账户" />
<el-option value="product" label="商品" />
<el-option value="other" label="其他" />
</el-select>
</el-form-item>
<el-form-item :label="$t('ticket.content')"><el-input v-model="form.content" type="textarea" :rows="6" /></el-form-item>
<el-form-item><el-button type="primary" @click="submit">{{ $t('common.submit') }}</el-button></el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ticketApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const router = useRouter()
const form = reactive({ title: '', content: '', category: 'other' })
async function submit() {
await ticketApi.create(form)
ElMessage.success('工单已提交')
router.push('/tickets')
}
</script>
<style scoped lang="scss">
.create-ticket-page { padding: 0; }
.form-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
</style>
+218
View File
@@ -0,0 +1,218 @@
<template>
<div class="home-page">
<section class="hero">
<div class="hero-content">
<h1>Discover Premium Products</h1>
<p>Find the best products from trusted suppliers worldwide</p>
<el-button type="primary" size="large" @click="$router.push('/products')">
{{ $t('common.products') }}
</el-button>
</div>
</section>
<section class="section">
<div class="section-header">
<h2 class="section-title">{{ $t('product.category') }}</h2>
</div>
<div class="category-grid">
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="32"><Folder /></el-icon>
</div>
<span class="category-name">{{ cat.name }}</span>
</div>
</div>
</section>
<section class="section">
<div class="section-header">
<h2 class="section-title">{{ $t('common.products') }}</h2>
<el-button link @click="$router.push('/products')">{{ $t('common.search') }} </el-button>
</div>
<div class="product-grid">
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
<div class="product-image">
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="cover" lazy>
<template #error>
<div class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ p.name }}</h3>
<p class="product-price">¥{{ p.price }}</p>
<div class="product-tags">
<el-tag v-if="p.require_credit" size="small" type="warning">{{ $t('product.requireCredit') }}</el-tag>
<el-tag v-if="p.credit_reward > 0" size="small" type="success">+{{ p.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
</div>
</div>
</div>
</div>
</section>
<section class="section" v-if="lotteries.length">
<div class="section-header">
<h2 class="section-title">{{ $t('lottery.title') }}</h2>
<el-button link @click="$router.push('/lotteries')">View All </el-button>
</div>
<div class="lottery-grid">
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
<el-button type="primary" size="small">{{ $t('lottery.register') }}</el-button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Folder, Goods } from '@element-plus/icons-vue'
import { categoryApi, productApi, lotteryApi } from '../../api'
import { getFirstImage } from '../../utils/image'
const router = useRouter()
const categories = ref<any[]>([])
const products = ref<any[]>([])
const lotteries = ref<any[]>([])
function goCategory(id: number) {
router.push({ path: '/products', query: { category_id: String(id) } })
}
onMounted(async () => {
try {
const [catRes, prodRes, lotRes]: any[] = await Promise.all([
categoryApi.list(),
productApi.list({ page: 1, page_size: 8 }),
lotteryApi.list(),
])
categories.value = catRes.data || []
products.value = prodRes.data || []
lotteries.value = (lotRes.data || []).slice(0, 3)
} catch {}
})
</script>
<style scoped lang="scss">
.hero {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff;
padding: 80px 24px;
text-align: center;
h1 { font-size: 42px; font-weight: 700; margin-bottom: 16px; }
p { font-size: 18px; opacity: 0.8; margin-bottom: 32px; }
}
.section {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
color: #fff;
font-size: 24px;
font-weight: 600;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.category-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
}
.category-icon { margin-bottom: 8px; color: #4e6ef2; }
.category-name { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.85); }
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
}
.product-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
}
.product-image {
height: 200px;
background: rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.el-image {
width: 100%;
height: 100%;
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.15);
}
}
.product-info { padding: 16px; }
.product-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: rgba(255, 255, 255, 0.9); }
.product-price { font-size: 20px; font-weight: 700; color: #4e6ef2; margin-bottom: 8px; }
.product-tags { display: flex; gap: 8px; }
.lottery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.lottery-card {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: all 0.3s;
h3 { margin-bottom: 8px; }
p { opacity: 0.8; margin-bottom: 16px; font-size: 14px; }
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<div class="lotteries-page">
<BackNav />
<div class="lottery-grid">
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
<p class="time">{{ formatDate(l.start_time) }} ~ {{ formatDate(l.end_time) }}</p>
</div>
</div>
<el-empty v-if="!lotteries.length && !loading" />
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchLotteries" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { lotteryApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const lotteries = ref<any[]>([])
const page = ref(1)
const pageSize = 6
const total = ref(0)
const loading = ref(false)
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
async function fetchLotteries() {
loading.value = true
const res: any = await lotteryApi.list({ page: page.value, page_size: pageSize })
lotteries.value = res.data || []
total.value = res.pagination?.total || res.data?.length || 0
loading.value = false
}
onMounted(fetchLotteries)
</script>
<style scoped lang="scss">
.lotteries-page { padding: 0; }
.lottery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.lottery-card {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff; border-radius: 12px; padding: 24px; cursor: pointer; transition: all 0.3s;
h3 { margin-bottom: 8px; } p { opacity: 0.8; margin-bottom: 8px; } .time { font-size: 13px; }
&:hover { transform: translateY(-4px); }
}
.pagination-wrap {
margin-top: 32px;
display: flex;
justify-content: center;
}
.pagination-wrap :deep(.el-pagination) {
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-hover-color: #4e6ef2;
}
.pagination-wrap :deep(.el-pagination .el-pager li) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
margin: 0 4px;
}
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
background: #4e6ef2;
color: #fff;
}
.pagination-wrap :deep(.el-pagination .btn-prev),
.pagination-wrap :deep(.el-pagination .btn-next) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
}
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
background: rgba(78, 110, 242, 0.2);
color: #4e6ef2;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<template>
<div class="lottery-detail-page">
<BackNav />
<div v-if="lottery" class="detail-card">
<p class="description">{{ lottery.description }}</p>
<div class="time-info">
<span>活动时间{{ formatDate(lottery.start_time) }} ~ {{ formatDate(lottery.end_time) }}</span>
</div>
<h4 class="prizes-title">{{ $t('lottery.prizes') }}</h4>
<el-table :data="lottery.prizes" size="small">
<el-table-column prop="name" label="奖品" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ prizeTypeText(row.type) }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" />
</el-table>
<el-button type="primary" size="large" style="margin-top:24px" @click="register">{{ $t('lottery.register') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { lotteryApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const lottery = ref<any>(null)
const prizeTypeMap: Record<string, string> = {
product: '商品',
credits: '积分',
cash: '现金',
}
function prizeTypeText(t: string) { return prizeTypeMap[t] || t }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onMounted(async () => {
const res: any = await lotteryApi.getById(Number(route.params.id))
lottery.value = res.data
})
async function register() {
await lotteryApi.register(Number(route.params.id))
ElMessage.success('报名成功')
}
</script>
<style scoped lang="scss">
.lottery-detail-page { padding: 0; }
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
.description { color: rgba(255, 255, 255, 0.5); margin-bottom: 16px; }
.time-info { color: rgba(255, 255, 255, 0.6); font-size: 14px; margin-bottom: 24px; }
.prizes-title { color: #fff; margin: 24px 0 12px; font-size: 16px; }
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="order-detail-page">
<BackNav />
<div v-if="order" class="detail-card">
<div class="status-section">
<el-tag :type="statusType(order.status)" size="large">{{ statusText(order.status) }}</el-tag>
<span v-if="order.payment_method" class="payment-method">支付方式: {{ paymentMethodText(order.payment_method) }}</span>
</div>
<div class="section-title">订单信息</div>
<el-descriptions :column="2" border class="order-desc">
<el-descriptions-item label="商品小计">¥{{ order.subtotal || order.total_amount }}</el-descriptions-item>
<el-descriptions-item label="运费">¥{{ order.shipping_fee || 0 }}</el-descriptions-item>
<el-descriptions-item label="服务费">¥{{ order.service_fee || 0 }}</el-descriptions-item>
<el-descriptions-item label="税费">¥{{ order.tax || 0 }}</el-descriptions-item>
<el-descriptions-item label="订单总额">
<span class="total-amount">¥{{ order.total_amount }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('order.createTime')">{{ formatDate(order.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('order.trackingNumber')" v-if="order.tracking_number">{{ order.tracking_number }}</el-descriptions-item>
</el-descriptions>
<div class="section-title" v-if="order.shipping_address">收货地址</div>
<div class="address-info" v-if="order.shipping_address">
<p><strong>{{ order.shipping_address.name }}</strong> {{ order.shipping_address.phone }}</p>
<p>{{ order.shipping_address.province }}{{ order.shipping_address.city }}{{ order.shipping_address.district }}{{ order.shipping_address.address }}</p>
</div>
<div class="section-title">商品明细</div>
<el-table :data="order.order_items" size="small">
<el-table-column prop="product.name" label="商品" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="price" label="单价" width="100"><template #default="{ row }">¥{{ row.price }}</template></el-table-column>
<el-table-column label="小计" width="100"><template #default="{ row }">¥{{ (row.price * row.quantity).toFixed(2) }}</template></el-table-column>
</el-table>
<div class="refund-section" v-if="canRefund">
<el-button type="danger" @click="showRefund = true">{{ $t('order.applyRefund') }}</el-button>
</div>
<div class="refund-info" v-if="order.refund_status">
<el-tag type="warning">退款状态: {{ refundStatusText(order.refund_status) }}</el-tag>
<p v-if="order.refund_reason">退款原因: {{ order.refund_reason }}</p>
<p v-if="order.refund_amount">退款金额: ¥{{ order.refund_amount }}</p>
</div>
</div>
<el-dialog v-model="showRefund" :title="$t('order.applyRefund')" width="400px">
<el-input v-model="refundReason" type="textarea" :placeholder="$t('order.refundReason')" />
<template #footer>
<el-button @click="showRefund = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="applyRefund">{{ $t('common.submit') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { orderApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const order = ref<any>(null)
const showRefund = ref(false)
const refundReason = ref('')
const statusMap: Record<string, string> = {
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
}
const statusTypeMap: Record<string, string> = {
pending_payment: 'warning', pending_confirm: 'info', pending_ship: 'info',
shipped: 'primary', completed: 'success', refunding: 'danger', refunded: 'danger', cancelled: 'info',
}
const refundStatusMap: Record<string, string> = {
pending: '待处理', approved: '已批准', rejected: '已拒绝', completed: '已完成',
}
const paymentMethodMap: Record<string, string> = {
balance: '余额支付', alipay: '支付宝', wechat: '微信支付',
}
function statusText(s: string) { return statusMap[s] || s }
function statusType(s: string) { return statusTypeMap[s] || 'info' }
function refundStatusText(s: string) { return refundStatusMap[s] || s }
function paymentMethodText(s: string) { return paymentMethodMap[s] || s }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
const canRefund = computed(() => {
if (!order.value) return false
return !['shipped', 'completed', 'refunded', 'refunding'].includes(order.value.status)
})
onMounted(async () => {
const res: any = await orderApi.getById(Number(route.params.id))
order.value = res.data
})
async function applyRefund() {
if (!refundReason.value.trim()) {
ElMessage.warning('请填写退款原因')
return
}
await orderApi.refund(order.value.id, { reason: refundReason.value })
ElMessage.success('退款申请已提交')
showRefund.value = false
const res: any = await orderApi.getById(order.value.id)
order.value = res.data
}
</script>
<style scoped lang="scss">
.order-detail-page { padding: 0; }
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
.status-section {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.payment-method {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
}
.section-title {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 24px 0 12px;
padding-left: 12px;
border-left: 3px solid #4e6ef2;
}
.order-desc { margin-bottom: 0; }
.total-amount {
font-size: 18px;
font-weight: 700;
color: #4e6ef2;
}
.address-info {
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 16px;
p {
color: rgba(255, 255, 255, 0.8);
margin: 4px 0;
strong {
color: #fff;
margin-right: 12px;
}
}
}
.refund-section { margin-top: 24px; }
.refund-info {
margin-top: 24px;
padding: 16px;
background: rgba(245, 158, 11, 0.1);
border-radius: 8px;
p {
color: rgba(255, 255, 255, 0.7);
margin: 8px 0 0;
}
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<template>
<div class="orders-page">
<BackNav />
<div class="table-card">
<el-table :data="orders">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="total_amount" :label="$t('order.totalAmount')" width="120">
<template #default="{ row }">¥{{ row.total_amount }}</template>
</el-table-column>
<el-table-column prop="status" :label="$t('order.status')" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="$t('common.edit')" width="120">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { orderApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const orders = ref<any[]>([])
const statusMap: Record<string, string> = {
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
}
const statusTypeMap: Record<string, string> = {
pending_payment: 'warning', pending_confirm: 'info', pending_ship: 'info',
shipped: 'primary', completed: 'success', refunding: 'danger', refunded: 'danger', cancelled: 'info',
}
function statusText(s: string) { return statusMap[s] || s }
function statusType(s: string) { return statusTypeMap[s] || 'info' }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(async () => {
const res: any = await orderApi.list()
orders.value = res.data || []
})
</script>
<style scoped lang="scss">
.orders-page { padding: 0; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="product-detail-page" v-if="product">
<BackNav />
<div class="detail-card">
<el-row :gutter="32">
<el-col :span="12">
<div class="product-images">
<el-carousel v-if="productImages.length > 1" height="400px" :autoplay="false" indicator-position="outside">
<el-carousel-item v-for="(img, index) in productImages" :key="index">
<el-image :src="img" fit="contain" class="carousel-image" :preview-src-list="productImages" :initial-index="index">
<template #error>
<div class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</template>
</el-image>
</el-carousel-item>
</el-carousel>
<el-image v-else-if="productImages.length === 1" :src="productImages[0]" fit="contain" class="single-image" :preview-src-list="productImages">
<template #error>
<div class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</div>
</el-col>
<el-col :span="12">
<h1>{{ product.name }}</h1>
<p class="price">¥{{ product.price }}</p>
<p class="description">{{ product.description }}</p>
<div class="meta">
<el-tag v-if="product.require_credit" type="warning">{{ $t('product.requireCredit') }}: {{ product.credit_cost }}</el-tag>
<el-tag v-if="product.credit_reward > 0" type="success">+{{ product.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
</div>
<div class="custom-fields" v-if="product.custom_fields?.length">
<h4>{{ $t('product.customFields') }}</h4>
<div v-for="f in product.custom_fields" :key="f.id" class="field-item">
<span class="field-name">{{ f.field_name }}:</span>
<span>{{ f.field_value }}</span>
</div>
</div>
<div class="purchase-section">
<el-input-number v-model="quantity" :min="product.min_purchase || 1" :max="product.max_purchase || 999" />
<el-button type="primary" size="large" @click="addToCart">{{ $t('product.addToCart') }}</el-button>
<el-button type="success" size="large" @click="buyNow">{{ $t('product.buyNow') }}</el-button>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Goods } from '@element-plus/icons-vue'
import { productApi } from '../../api'
import { useCartStore } from '../../store/cart'
import { useI18n } from 'vue-i18n'
import { getImageUrl } from '../../utils/image'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const cartStore = useCartStore()
const product = ref<any>(null)
const quantity = ref(1)
const productImages = computed(() => {
if (!product.value?.images) return []
return product.value.images.split(',').map((s: string) => s.trim()).filter(Boolean).map(url => getImageUrl(url))
})
onMounted(async () => {
const res: any = await productApi.getById(Number(route.params.id))
product.value = res.data
})
async function addToCart() {
await cartStore.addItem(product.value.id, quantity.value)
ElMessage.success(t('product.addToCart') + ' ✓')
}
function buyNow() {
addToCart().then(() => router.push('/cart'))
}
</script>
<style scoped lang="scss">
.product-detail-page { padding: 0; }
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 32px; }
.product-images {
height: 400px;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
.carousel-image, .single-image {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.image-placeholder {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.2);
}
}
h1 { font-size: 28px; color: #fff; margin-bottom: 16px; }
.price { font-size: 32px; font-weight: 700; color: #4e6ef2; margin-bottom: 16px; }
.description { color: rgba(255, 255, 255, 0.5); margin-bottom: 16px; line-height: 1.6; }
.meta { display: flex; gap: 8px; margin-bottom: 16px; }
.custom-fields { margin-bottom: 24px; h4 { color: #fff; margin-bottom: 8px; } .field-item { color: rgba(255, 255, 255, 0.7); margin-bottom: 4px; .field-name { color: rgba(255, 255, 255, 0.5); margin-right: 8px; } } }
.purchase-section { display: flex; gap: 12px; align-items: center; margin-top: 24px; }
</style>
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="products-page">
<div class="page-header">
<BackNav />
<div class="filter-bar">
<el-select v-model="categoryId" :placeholder="$t('product.filterByCategory')" popper-class="dark-select-dropdown" @change="fetchProducts">
<el-option label="全部分类" :value="undefined" />
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="brandId" placeholder="筛选品牌" popper-class="dark-select-dropdown" @change="fetchProducts">
<el-option label="全部品牌" :value="undefined" />
<el-option v-for="b in brands" :key="b.id" :label="b.name" :value="b.id" />
</el-select>
</div>
</div>
<div class="product-grid">
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
<div class="product-image">
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="contain" lazy>
<template #error>
<div class="image-placeholder">
<el-icon :size="40"><Picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon :size="40"><Picture /></el-icon>
</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ p.name }}</h3>
<div class="product-meta">
<span class="price">¥{{ p.price }}</span>
<el-tag v-if="p.brand" size="small" type="info">{{ p.brand?.name }}</el-tag>
</div>
<div class="product-tags">
<el-tag v-if="p.require_credit" size="small" type="warning">需要资格</el-tag>
<el-tag v-if="p.credit_reward > 0" size="small" type="success">奖励 +{{ p.credit_reward }}</el-tag>
<el-tag v-if="p.stock !== undefined && p.stock <= 10" size="small" type="danger">仅剩{{ p.stock }}</el-tag>
</div>
</div>
</div>
</div>
<el-empty v-if="!products.length" :description="$t('common.noData')" />
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchProducts" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Picture } from '@element-plus/icons-vue'
import { productApi, categoryApi, brandApi } from '../../api'
import { getFirstImage } from '../../utils/image'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const products = ref<any[]>([])
const categories = ref<any[]>([])
const brands = ref<any[]>([])
const categoryId = ref<number | undefined>(undefined)
const brandId = ref<number | undefined>(undefined)
const page = ref(1)
const pageSize = 20
const total = ref(0)
async function fetchProducts() {
const res: any = await productApi.list({
page: page.value,
page_size: pageSize,
category_id: categoryId.value,
brand_id: brandId.value,
})
products.value = res.data || []
total.value = res.pagination?.total || 0
}
onMounted(async () => {
if (route.query.category_id) categoryId.value = Number(route.query.category_id)
const [catRes, brandRes]: any[] = await Promise.all([
categoryApi.list(),
brandApi.list()
])
categories.value = catRes.data || []
brands.value = brandRes.data || []
await fetchProducts()
})
</script>
<style scoped lang="scss">
.products-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
:deep(.breadcrumb-nav) {
margin-bottom: 0;
}
}
.filter-bar {
display: flex;
gap: 12px;
}
.filter-bar :deep(.el-select) {
width: 160px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.product-card {
background: #2d2d44;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.25s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.product-card:hover {
transform: translateY(-4px);
background: #35355a;
border-color: rgba(78, 110, 242, 0.3);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
}
.product-image {
aspect-ratio: 1;
background: rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
.el-image {
width: 100%;
height: 100%;
img {
object-fit: contain;
background: rgba(255, 255, 255, 0.02);
}
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.15);
}
}
.product-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.product-name {
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.price {
font-size: 18px;
font-weight: 700;
color: #4e6ef2;
}
.product-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pagination-wrap {
margin-top: 32px;
display: flex;
justify-content: center;
}
.pagination-wrap :deep(.el-pagination) {
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-hover-color: #4e6ef2;
}
.pagination-wrap :deep(.el-pagination .el-pager li) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
margin: 0 4px;
}
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
background: #4e6ef2;
color: #fff;
}
.pagination-wrap :deep(.el-pagination .btn-prev),
.pagination-wrap :deep(.el-pagination .btn-next) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
}
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
background: rgba(78, 110, 242, 0.2);
color: #4e6ef2;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.filter-bar {
width: 100%;
flex-wrap: wrap;
}
.filter-bar :deep(.el-select) {
flex: 1;
min-width: 140px;
}
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.product-info {
padding: 12px;
}
.product-name {
font-size: 13px;
min-height: 36px;
}
.price {
font-size: 16px;
}
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="profile-page">
<BackNav />
<div class="profile-card" v-if="user">
<div class="profile-header">
<el-avatar :size="64" class="profile-avatar">{{ user.username?.charAt(0).toUpperCase() }}</el-avatar>
<div class="profile-info">
<h3>{{ user.username }}</h3>
<p>{{ user.email }}</p>
<el-tag :type="user.role === 'admin' ? 'danger' : user.role === 'supplier' ? 'warning' : 'info'" size="small">{{ roleText(user.role) }}</el-tag>
</div>
</div>
<el-descriptions :column="2" border class="profile-desc">
<el-descriptions-item label="购买资格">{{ user.purchase_credits }}</el-descriptions-item>
<el-descriptions-item label="邀请码">{{ user.invite_code }}</el-descriptions-item>
</el-descriptions>
<div class="profile-actions">
<el-button @click="$router.push('/addresses')">地址管理</el-button>
<el-button @click="$router.push('/orders')">我的订单</el-button>
<el-button v-if="user.role === 'admin'" type="primary" @click="$router.push('/admin')">管理后台</el-button>
<el-button v-if="user.role === 'supplier'" type="primary" @click="$router.push('/supplier')">供应商后台</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useUserStore } from '../../store/user'
import BackNav from '../../components/BackNav.vue'
const userStore = useUserStore()
const user = computed(() => userStore.user)
const roleMap: Record<string, string> = {
admin: '管理员',
supplier: '供应商',
user: '用户',
}
function roleText(r: string) { return roleMap[r] || r }
onMounted(() => userStore.fetchProfile())
</script>
<style scoped lang="scss">
.profile-page {
padding: 0;
}
.profile-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px;
}
.profile-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-avatar {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
font-size: 24px;
font-weight: 600;
}
.profile-info h3 {
font-size: 20px;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.profile-info p {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 8px;
}
.profile-desc {
margin-bottom: 24px;
}
.profile-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<div class="tickets-page">
<BackNav />
<div class="page-header">
<el-button type="primary" @click="$router.push('/tickets/create')">{{ $t('ticket.createTicket') }}</el-button>
</div>
<div class="table-card">
<el-table :data="tickets">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" :label="$t('ticket.subject')" />
<el-table-column prop="status" :label="$t('ticket.status')" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ticketApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const tickets = ref<any[]>([])
const statusMap: Record<string, string> = {
pending: '待处理',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
}
const statusTypeMap: Record<string, string> = {
pending: 'warning',
processing: 'primary',
resolved: 'success',
closed: 'info',
}
function statusText(s: string) { return statusMap[s] || s }
function statusType(s: string) { return statusTypeMap[s] || 'info' }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(async () => {
const res: any = await ticketApi.list()
tickets.value = res.data || []
})
</script>
<style scoped lang="scss">
.tickets-page { padding: 0; }
.page-header { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 24px; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
</style>