商品管理ui优化, 后台支持分页参数
This commit is contained in:
@@ -54,6 +54,15 @@ type AdminListQuery struct {
|
||||
Status string
|
||||
ReviewStatus string
|
||||
Limit int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type AdminListResult struct {
|
||||
Items []ListingDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type AdminActionRequest struct {
|
||||
|
||||
@@ -95,12 +95,12 @@ func (h *Handler) ListAdmin(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAdmin(query)
|
||||
result, err := h.service.ListAdmin(query)
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) FindAdmin(c *gin.Context) {
|
||||
@@ -347,6 +347,22 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
|
||||
}
|
||||
query.Limit = value
|
||||
}
|
||||
if raw := c.Query("page"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
response.BadRequest(c, "页码不正确")
|
||||
return query, false
|
||||
}
|
||||
query.Page = value
|
||||
}
|
||||
if raw := c.Query("page_size"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
response.BadRequest(c, "每页条数不正确")
|
||||
return query, false
|
||||
}
|
||||
query.PageSize = value
|
||||
}
|
||||
query.Status = c.Query("status")
|
||||
query.ReviewStatus = c.Query("review_status")
|
||||
return query, true
|
||||
|
||||
@@ -170,13 +170,47 @@ func (r *Repository) ListPendingReview() ([]ListingDTO, error) {
|
||||
return rowsToDTO(rows), nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
func (r *Repository) ListAdmin(query AdminListQuery) (*AdminListResult, error) {
|
||||
page := query.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := query.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = query.Limit
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
db := r.baseQuery()
|
||||
countDB := r.applyAdminListFilters(r.db.Table("rental_listings AS l"), query)
|
||||
var total int64
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := r.applyAdminListFilters(r.baseQuery(), query)
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var rows []listingRow
|
||||
err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminListResult{
|
||||
Items: rowsToDTO(rows),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
|
||||
if query.OwnerID > 0 {
|
||||
db = db.Where("l.owner_id = ?", query.OwnerID)
|
||||
}
|
||||
@@ -186,13 +220,7 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
||||
if query.ReviewStatus != "" {
|
||||
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
||||
}
|
||||
|
||||
var rows []listingRow
|
||||
err := db.Order("l.id DESC").Limit(limit).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rowsToDTO(rows), nil
|
||||
return db
|
||||
}
|
||||
|
||||
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Service) ListPendingReview() ([]ListingDTO, error) {
|
||||
return s.repo.ListPendingReview()
|
||||
}
|
||||
|
||||
func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
||||
func (s *Service) ListAdmin(query AdminListQuery) (*AdminListResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
@@ -83,12 +83,31 @@ export interface AdminListingQuery {
|
||||
status?: ListingStatus | ''
|
||||
review_status?: ListingReviewStatus | ''
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminListingPage {
|
||||
items: Listing[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings', { params })
|
||||
return data.data.items
|
||||
const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params })
|
||||
return normalizeAdminListingPage(data.data, query)
|
||||
}
|
||||
|
||||
function normalizeAdminListingPage(data: Partial<AdminListingPage>, query: AdminListingQuery): AdminListingPage {
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
return {
|
||||
items,
|
||||
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length,
|
||||
page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1),
|
||||
page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 10),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminListing(id: string | number) {
|
||||
|
||||
@@ -419,6 +419,75 @@ h1 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.admin-listings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
.listing-control-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 0.65fr) minmax(520px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.listing-metric-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.listing-metric-strip span {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fe;
|
||||
padding: 8px 10px;
|
||||
color: #8f9bba;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.listing-metric-strip strong {
|
||||
color: #1b2559;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.listing-filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(160px, 0.8fr) minmax(170px, 0.8fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.listing-filter-bar .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.listing-filter-bar .el-form-item__label {
|
||||
color: #8f9bba;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.listing-action-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-bottom: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========== Dashboard Panels ========== */
|
||||
.dashboard-panels {
|
||||
display: grid;
|
||||
@@ -559,6 +628,71 @@ h1 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: #66728f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-summary,
|
||||
.pagination-page,
|
||||
.pagination-size-label {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
width: 86px;
|
||||
flex: 0 0 86px;
|
||||
}
|
||||
|
||||
.page-size-select .el-select__wrapper {
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.page-size-select .el-select__selected-item {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.admin-listings-page .table-pagination {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.pagination-controls .el-button--small {
|
||||
min-width: 44px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.pagination-controls .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ========== Filter Panel ========== */
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchAdminListings, type AdminListingQuery, type Listing } from '@/api/listings'
|
||||
import { fetchAdminListings, type AdminListingPage, type AdminListingQuery, type Listing } from '@/api/listings'
|
||||
import { useAdminTable } from '@/composables/useAdminTable'
|
||||
import { useMoney } from '@/composables/useMoney'
|
||||
import {
|
||||
@@ -23,24 +24,237 @@ const filters = reactive<AdminListingQuery>({
|
||||
owner_id: '',
|
||||
status: '',
|
||||
review_status: '',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loading, data: listings, load: loadListings } = useAdminTable<Listing[]>({
|
||||
fetchFn: () => fetchAdminListings(filters),
|
||||
initialData: [],
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
|
||||
fetchFn: () =>
|
||||
fetchAdminListings({
|
||||
...filters,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
}),
|
||||
initialData: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
})
|
||||
|
||||
const listings = computed(() => listingPage.value.items)
|
||||
const totalListings = computed(() => listingPage.value.total)
|
||||
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
|
||||
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length)
|
||||
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
|
||||
|
||||
const screenshotColumns = [
|
||||
{ label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) },
|
||||
{ label: '地区', width: 92, read: (row: Listing) => regionText(row) },
|
||||
{ label: '上号方式', width: 78, read: (row: Listing) => row.login_platform || '-' },
|
||||
{ label: '哈夫币(M)', width: 86, read: (row: Listing) => listingCoinM(row) },
|
||||
{ label: '保险', width: 56, read: (row: Listing) => assetText(row, 'season_insurance') },
|
||||
{ label: '体力(级)', width: 70, read: (row: Listing) => levelText(row, 'stamina_level') },
|
||||
{ label: '负重(级)', width: 70, read: (row: Listing) => levelText(row, 'load_level') },
|
||||
{ label: '账号等级', width: 76, read: (row: Listing) => assetText(row, 'fire_level') },
|
||||
{ label: '绝密KD', width: 66, read: (row: Listing) => assetText(row, 'secret_kd') },
|
||||
{ label: 'awm子弹', width: 74, read: (row: Listing) => resourceText(row, 'awmAmmo') },
|
||||
{ label: '六头', width: 50, read: (row: Listing) => resourceText(row, 'helmet6') },
|
||||
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
|
||||
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') },
|
||||
{ label: '人物红皮/人物金皮/武器皮肤', width: 420, read: (row: Listing) => characterAndWeaponSkinText(row) },
|
||||
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
|
||||
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
|
||||
{ label: '租期', width: 92, read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}` },
|
||||
] as const
|
||||
|
||||
async function queryListings() {
|
||||
currentPage.value = 1
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.owner_id = ''
|
||||
filters.status = ''
|
||||
filters.review_status = ''
|
||||
filters.limit = 200
|
||||
void loadListings()
|
||||
void queryListings()
|
||||
}
|
||||
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
async function prevPage() {
|
||||
currentPage.value = Math.max(1, currentPage.value - 1)
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
async function nextPage() {
|
||||
currentPage.value = Math.min(totalPages.value, currentPage.value + 1)
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
async function copyTableScreenshot() {
|
||||
try {
|
||||
const blob = await createTableScreenshotBlob()
|
||||
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
|
||||
ElMessage.warning('当前浏览器不支持直接复制图片,请使用下载截图')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
ElMessage.success('表格截图已复制到剪贴板')
|
||||
} catch (error) {
|
||||
ElMessage.error(readScreenshotError(error, '复制截图失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTableScreenshot() {
|
||||
try {
|
||||
const blob = await createTableScreenshotBlob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `商品管理表格-${formatFileTime(new Date())}.png`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('表格截图已下载')
|
||||
} catch (error) {
|
||||
ElMessage.error(readScreenshotError(error, '下载截图失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function createTableScreenshotBlob() {
|
||||
if (!listings.value.length) throw new Error('当前页暂无可截图数据')
|
||||
const canvas = renderTableScreenshotCanvas(listings.value)
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) throw new Error('截图生成失败')
|
||||
return blob
|
||||
}
|
||||
|
||||
function renderTableScreenshotCanvas(rows: Listing[]) {
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
const paddingX = 24
|
||||
const paddingY = 20
|
||||
const titleHeight = 44
|
||||
const headerHeight = 34
|
||||
const lineHeight = 18
|
||||
const cellPaddingX = 8
|
||||
const cellPaddingY = 10
|
||||
const tableWidth = screenshotColumns.reduce((sum, column) => sum + column.width, 0)
|
||||
const canvasWidth = tableWidth + paddingX * 2
|
||||
|
||||
const measureCanvas = document.createElement('canvas')
|
||||
const measureCtx = measureCanvas.getContext('2d')
|
||||
if (!measureCtx) throw new Error('截图画布初始化失败')
|
||||
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
|
||||
const rowLines = rows.map((row) =>
|
||||
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)),
|
||||
)
|
||||
const rowHeights = rowLines.map((lineGroups) => {
|
||||
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1)
|
||||
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
|
||||
})
|
||||
const canvasHeight = paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(canvasWidth * ratio)
|
||||
canvas.height = Math.round(canvasHeight * ratio)
|
||||
canvas.style.width = `${canvasWidth}px`
|
||||
canvas.style.height = `${canvasHeight}px`
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('截图画布初始化失败')
|
||||
ctx.scale(ratio, ratio)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
|
||||
|
||||
ctx.fillStyle = '#1b2559'
|
||||
ctx.font = '700 18px Arial, "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('商品管理', paddingX, paddingY + 20)
|
||||
ctx.fillStyle = '#8f9bba'
|
||||
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value} 条`, paddingX, paddingY + 40)
|
||||
|
||||
let y = paddingY + titleHeight
|
||||
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
|
||||
ctx.font = '700 12px Arial, "Microsoft YaHei", sans-serif'
|
||||
ctx.fillStyle = '#8f9bba'
|
||||
let x = paddingX
|
||||
for (const column of screenshotColumns) {
|
||||
drawCellText(ctx, [column.label], x + cellPaddingX, y + 22, lineHeight)
|
||||
x += column.width
|
||||
}
|
||||
drawLine(ctx, paddingX, y + headerHeight, paddingX + tableWidth, y + headerHeight)
|
||||
y += headerHeight
|
||||
|
||||
ctx.font = '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const rowHeight = rowHeights[rowIndex] ?? 44
|
||||
drawRect(ctx, paddingX, y, tableWidth, rowHeight, rowIndex % 2 === 0 ? '#ffffff' : '#fbfcff')
|
||||
x = paddingX
|
||||
for (const [columnIndex, column] of screenshotColumns.entries()) {
|
||||
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
|
||||
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654'
|
||||
ctx.font = columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
|
||||
x += column.width
|
||||
}
|
||||
drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight)
|
||||
y += rowHeight
|
||||
})
|
||||
return canvas
|
||||
}
|
||||
|
||||
function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||||
const paragraphs = String(text || '-').split('\n')
|
||||
const lines: string[] = []
|
||||
for (const paragraph of paragraphs) {
|
||||
let line = ''
|
||||
for (const char of paragraph) {
|
||||
const nextLine = line + char
|
||||
if (line && ctx.measureText(nextLine).width > maxWidth) {
|
||||
lines.push(line)
|
||||
line = char
|
||||
} else {
|
||||
line = nextLine
|
||||
}
|
||||
}
|
||||
lines.push(line || '-')
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, x, y + index * lineHeight)
|
||||
})
|
||||
}
|
||||
|
||||
function drawRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, width, height)
|
||||
}
|
||||
|
||||
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
|
||||
ctx.strokeStyle = '#e6eaf2'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX, startY)
|
||||
ctx.lineTo(endX, endY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function formatFileTime(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function readScreenshotError(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
function listingPrice(row: Listing) {
|
||||
@@ -108,39 +322,24 @@ function formatQuantity(value: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<section class="page admin-listings-page">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Listings</p>
|
||||
<h1>商品管理</h1>
|
||||
<p>查看商品编码、地区、上号方式、账号资产、租金押金、比例和预计租期。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadListings">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ listings.length }} 个</strong>
|
||||
<div class="listing-control-panel">
|
||||
<div class="listing-metric-strip">
|
||||
<span>结果 <strong>{{ totalListings }}</strong></span>
|
||||
<span>本页 <strong>{{ listings.length }}</strong></span>
|
||||
<span>已上架 <strong>{{ publishedCount }}</strong></span>
|
||||
<span>已锁定 <strong>{{ rentedCount }}</strong></span>
|
||||
<span>待审核 <strong>{{ pendingCount }}</strong></span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>已上架</span>
|
||||
<strong>{{ publishedCount }} 个</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>已锁定</span>
|
||||
<strong>{{ rentedCount }} 个</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>待审核</span>
|
||||
<strong>{{ pendingCount }} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form class="filter-panel" label-position="top">
|
||||
<el-form class="listing-filter-bar" label-position="top">
|
||||
<el-form-item label="号主 ID">
|
||||
<el-input v-model="filters.owner_id" clearable placeholder="按号主筛选" />
|
||||
</el-form-item>
|
||||
@@ -161,10 +360,14 @@ function formatQuantity(value: number) {
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询条数">
|
||||
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="listing-action-strip">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings">查询</el-button>
|
||||
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
|
||||
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
@@ -240,5 +443,26 @@ function formatQuantity(value: number) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<span class="pagination-summary">当前页 {{ listings.length }} 条,共 {{ totalListings }} 条</span>
|
||||
<div class="pagination-controls">
|
||||
<span class="pagination-size-label">每页</span>
|
||||
<el-select
|
||||
:model-value="pageSize"
|
||||
class="page-size-select"
|
||||
size="small"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<el-option :value="10" label="10条" />
|
||||
<el-option :value="20" label="20条" />
|
||||
<el-option :value="50" label="50条" />
|
||||
<el-option :value="100" label="100条" />
|
||||
</el-select>
|
||||
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}页</span>
|
||||
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user