Files
medical-mall/pages/mall/admin/shop/shop-manage.uvue
2026-03-20 17:51:59 +08:00

507 lines
16 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.
<!-- Admin 店铺列表管理页 (role=admin 专用)
数据来源: ml_shops 真实数据,服务端分页,按需请求
-->
<template>
<view class="shop-list-page">
<!-- 1. 搜索筛选区 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="label">注册时间:</text>
<view class="date-range-row">
<input class="mock-input date-input" v-model="startTime" placeholder="开始 2025-01-01" />
<text class="date-sep">~</text>
<input class="mock-input date-input" v-model="endTime" placeholder="结束 2025-12-31" />
</view>
</view>
<view class="search-item">
<text class="label">店铺状态:</text>
<picker :range="statusLabels" :value="statusPickerIndex" @change="onStatusChange">
<view class="mock-select">
<text>{{ statusLabels[statusPickerIndex] }}</text>
<text class="arrow">▼</text>
</view>
</picker>
</view>
</view>
<view class="search-row mt-16">
<view class="search-item">
<text class="label">店铺名称:</text>
<input class="mock-input" v-model="searchName" placeholder="请输入店铺名称" />
</view>
<button class="btn-primary" @click="onSearch">查询</button>
<button class="btn-white" @click="onReset">重置</button>
</view>
</view>
<!-- 2. 数据表格 -->
<view class="list-card">
<!-- 错误提示 -->
<view v-if="fetchError !== ''" class="error-tip">
<text class="error-txt">{{ fetchError }}</text>
<button class="btn-white btn-sm mt-8" @click="loadShops(1)">重试</button>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-tip">
<text class="loading-txt">加载中...</text>
</view>
<view class="table-v5">
<!-- 表头 -->
<view class="th-row">
<view class="th col-logo"><text>Logo</text></view>
<view class="th col-name"><text>店铺名称</text></view>
<view class="th col-cid"><text>编号</text></view>
<view class="th col-merchant"><text>商户ID</text></view>
<view class="th col-contact"><text>联系人/电话</text></view>
<view class="th col-status"><text>状态</text></view>
<view class="th col-stats"><text>商品/订单</text></view>
<view class="th col-rating"><text>评分/评价</text></view>
<view class="th col-verify"><text>认证</text></view>
<view class="th col-time"><text>注册时间</text></view>
<view class="th col-op"><text>操作</text></view>
</view>
<!-- 空态 -->
<view v-if="shopList.length === 0 && !loading && fetchError === ''" class="empty-row">
<text class="empty-txt">暂无店铺数据</text>
</view>
<!-- 数据行 -->
<view v-for="item in shopList" :key="item.id" class="tr-row">
<!-- Logo -->
<view class="td col-logo">
<image
v-if="item.shop_logo != null && item.shop_logo !== ''"
class="shop-logo-img"
:src="item.shop_logo"
mode="aspectFill"
/>
<view v-else class="logo-placeholder">
<text class="logo-placeholder-txt">无</text>
</view>
</view>
<!-- 店铺名称 -->
<view class="td col-name">
<text class="shop-name-txt">{{ item.shop_name !== '' ? item.shop_name : '—' }}</text>
</view>
<!-- 编号 -->
<view class="td col-cid">
<text class="cid-txt">{{ item.cid > 0 ? item.cid : '—' }}</text>
</view>
<!-- 商户ID截短 -->
<view class="td col-merchant">
<text class="merchant-txt">{{ item.merchant_id !== '' ? item.merchant_id.substring(0, 8) + '...' : '—' }}</text>
</view>
<!-- 联系人/电话 -->
<view class="td col-contact">
<text class="contact-name">{{ item.contact_name != null && item.contact_name !== '' ? item.contact_name : '—' }}</text>
<text class="contact-phone">{{ item.contact_phone != null && item.contact_phone !== '' ? item.contact_phone : '' }}</text>
</view>
<!-- 状态 -->
<view class="td col-status">
<text class="status-tag" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</text>
</view>
<!-- 商品/订单数 -->
<view class="td col-stats">
<text class="stats-txt">{{ item.product_count }} / {{ item.order_count }}</text>
</view>
<!-- 评分/评价数 -->
<view class="td col-rating">
<text class="rating-txt">{{ formatRating(item.rating_avg) }} ({{ item.rating_count }})</text>
</view>
<!-- 认证状态 -->
<view class="td col-verify">
<text class="verify-tag" :class="item.verified_at != null && item.verified_at !== '' ? 'verified' : 'unverified'">
{{ item.verified_at != null && item.verified_at !== '' ? '已认证' : '未认证' }}
</text>
</view>
<!-- 注册时间 -->
<view class="td col-time">
<text>{{ formatTime(item.created_at) }}</text>
</view>
<!-- 操作列 -->
<view class="td col-op">
<text
v-if="item.status !== 1"
class="op-link green"
@click="handleStatusChange(item.id, 1)"
>启用</text>
<text
v-if="item.status === 1"
class="op-link warn"
@click="handleStatusChange(item.id, 2)"
>暂停</text>
<text
v-if="item.status !== 3"
class="op-link red"
@click="handleStatusChange(item.id, 3)"
>关闭</text>
</view>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="total > 0"
:total="total"
:loading="loading"
: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 = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
import {
fetchAdminShops,
updateAdminShopStatus,
type AdminShopItem
} from '@/services/admin/shopManageService.uts'
// ========== 搜索筛选状态 ==========
const searchName = ref('')
const startTime = ref('')
const endTime = ref('')
// index 0 → 全部(null), 1 → 正常(1), 2 → 暂停(2), 3 → 关闭(3)
const statusLabels = ['全部', '正常营业', '暂停营业', '已关闭']
const statusPickerIndex = ref(0)
function onStatusChange(e : any) {
statusPickerIndex.value = Number(e.detail.value)
}
// ========== 数据状态 ==========
const loading = ref(false)
const fetchError = ref('')
const shopList = ref<AdminShopItem[]>([])
// ========== 数据加载(服务端分页,点第几页才请求第几页) ==========
async function loadShops(page : number) {
if (loading.value) return
loading.value = true
fetchError.value = ''
try {
// statusPickerIndex: 0=全部, 1~3=对应 status 值
const selectedStatus : number | null = statusPickerIndex.value === 0 ? null : statusPickerIndex.value
const result = await fetchAdminShops({
searchName: searchName.value !== '' ? searchName.value : null,
status: selectedStatus,
startTime: startTime.value !== '' ? startTime.value : null,
endTime: endTime.value !== '' ? endTime.value : null,
page: page,
pageSize: pageSize.value
})
shopList.value = result.items
total.value = result.total
currentPage.value = page
} catch (e) {
fetchError.value = '加载失败,请稍后重试'
console.error('[shop-manage] 加载店铺列表失败:', e)
} finally {
loading.value = false
}
}
function onSearch() {
loadShops(1)
}
function onReset() {
searchName.value = ''
startTime.value = ''
endTime.value = ''
statusPickerIndex.value = 0
loadShops(1)
}
onMounted(() => {
loadShops(1)
})
// ========== 字段格式化 ==========
function formatTime(t : string | null) : string {
if (t == null || t === '') return '—'
return t.replace('T', ' ').substring(0, 16)
}
function formatRating(val : number) : string {
if (val == null || val === 0) return '—'
return val.toFixed(1)
}
// status: 1=正常营业 2=暂停营业 3=已关闭
function getStatusText(status : number) : string {
if (status === 1) return '正常营业'
if (status === 2) return '暂停营业'
if (status === 3) return '已关闭'
return '未知'
}
function getStatusClass(status : number) : string {
if (status === 1) return 'normal'
if (status === 2) return 'pause'
if (status === 3) return 'closed'
return 'unknown'
}
// ========== 操作:变更店铺状态 ==========
function handleStatusChange(id : string, targetStatus : number) {
let msg = ''
if (targetStatus === 1) msg = '确定要启用该店铺吗?'
else if (targetStatus === 2) msg = '确定要暂停该店铺营业吗?'
else if (targetStatus === 3) msg = '确定要关闭该店铺吗?此操作不可自动恢复。'
uni.showModal({
title: '操作确认',
content: msg,
success: async (res : any) => {
if (res.confirm === true) {
const ok = await updateAdminShopStatus(id, targetStatus)
if (ok) {
const label = targetStatus === 1 ? '已启用' : targetStatus === 2 ? '已暂停' : '已关闭'
uni.showToast({ title: label, icon: 'success' })
loadShops(currentPage.value)
} else {
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
}
}
}
})
}
// ========== 分页状态(服务端分页) ==========
const currentPage = ref(1)
const pageSize = ref(20)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n : number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 1 })
const total = ref(0)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.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) return
loadShops(p)
}
const handlePageSizeChange = (e : any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[1]
loadShops(1)
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) {
loadShops(p)
}
}
// ========== END PAGINATION STATE ==========
</script>
<style scoped lang="scss">
.shop-list-page {
padding: 0;
background-color: transparent;
min-height: auto;
}
/* 搜索区 */
.search-card {
background: #fff;
padding: var(--admin-card-padding, 16px 24px);
border-radius: 4px;
margin-bottom: var(--admin-section-gap, 16px);
}
.search-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.mt-16 { margin-top: 16px; }
.mt-8 { margin-top: 8px; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
.label { font-size: 14px; color: #606266; white-space: nowrap; }
}
.date-range-row {
display: flex; flex-direction: row; align-items: center; gap: 8px;
.date-sep { font-size: 13px; color: #606266; }
}
.mock-select {
width: 160px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
display: flex; flex-direction: row; align-items: center; justify-content: space-between;
padding: 0 12px; font-size: 13px; color: #606266;
.arrow { font-size: 10px; color: #c0c4cc; }
}
.mock-input {
width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
padding: 0 12px; font-size: 13px;
}
.date-input { width: 140px; }
.btn-primary {
height: 32px; padding: 0 16px; background: #1890ff; color: #fff;
border: none; border-radius: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.btn-white {
height: 32px; padding: 0 16px; background: #fff; color: #606266;
border: 1px solid #dcdfe6; border-radius: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.btn-sm { height: 28px; padding: 0 12px; font-size: 12px; }
/* 错误/加载提示 */
.error-tip {
padding: 16px 24px;
display: flex; flex-direction: column; align-items: center;
}
.error-txt { font-size: 13px; color: #f56c6c; }
.loading-tip {
padding: 32px; display: flex; align-items: center; justify-content: center;
}
.loading-txt { font-size: 14px; color: #999; }
/* 列表卡片 */
.list-card {
background: #fff;
border-radius: 4px;
overflow: hidden;
}
/* 表格 */
.table-v5 {
width: 100%;
overflow-x: auto;
}
.th-row, .tr-row {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #f0f0f0;
min-width: 1200px;
}
.th-row {
background: #fafafa;
font-weight: 600;
}
.th, .td {
padding: 12px 8px;
font-size: 13px;
color: #515a6e;
display: flex;
align-items: center;
flex-shrink: 0;
}
.th { color: #666; font-size: 12px; }
.tr-row:hover { background: #f5f7fa; }
/* 列宽 */
.col-logo { width: 56px; justify-content: center; }
.col-name { width: 160px; }
.col-cid { width: 60px; justify-content: center; }
.col-merchant{ width: 110px; }
.col-contact { width: 140px; flex-direction: column; align-items: flex-start; }
.col-status { width: 90px; justify-content: center; }
.col-stats { width: 90px; justify-content: center; }
.col-rating { width: 100px; justify-content: center; }
.col-verify { width: 70px; justify-content: center; }
.col-time { width: 140px; }
.col-op {
flex: 1;
gap: 12px;
min-width: 100px;
display: flex;
flex-direction: row;
}
/* 空态行 */
.empty-row {
padding: 48px; display: flex; justify-content: center;
width: 100%;
}
.empty-txt { font-size: 14px; color: #999; }
/* Logo 图片 */
.shop-logo-img {
width: 36px; height: 36px; border-radius: 4px; object-fit: cover;
}
.logo-placeholder {
width: 36px; height: 36px; border-radius: 4px;
background: #f0f0f0;
display: flex; align-items: center; justify-content: center;
}
.logo-placeholder-txt { font-size: 10px; color: #bbb; }
/* 文本样式 */
.shop-name-txt { font-size: 13px; color: #303133; font-weight: 500; }
.cid-txt { font-size: 12px; color: #909399; }
.merchant-txt { font-size: 12px; color: #909399; font-family: monospace; }
.contact-name { font-size: 13px; color: #303133; }
.contact-phone { font-size: 12px; color: #909399; }
.stats-txt { font-size: 13px; color: #515a6e; }
.rating-txt { font-size: 13px; color: #515a6e; }
/* 状态标签 */
.status-tag {
padding: 2px 8px; border-radius: 3px; font-size: 12px;
&.normal { background: #f0f9eb; color: #67c23a; }
&.pause { background: #fdf6ec; color: #e6a23c; }
&.closed { background: #fef0f0; color: #f56c6c; }
&.unknown { background: #f4f4f5; color: #909399; }
}
/* 认证标签 */
.verify-tag {
padding: 2px 6px; border-radius: 3px; font-size: 12px;
&.verified { background: #e6f7ff; color: #1890ff; }
&.unverified { background: #f4f4f5; color: #909399; }
}
/* 操作链接 */
.op-link {
font-size: 13px; cursor: pointer;
&.green { color: #67c23a; }
&.warn { color: #e6a23c; }
&.red { color: #f56c6c; }
}
</style>