Files
medical-mall/pages/mall/admin/setting/delivery/management/index.uvue

441 lines
13 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-container">
<view class="page-card">
<view class="action-wrap">
<button class="btn btn-primary" @click="onAdd">添加配送员</button>
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<view class="table-header">
<view class="th" style="flex: 1;">ID</view>
<view class="th" style="flex: 1.5;">头像</view>
<view class="th" style="flex: 2;">名称</view>
<view class="th" style="flex: 2.5;">手机号码</view>
<view class="th" style="flex: 1.5;">是否显示</view>
<view class="th" style="flex: 3;">添加时间</view>
<view class="th" style="flex: 2;">操作</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="table-loading">
<text class="table-tip">加载中...</text>
</view>
<!-- 错误提示 -->
<view v-else-if="fetchError !== ''" class="table-error">
<text class="table-tip-err">{{ fetchError }}</text>
</view>
<!-- 空数据 -->
<view v-else-if="pagedList.length === 0" class="table-empty">
<text class="table-tip">暂无配送员数据</text>
</view>
<view class="table-body">
<view v-for="item in pagedList" :key="item.id" class="tr">
<view class="td" style="flex: 1;">{{ item.id.length > 8 ? item.id.substring(0, 8) : item.id }}</view>
<view class="td" style="flex: 1.5;">
<image class="avatar" :src="item.avatar" mode="aspectFill" />
</view>
<view class="td" style="flex: 2;">{{ item.name }}</view>
<view class="td" style="flex: 2.5;">{{ item.phone }}</view>
<view class="td" style="flex: 1.5;">
<switch :checked="item.isshow" color="#1890ff" @change="onToggleShow(item)" />
</view>
<view class="td" style="flex: 3;">{{ item.addTime }}</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onDetail(item)">详情</text>
<text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="action-btn-del" @click="onDel(item)">删除</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
<!-- 配送员详情抽屉 -->
<DriverDetailDrawer
:visible="drawerVisible"
:driverId="selectedDriverId"
@update:visible="(v: boolean) => { drawerVisible = v }"
/>
<!-- 配送员编辑抽屉 -->
<DriverEditDrawer
:visible="editDrawerVisible"
:driverId="selectedEditId"
@update:visible="(v: boolean) => { editDrawerVisible = v }"
@saved="fetchDrivers(currentPage, pageSize)"
/>
<!-- 添加配送员抽屉 -->
<DriverAddDrawer
:visible="addDrawerVisible"
@update:visible="(v: boolean) => { addDrawerVisible = v }"
@saved="onAddSaved"
/>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
import supa from '@/components/supadb/aksupainstance.uts'
import DriverDetailDrawer from './components/DriverDetailDrawer.uvue'
import DriverEditDrawer from './components/DriverEditDrawer.uvue'
import DriverAddDrawer from './components/DriverAddDrawer.uvue'
type DriverRow = {
id: string
avatar: string
name: string
phone: string
isshow: boolean
addTime: string
}
// ========== 数据状态 ==========
const driverList = ref<DriverRow[]>([])
const loading = ref(false)
const fetchError = ref('')
// ========== 详情抽屉 ==========
const drawerVisible = ref(false)
const selectedDriverId = ref('')
// ========== 编辑抽屉 ==========
const editDrawerVisible = ref(false)
const selectedEditId = ref('')
// ========== 添加抽屉 ==========
const addDrawerVisible = ref(false)
// ========== 字段映射 ==========
function formatTime(ts: string): string {
if (ts === '' || ts == null) return '—'
const tIdx = ts.indexOf('T')
if (tIdx > -1) {
const date = ts.substring(0, tIdx)
const timeFull = ts.substring(tIdx + 1)
const dotIdx = timeFull.indexOf('.')
const time = dotIdx > -1 ? timeFull.substring(0, dotIdx) : timeFull.substring(0, 8)
return date + ' ' + time
}
return ts
}
function mapDbRow(row: UTSJSONObject): DriverRow {
const id = row.getString('id') ?? ''
const realName = row.getString('real_name') ?? '—'
const status = row.getNumber('status') ?? 1
const createdAt = row.getString('created_at') ?? ''
// JOIN 字段来自 ak_users别名 user_info
let avatar = '/static/logo.png'
let phone = '—'
const userInfo = row.getJSON('user_info')
if (userInfo != null) {
const avatarUrl = userInfo.getString('avatar_url') ?? ''
if (avatarUrl !== '') avatar = avatarUrl
const phoneVal = userInfo.getString('phone') ?? ''
if (phoneVal !== '') phone = phoneVal
}
return {
id: id,
avatar: avatar,
name: realName !== '' ? realName : '—',
phone: phone,
isshow: status === 1,
addTime: formatTime(createdAt)
} as DriverRow
}
// ========== 服务端按页查询 ==========
// 查询字段说明:
// ml_delivery_drivers 本表字段id, real_name, status, created_at
// JOIN ak_users通过 user_id FKavatar_url, phone
// 敏感字段id_card, driver_license, current_lat, current_lng不纳入查询
const fetchDrivers = async (page: number, ps: number) => {
if (loading.value) return // 防止并发重复请求
loading.value = true
fetchError.value = ''
try {
const offset = (page - 1) * ps
// offset 注入 filter使用 limit+offset URL 参数方案,避免 Range 头 416 问题
const offsetFilter = offset > 0 ? `offset=${offset}` : null
const res = await supa.select(
'ml_delivery_drivers',
offsetFilter,
{
// 只取列表页所需字段JOIN ak_users 别名为 user_info
columns: 'id,real_name,status,created_at,user_info:ak_users!user_id(avatar_url,phone)',
limit: ps,
order: 'created_at.desc', // 稳定排序:按创建时间降序
count: 'exact' // 触发 Prefer: count=exact → Content-Range 总行数
}
)
if (res.status >= 200 && res.status < 300 && res.data != null) {
driverList.value = (res.data as UTSJSONObject[]).map((row: UTSJSONObject): DriverRow => mapDbRow(row))
// 从 Content-Range 响应头解析真实总行数格式0-14/total
let totalCount = 0
const hdrs = res.headers
if (hdrs != null) {
let cr: string | null = null
if (typeof (hdrs as any).get === 'function') {
cr = (hdrs as any).get('content-range') as string | null
}
if (cr == null) {
cr = (hdrs as UTSJSONObject)['content-range'] as string | null
}
if (cr != null) {
const m = /\/(\d+)$/.exec(cr)
if (m != null) totalCount = parseInt(m[1] ?? '0')
}
}
// Content-Range 解析失败时:以 offset + 当前页条数 作保守兜底
if (totalCount === 0) {
totalCount = offset + (Array.isArray(res.data) ? (res.data as UTSJSONObject[]).length : 0)
}
total.value = totalCount
} else {
fetchError.value = '加载配送员列表失败,请检查网络或权限配置'
}
} catch (e) {
fetchError.value = '请求异常,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDrivers(1, pageSize.value)
})
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = ref(0) // 来自服务端 Content-Range 真实总行数
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => driverList.value) // 服务端已按页返回,直接展示当前页数据
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => {
if (p < 1 || p > totalPage.value || p === currentPage.value) return
currentPage.value = p
fetchDrivers(p, pageSize.value)
}
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
fetchDrivers(1, pageSize.value)
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) {
currentPage.value = p
fetchDrivers(p, pageSize.value)
}
}
// ========== END PAGINATION STATE ==========
function onAdd() {
addDrawerVisible.value = true
}
function onAddSaved() {
currentPage.value = 1
fetchDrivers(1, pageSize.value)
}
async function onToggleShow(item: DriverRow) {
const newIsShow = !item.isshow
const newStatus = newIsShow ? 1 : 2 // 1:正常(显示) 2:暂停(不显示)
item.isshow = newIsShow // 乐观更新本地状态
try {
const res = await supa.from('ml_delivery_drivers')
.update({ status: newStatus } as UTSJSONObject)
.eq('id', item.id)
.execute()
if (res.status < 200 || res.status >= 300) {
item.isshow = !newIsShow // 请求失败,回滚本地状态
uni.showToast({ title: '状态更新失败', icon: 'none' })
}
} catch (e) {
item.isshow = !newIsShow // 异常,回滚本地状态
uni.showToast({ title: '操作异常,请重试', icon: 'none' })
}
}
function onDetail(item: DriverRow) {
selectedDriverId.value = item.id
drawerVisible.value = true
}
function onEdit(item: DriverRow) {
selectedEditId.value = item.id
editDrawerVisible.value = true
}
async function doDelete(item: DriverRow) {
try {
const result = await supa.from('ml_delivery_drivers')
.eq('id', item.id)
.delete()
.execute()
if (result.status >= 200 && result.status < 300) {
uni.showToast({ title: '删除成功', icon: 'success' })
fetchDrivers(currentPage.value, pageSize.value)
} else {
uni.showToast({ title: '删除失败,请重试', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '操作异常,请重试', icon: 'none' })
}
}
function onDel(item: DriverRow) {
uni.showModal({
title: '确认删除',
content: '确认删除配送员「' + item.name + '」?该操作不可恢复。',
confirmText: '确认删除',
confirmColor: '#ff4d4f',
success: (res: any) => {
if (res.confirm === true) {
doDelete(item)
}
}
})
}
</script>
<style scoped>
.admin-page-container {
/* 使用 Layout 的背景和内边距 */
padding: 0;
background-color: transparent;
min-height: auto;
}
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.action-wrap {
margin-bottom: 20px;
}
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
margin-right: 10px;
cursor: pointer;
}
.action-btn-del {
color: #ed4014;
font-size: 13px;
cursor: pointer;
}
.table-loading,
.table-error,
.table-empty {
padding: 40px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.table-tip {
font-size: 14px;
color: #999;
}
.table-tip-err {
font-size: 14px;
color: #ed4014;
}
</style>