Files
medical-mall/pages/mall/admin/order/order-management/index.uvue
2026-03-09 09:44:04 +08:00

884 lines
24 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="admin-page">
<view class="admin-sections" @click="closeDropdowns">
<!-- 筛选区域 -->
<view class="admin-card filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">订单类型:</text>
<view class="mock-select">
<text>全部订单</text>
<view class="arrow-down"></view>
</view>
</view>
<view class="filter-item">
<text class="label">支付方式:</text>
<view class="mock-select">
<text>全部</text>
<view class="arrow-down"></view>
</view>
</view>
<view class="filter-item long">
<text class="label">创建时间:</text>
<view class="mock-date-range">
<image class="cal-icon" src="/static/icons/calendar.png" mode="aspectFit" />
<text class="placeholder">开始日期 - 结束日期</text>
</view>
</view>
<view class="filter-item search">
<text class="label">订单搜索:</text>
<view class="search-group">
<view class="search-select">
<text>全部</text>
<view class="arrow-down"></view>
</view>
<input class="search-input" v-model="searchKeyword" placeholder="请输入订单号/手机号" />
</view>
</view>
</view>
<view class="btn-row">
<button class="btn btn-primary" @click="fetchData">查询</button>
<button class="btn btn-default" @click="resetQuery">重置</button>
</view>
</view>
<!-- 列表数据区域 -->
<view class="admin-card content-card">
<!-- 状态 Tabs -->
<view class="status-tabs">
<view
v-for="(tab, index) in statusTabs"
:key="index"
class="tab-item"
:class="{ active: activeTab === index }"
@click="handleTabClick(index)"
>
<text class="tab-text">{{ tab.name }}</text>
<text v-if="tab.count !== null" class="tab-count">({{ tab.count }})</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-bar">
<button class="action-btn btn-blue">订单核销</button>
<button class="action-btn btn-outline">批量发货</button>
<button class="action-btn btn-outline">批量删除</button>
<button class="action-btn btn-outline">订单导出</button>
</view>
<!-- 数据表格 -->
<view class="order-table">
<view class="thead">
<view class="th col-expand"></view>
<view class="th col-check">
<checkbox :checked="false" color="#1890ff" />
</view>
<view class="th col-order">订单号 | 类型</view>
<view class="th col-product">商品信息</view>
<view class="th col-user">用户信息</view>
<view class="th col-price">实际支付</view>
<view class="th col-pay">支付方式</view>
<view class="th col-time">支付时间</view>
<view class="th col-status">订单状态</view>
<view class="th col-op">操作</view>
</view>
<view class="tbody">
<!-- Loading 状态 -->
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<!-- 无数据状态 -->
<view v-else-if="filteredOrders.length === 0" class="empty-state">
<text>暂无订单数据</text>
</view>
<view v-for="(item, index) in filteredOrders" :key="index" class="tr">
<view class="td col-expand">
<text class="expand-arrow"></text>
</view>
<view class="td col-check">
<checkbox :checked="false" color="#1890ff" />
</view>
<!-- 订单号|类型 -->
<view class="td col-order">
<text class="order-sn">{{ item['sn'] }}</text>
<text class="order-type" :class="item['typeColor']">[{{ item['typeName'] }}]</text>
<text v-if="item['cancelStatus'] != ''" class="cancel-text">{{ item['cancelStatus'] }}</text>
</view>
<!-- 商品信息 -->
<view class="td col-product">
<view class="product-info-list" v-if="item['items'] != null">
<view v-for="(prod, pidx) in (item['items'] as UTSJSONObject[])" :key="pidx" class="product-info-wrap">
<image class="p-img" :src="prod['image'] != null ? (prod['image'] as string) : '/static/logo.png'" mode="aspectFill" />
<text class="p-name">{{ prod['name'] }}</text>
</view>
</view>
<view v-else class="product-info-wrap">
<image class="p-img" :src="item['product']['img']" mode="aspectFill" />
<text class="p-name">{{ item['product']['name'] }}</text>
</view>
</view>
<!-- 用户信息 -->
<view class="td col-user">
<text class="u-info">{{ item['user']['name'] }} | {{ item['user']['id'] || '--' }}</text>
</view>
<!-- 实际支付 -->
<view class="td col-price">
<text class="price-val">{{ item['isPaid'] === true ? '¥' + item['actualPrice'] : '未支付' }}</text>
</view>
<!-- 支付方式 -->
<view class="td col-pay">
<text class="pay-text">{{ item['payMethod'] }}</text>
</view>
<!-- 支付时间 -->
<view class="td col-time">
<text class="time-text">{{ item['payTime'] }}</text>
</view>
<!-- 订单状态 -->
<view class="td col-status">
<text class="status-text">{{ item['statusName'] }}</text>
</view>
<!-- 操作 -->
<view class="td col-op overflow-visible no-wrap">
<view class="op-links">
<text class="op-link primary" @click.stop="handleAction('edit', item['sn'] as string)">编辑</text>
<view class="divider-v"></view>
<view class="op-dropdown-container">
<view class="op-link-more" @click.stop="toggleDropdown(item['sn'] as string)">
<text class="more-text">更多</text>
<view :class="{ 'arrow-up-blue': activeDropdownId === item['sn'], 'arrow-down-blue': activeDropdownId !== item['sn'] }"></view>
</view>
<!-- 浮动菜单 -->
<view v-if="activeDropdownId === (item['sn'] as string)" class="dropdown-menu">
<view class="dropdown-item" @click.stop="viewDetail(item)">
<text class="item-text">订单详情</text>
</view>
<view class="dropdown-item item-danger" @click.stop="deleteOrder(item)">
<text class="item-text text-red">删除订单</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-footer">
<view class="page-left">
<text class="count-text">共 {{ orderData.length }} 条</text>
<view class="page-size-select">
<text>10条/页</text>
<view class="arrow-down"></view>
</view>
</view>
<view class="page-right">
<view class="page-btn disabled"><text></text></view>
<view class="page-num active"><text>1</text></view>
<view class="page-num"><text>2</text></view>
<view class="page-num"><text>3</text></view>
<view class="page-btns-more"><text>...</text></view>
<view class="page-num"><text>10</text></view>
<view class="page-btn"><text></text></view>
</view>
</view>
</view>
</view>
<!-- 抽屉详情 -->
<OrderDetailDrawer
v-model:visible="showDetail"
:order-info="selectedOrder"
/>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/components/supadb/aksupainstance.uts'
import OrderDetailDrawer from './components/OrderDetailDrawer.uvue'
const activeTab = ref(0)
const loading = ref(false)
const searchKeyword = ref('')
// Dropdown 状态
const activeDropdownId = ref('')
const showDetail = ref(false)
const selectedOrder = ref<UTSJSONObject>({} as UTSJSONObject)
// 时间显示格式化
const formatTime = (timeStr : string | null) : string => {
if (timeStr == null || timeStr == '' || timeStr == '--') return '--'
if (timeStr.indexOf('T') > -1) {
const parts = timeStr.split('T')
const date = parts[0]
const timeFull = parts[1]
const time = timeFull.split('.')[0]
return date + ' ' + time
}
return timeStr
}
const statusTabs = [
{ name: '全部', count: null, status: 0 },
{ name: '待支付', count: null, status: 1 },
{ name: '待发货', count: null, status: 2 },
{ name: '待收货', count: null, status: 3 },
{ name: '已完成', count: null, status: 4 },
{ name: '已取消', count: null, status: 5 },
{ name: '退款中', count: null, status: 6 },
{ name: '已退款', count: null, status: 7 }
]
const orderData = ref<UTSJSONObject[]>([])
const filteredOrders = computed<UTSJSONObject[]>(() => {
let list = orderData.value
// Tab 过滤
if (activeTab.value > 0) {
const targetStatus = statusTabs[activeTab.value].status
list = list.filter((o : UTSJSONObject) : boolean => (o['orderStatus'] as number) === targetStatus)
}
// 搜索过滤
if (searchKeyword.value.trim() !== '') {
const kw = searchKeyword.value.toLowerCase()
list = list.filter((o : UTSJSONObject) : boolean => {
const sn = (o['sn'] as string).toLowerCase()
const user = o['user'] as UTSJSONObject
const phone = user['phone'] as string
return sn.includes(kw) || phone.includes(kw)
})
}
return list
})
const fetchData = async () => {
loading.value = true
try {
// 从 ml_orders_detail_view 视图读取数据
const res = await supabase
.from('ml_orders_detail_view')
.select('*')
.order('created_at', { ascending: false })
.execute()
if (res.error == null && res.data != null) {
const rawData = res.data as UTSJSONObject[]
orderData.value = rawData.map((item: UTSJSONObject) : UTSJSONObject => {
return {
sn: item['order_no'],
typeName: '普通订单',
typeColor: 'green',
orderStatus: item['order_status'],
cancelStatus: item['order_status'] === 5 ? '用户已取消' : '',
product: {
img: '/static/logo.png', // 默认占位图
name: '订单概要 (详情查看)'
} as UTSJSONObject,
items: item['order_items'] as UTSJSONObject[], // 尝试获取子项
user: {
name: (item['customer_name'] || '未知用户') as string,
id: (item['user_id'] || '--') as string,
phone: (item['customer_phone'] || '--') as string
} as UTSJSONObject,
isPaid: item['payment_status'] === 2,
actualPrice: item['total_amount'], // 总金额
paidAmount: item['paid_amount'] != null ? item['paid_amount'] : 0.00, // 支付金额
payMethod: item['payment_status_name'] || '--',
payTime: formatTime(item['paid_at'] as string | null),
created_at: formatTime(item['created_at'] as string | null),
statusName: item['order_status_name'] || '未知',
primaryAction: item['order_status'] === 1 ? '立即支付' : '',
total_num: Array.isArray(item['order_items']) ? (item['order_items'] as Array<any>).length : 0,
total_price: item['product_amount'] != null ? item['product_amount'] : 0.00, // 产品价格
coupon_price: item['discount_amount'] != null ? item['discount_amount'] : 0.00, // 折扣价
shipping_fee: item['shipping_fee'] != null ? item['shipping_fee'] : 0.00, // 运费
deduction_price: 0,
user_address: item['shipping_address'] != null ? (typeof item['shipping_address'] === 'string' ? item['shipping_address'] : JSON.stringify(item['shipping_address'])) : '暂无地址信息',
real_name: item['customer_name'] != null ? item['customer_name'] : '', // 消费者名字
user_phone: item['customer_phone'] != null ? item['customer_phone'] : '',
delivery_name: item['merchant_name'] != null ? item['merchant_name'] : '--', // 配送人员
store_name: item['shop_name'] != null ? item['shop_name'] : '--', // 店铺名字
mark: item['remark'] != null ? item['remark'] : '-',
remark: item['merchant_memo'] != null ? item['merchant_memo'] : '-'
} as UTSJSONObject
})
// 更新统计数据
updateTabCounts()
} else {
console.error('Fetch orders error:', res.error)
}
} catch (e) {
console.error('Fetch orders exception:', e)
} finally {
loading.value = false
}
}
const updateTabCounts = () => {
statusTabs.forEach((tab, index) => {
if (index === 0) {
tab.count = orderData.value.length
} else {
tab.count = orderData.value.filter((o : UTSJSONObject) : boolean => (o['orderStatus'] as number) === tab.status).length
}
})
}
const handleTabClick = (index: number) => {
activeTab.value = index
}
const resetQuery = () => {
searchKeyword.value = ''
activeTab.value = 0
fetchData()
}
// 更多菜单控制
const toggleDropdown = (id: string) => {
if (activeDropdownId.value === id) {
activeDropdownId.value = ''
} else {
activeDropdownId.value = id
}
}
// 关闭下拉
const closeDropdowns = () => {
activeDropdownId.value = ''
}
// 查看详情
const viewDetail = (order: UTSJSONObject) => {
selectedOrder.value = order
showDetail.value = true
closeDropdowns()
}
// 删除订单 (权限占位代码)
const deleteOrder = async (order: UTSJSONObject) => {
closeDropdowns()
// 模拟权限验证: admin-order-storeOrder-index
const hasAuth = true // 实际项目中应通过 store 读取权限
if (!hasAuth) {
uni.showToast({ title: '无权限操作', icon: 'none' })
return
}
uni.showModal({
title: '确认删除',
content: `确定要删除订单 ${order['sn']} 吗?删除后不可恢复。`,
success: async (res) => {
if (res.confirm) {
try {
const { error } = await supabase
.from('ml_orders')
.delete()
.eq('order_no', order['sn'])
.execute()
if (!error) {
uni.showToast({ title: '删除成功' })
fetchData()
} else {
uni.showToast({ title: '删除失败: ' + error.message, icon: 'none' })
}
} catch (e) {
console.error(e)
}
}
}
})
}
const handleAction = (action: string, sn: string) => {
uni.showToast({ title: `执行操作: ${action} - ${sn}`, icon: 'none' })
closeDropdowns()
}
onMounted(() => {
fetchData()
// 点击空白处关闭下拉
// uni.onWindowClick(() => closeDropdowns())
// UVUE 不支持 window click可用 view 遮罩或在主容器加点击
})
</script>
<style scoped lang="scss">
.admin-page {
/* 基础背景由 Layout 提供 */
min-height: calc(100vh - 120px);
padding: 16px;
}
.admin-card {
background-color: #ffffff;
border-radius: 4px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
overflow: visible !important; /* 关键:确保内部内容不被裁切 */
}
.content-card {
overflow: visible !important;
}
.filter-card {
padding: 24px;
}
.filter-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 24px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
.label {
font-size: 14px;
color: #333;
width: 80px;
flex-shrink: 0;
}
}
.mock-select {
width: 160px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
text { font-size: 14px; color: #595959; }
}
.mock-date-range {
width: 240px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
.cal-icon { width: 14px; height: 14px; margin-right: 8px; opacity: 0.4; }
.placeholder { font-size: 14px; color: #bfbfbf; }
}
.search-group {
display: flex;
flex-direction: row;
border: 1px solid #d9d9d9;
border-radius: 4px;
height: 32px;
overflow: hidden;
}
.search-select {
width: 80px;
border-right: 1px solid #d9d9d9;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
background-color: #fafafa;
text { font-size: 14px; color: #595959; }
}
.search-input {
flex: 1;
border: none;
padding: 0 12px;
font-size: 14px;
width: 180px;
}
.arrow-down {
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #bfbfbf;
}
.btn-row {
margin-top: 16px;
display: flex;
flex-direction: row;
gap: 8px;
}
.btn {
height: 32px;
padding: 0 16px;
font-size: 14px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
.btn-primary { background-color: #1890ff; color: #fff; border: none; }
.btn-default { background-color: #fff; color: #595959; border: 1px solid #d9d9d9; }
.status-tabs {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
position: relative;
display: flex;
flex-direction: row;
gap: 2px;
.tab-text { font-size: 14px; color: #595959; }
.tab-count { font-size: 14px; color: #595959; }
&.active {
.tab-text, .tab-count { color: #1890ff; font-weight: 500; }
&::after {
content: '';
position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: #1890ff;
}
}
}
.action-bar {
padding: 16px 20px;
display: flex;
flex-direction: row;
gap: 12px;
}
.action-btn {
height: 32px;
padding: 0 16px;
font-size: 14px;
border-radius: 4px;
margin: 0;
display: flex;
align-items: center;
}
.btn-blue { background-color: #1890ff; color: #fff; border: none; }
.btn-outline { background-color: #fff; color: #595959; border: 1px solid #d9d9d9; }
/* 表格样式 */
.order-table {
width: 100%;
overflow-x: auto;
overflow-y: visible !important; /* 关键:确保垂直方向不被裁切 */
position: relative;
}
.thead {
display: flex;
flex-direction: row;
background-color: #f0f7ff;
min-width: 1300px;
position: relative;
z-index: 10;
}
.th {
padding: 12px 8px;
font-size: 14px;
color: #595959;
font-weight: 500;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start; /* 左对齐内容 */
}
.tbody {
overflow: visible !important;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
min-width: 1300px;
position: relative; /* 定位参考 */
z-index: 10;
overflow: visible !important; /* 关键:确保子菜单可溢出 */
&:hover { background-color: #fafafa; z-index: 100; }
}
.td {
padding: 16px 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start; /* 左对齐内容 */
}
.no-wrap {
flex-direction: row !important;
flex-wrap: nowrap;
}
.overflow-visible {
overflow: visible !important;
}
/* 更多下拉菜单 */
.op-dropdown-container {
position: relative;
display: inline-block;
cursor: pointer;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
width: 100px;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
margin-top: 5px;
z-index: 99999; /* 极大层级 */
border: 1px solid #f0f0f0;
pointer-events: auto;
display: block; /* 显式设置为 block */
}
.dropdown-item {
height: 36px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
transition: background-color 0.2s;
cursor: pointer;
.item-text { font-size: 13px; color: #595959; }
&:hover {
background-color: #f5f5f5;
}
}
.text-red { color: #ff4d4f !important; }
.loading-state, .empty-state {
padding: 40px;
text-align: center;
color: #999;
font-size: 14px;
}
/* 列宽控制 */
.col-expand { width: 40px; justify-content: center; align-items: center; }
.expand-arrow { color: #bfbfbf; font-size: 18px; }
.col-check { width: 50px; justify-content: center; align-items: center; }
.col-order { width: 220px; }
.col-product { flex: 1; min-width: 300px; }
.col-user { width: 180px; }
.col-price { width: 100px; }
.col-pay { width: 100px; }
.col-time { width: 160px; }
.col-status { width: 100px; }
.col-op { width: 140px; }
.order-sn { font-size: 13px; color: #262626; margin-bottom: 4px; text-align: left; }
.order-type { font-size: 12px; text-align: left; }
.product-info-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.product-info-wrap {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px; /* 间距拉开一点 */
text-align: left;
}
.p-img { width: 44px; height: 44px; border-radius: 4px; background-color: #f5f5f5; flex-shrink: 0; }
.p-name { font-size: 13px; color: #595959; line-height: 1.5; }
.u-info { font-size: 13px; color: #595959; }
.price-val { font-size: 14px; color: #262626; }
.pay-text, .time-text, .status-text { font-size: 13px; color: #595959; }
.op-links {
display: flex;
flex-direction: row;
align-items: center;
}
.op-link { font-size: 13px; cursor: pointer; }
.primary { color: #1890ff; padding-right: 8px; }
.divider-v {
width: 1px;
height: 12px;
background-color: #dcdfe6;
margin: 0 8px;
}
.op-dropdown-container {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
padding-left: 0px;
}
.op-link-more {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.more-text { font-size: 13px; color: #1890ff; }
.dropdown-menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 110px;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 8px;
z-index: 100000;
border: 1px solid #ebeef5;
display: flex;
flex-direction: column;
}
.dropdown-item {
height: 32px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
cursor: pointer;
.item-text { font-size: 13px; color: #606266; text-align: center; }
&:hover {
background-color: #f5f7fa;
.item-text { color: #1890ff; }
}
}
.item-danger:hover {
.item-text { color: #ff4d4f !important; }
}
.text-red { color: #ff4d4f !important; }
/* 箭头 */
.arrow-down-blue {
width: 0; height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 4px solid #1890ff;
margin-top: 1px;
}
.arrow-up-blue {
width: 0; height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-bottom: 4px solid #1890ff;
margin-bottom: 3px;
}
.pagination-footer {
padding: 16px 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.page-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
.count-text { font-size: 14px; color: #595959; }
}
.page-size-select {
height: 28px;
padding: 0 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
text { font-size: 12px; color: #595959; }
}
.page-right {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.page-num, .page-btn, .page-btns-more {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 4px;
text { font-size: 14px; color: #595959; }
cursor: pointer;
}
.page-num.active {
border-color: #1890ff;
text { color: #1890ff; }
}
.page-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-btns-more { border: none; }
</style>