优化admin-gui
This commit is contained in:
@@ -41,6 +41,7 @@ var defaultConfigs = []defaultConfig{
|
||||
{Key: "settlement.platform_fee_rate", Value: "0.00", Description: "平台抽成比例"},
|
||||
{Key: "settlement.owner_cycle_days", Value: "0", Description: "号主结算周期天数"},
|
||||
{Key: "withdraw.min_amount", Value: "100", Description: "提现最低金额预留"},
|
||||
{Key: "chat.default_support_admin_id", Value: "1", Description: "订单群聊默认接入的客服管理员 ID"},
|
||||
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
|
||||
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
|
||||
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
|
||||
@@ -136,6 +137,11 @@ func (r *Repository) ensureDefaults() error {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Key == "chat.default_support_admin_id" {
|
||||
if err := updateDefaultDescription(tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if item.Key == publishOptionsConfigKey {
|
||||
if err := updateLegacyPublishOptions(tx, item); err != nil {
|
||||
return err
|
||||
@@ -146,6 +152,18 @@ func (r *Repository) ensureDefaults() error {
|
||||
})
|
||||
}
|
||||
|
||||
func updateDefaultDescription(tx *gorm.DB, item defaultConfig) error {
|
||||
var row model.SystemConfig
|
||||
if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Description == item.Description {
|
||||
return nil
|
||||
}
|
||||
row.Description = item.Description
|
||||
return tx.Save(&row).Error
|
||||
}
|
||||
|
||||
func updateLegacyPublishOptions(tx *gorm.DB, item defaultConfig) error {
|
||||
var row model.SystemConfig
|
||||
if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface AdminPaginatedTableOptions<T> {
|
||||
fetchFn: (page: number, pageSize: number) => Promise<PaginatedResult<T>>
|
||||
defaultPageSize?: number
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
interface AdminPaginatedTableResult<T> {
|
||||
loading: Ref<boolean>
|
||||
data: Ref<T[]>
|
||||
total: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
currentPageSize: Ref<number>
|
||||
load: () => Promise<void>
|
||||
handleSizeChange: () => void
|
||||
}
|
||||
|
||||
export function useAdminPaginatedTable<T>(options: AdminPaginatedTableOptions<T>): AdminPaginatedTableResult<T> {
|
||||
const loading = ref(false)
|
||||
const data = ref<T[]>([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(options.defaultPageSize || 20)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await options.fetchFn(currentPage.value, currentPageSize.value)
|
||||
data.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
if (options.immediate !== false) {
|
||||
load()
|
||||
}
|
||||
|
||||
return { loading, data, total, currentPage, currentPageSize, load, handleSizeChange }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
interface AdminQueryOptions<T> {
|
||||
fetchFn: () => Promise<T>
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
interface AdminQueryResult<T> {
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
data: Ref<T>
|
||||
load: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useAdminQuery<T>(options: AdminQueryOptions<T>): AdminQueryResult<T> {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<T>() as Ref<T>
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
data.value = await options.fetchFn()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
console.error('Failed to load data:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (options.immediate !== false) {
|
||||
load()
|
||||
}
|
||||
|
||||
return { loading, error, data, load }
|
||||
}
|
||||
|
||||
// 保持向后兼容
|
||||
export const useAdminTable = useAdminQuery
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useMoney() {
|
||||
return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}`
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ChatDotRound, DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ChatDotRound,
|
||||
DataLine,
|
||||
Document,
|
||||
DocumentChecked,
|
||||
Operation,
|
||||
Fold,
|
||||
Expand,
|
||||
ScaleToOriginal,
|
||||
Shop,
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
User,
|
||||
Wallet
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { logoutAdmin } from '@/api/adminAuth'
|
||||
@@ -9,6 +23,7 @@ import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
|
||||
const router = useRouter()
|
||||
const adminSession = useAdminSessionStore()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
|
||||
@@ -38,17 +53,23 @@ async function handleLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell">
|
||||
<div class="admin-shell" :class="{ 'admin-shell--collapsed': isCollapsed }">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-header">
|
||||
<RouterLink class="admin-brand" to="/admin/dashboard">
|
||||
<span class="brand-mark">H</span>
|
||||
<span>哈夫币后台</span>
|
||||
<span class="brand-text">哈夫币后台</span>
|
||||
</RouterLink>
|
||||
<el-icon class="collapse-btn" @click="isCollapsed = !isCollapsed">
|
||||
<Fold v-if="!isCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<nav class="admin-nav">
|
||||
<RouterLink v-for="item in navItems" :key="item.to" class="admin-nav-link" :to="item.to">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
+874
-64
File diff suppressed because it is too large
Load Diff
@@ -1,140 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
Bell,
|
||||
ChatDotRound,
|
||||
Document,
|
||||
Grid,
|
||||
Loading,
|
||||
Operation,
|
||||
Refresh,
|
||||
ScaleToOriginal,
|
||||
Shop,
|
||||
Tickets,
|
||||
User,
|
||||
Wallet,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAdminDashboard, type AdminDashboard } from '@/api/adminDashboard'
|
||||
import { useAdminTable } from '@/composables/useAdminTable'
|
||||
import { useMoney } from '@/composables/useMoney'
|
||||
import { disputeStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = ref<AdminDashboard | null>(null)
|
||||
const money = useMoney()
|
||||
|
||||
onMounted(loadDashboard)
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await fetchAdminDashboard()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function money(value?: number) {
|
||||
return `¥${Math.round(Number(value || 0))}`
|
||||
}
|
||||
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
|
||||
fetchFn: fetchAdminDashboard,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" v-loading="loading">
|
||||
<section class="page">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h1>后台仪表盘</h1>
|
||||
<p>待审核、待仲裁、订单和流水概览。</p>
|
||||
<p>实时监控平台运营数据,快速处理待办事项。</p>
|
||||
</div>
|
||||
<el-button @click="loadDashboard">刷新</el-button>
|
||||
<el-button @click="loadDashboard" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && !dashboard" class="loading-state">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error && !dashboard" class="error-state">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>{{ error }}</span>
|
||||
<el-button @click="loadDashboard" type="primary">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 核心指标卡片 -->
|
||||
<div v-if="dashboard" class="metric-grid dashboard-metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card metric-card--primary">
|
||||
<div class="metric-icon">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="metric-content">
|
||||
<span>用户总数</span>
|
||||
<strong>{{ dashboard.metrics.total_users }}</strong>
|
||||
<small>实名 {{ dashboard.metrics.verified_users }} 人</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>实名用户</span>
|
||||
<strong>{{ dashboard.metrics.verified_users }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card metric-card--success">
|
||||
<div class="metric-icon">
|
||||
<el-icon><ShoppingBag /></el-icon>
|
||||
</div>
|
||||
<div class="metric-content">
|
||||
<span>发布商品</span>
|
||||
<strong>{{ dashboard.metrics.total_listings }}</strong>
|
||||
<small>已上架 {{ dashboard.metrics.published_listings }} 个</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>已上架</span>
|
||||
<strong>{{ dashboard.metrics.published_listings }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card metric-card--warning">
|
||||
<div class="metric-icon">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<div class="metric-content">
|
||||
<span>订单总数</span>
|
||||
<strong>{{ dashboard.metrics.total_orders }}</strong>
|
||||
<small>使用中 {{ dashboard.metrics.renting_orders }} 个</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>使用中</span>
|
||||
<strong>{{ dashboard.metrics.renting_orders }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>今日订单</span>
|
||||
<strong>{{ dashboard.metrics.today_orders }}</strong>
|
||||
<div class="metric-card metric-card--danger">
|
||||
<div class="metric-icon">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-content">
|
||||
<span>今日流水</span>
|
||||
<strong>{{ money(dashboard.metrics.today_ledger_amount) }}</strong>
|
||||
<small>今日订单 {{ dashboard.metrics.today_orders }} 个</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待处理事项 & 快捷入口 -->
|
||||
<div v-if="dashboard" class="dashboard-panels">
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>待处理</h2>
|
||||
<div class="dashboard-panel">
|
||||
<div class="panel-header">
|
||||
<h2>
|
||||
<el-icon><Bell /></el-icon>
|
||||
待处理事项
|
||||
</h2>
|
||||
<el-tag type="warning" effect="dark" round>
|
||||
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }} 项
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="pending-list">
|
||||
<RouterLink class="pending-row" to="/admin/disputes">
|
||||
<span>待仲裁申诉</span>
|
||||
<strong>{{ dashboard.pending.disputes }}</strong>
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待仲裁申诉</span>
|
||||
<span class="pending-desc">需要处理的用户争议</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{ dashboard.pending.disputes }}</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/listings/review">
|
||||
<span>待审核商品</span>
|
||||
<strong>{{ dashboard.pending.listing_reviews }}</strong>
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待审核商品</span>
|
||||
<span class="pending-desc">新提交的商品审核</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{ dashboard.pending.listing_reviews }}</strong>
|
||||
</RouterLink>
|
||||
<div class="pending-row">
|
||||
<span>待交接订单</span>
|
||||
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待交接订单</span>
|
||||
<span class="pending-desc">等待卖家交接</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{ dashboard.pending.pending_handoffs }}</strong>
|
||||
</div>
|
||||
<div class="pending-row">
|
||||
<span>待结账确认</span>
|
||||
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待结账确认</span>
|
||||
<span class="pending-desc">等待双方确认</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>后台入口</h2>
|
||||
<RouterLink class="pending-row" to="/admin/users">
|
||||
<div class="dashboard-panel">
|
||||
<div class="panel-header">
|
||||
<h2>
|
||||
<el-icon><Grid /></el-icon>
|
||||
快捷入口
|
||||
</h2>
|
||||
</div>
|
||||
<div class="quick-links">
|
||||
<RouterLink class="quick-link" to="/admin/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/orders">
|
||||
<RouterLink class="quick-link" to="/admin/orders">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<span>订单管理</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/disputes">
|
||||
<RouterLink class="quick-link" to="/admin/disputes">
|
||||
<el-icon><ScaleToOriginal /></el-icon>
|
||||
<span>仲裁中心</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/listings/review">
|
||||
<span>商品审核</span>
|
||||
<strong>进入</strong>
|
||||
<RouterLink class="quick-link" to="/admin/listings">
|
||||
<el-icon><Shop /></el-icon>
|
||||
<span>商品管理</span>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/system-configs">
|
||||
<RouterLink class="quick-link" to="/admin/chats">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>客服群聊</span>
|
||||
</RouterLink>
|
||||
<RouterLink class="quick-link" to="/admin/system-configs">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span>系统配置</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="dashboard" class="table-panel" :data="dashboard.recent_orders">
|
||||
<el-table-column prop="order_no" label="最近订单" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
||||
<!-- 最近订单 -->
|
||||
<div v-if="dashboard" class="dashboard-section">
|
||||
<h2 class="section-title">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
最近订单
|
||||
</h2>
|
||||
<el-table class="table-panel" :data="dashboard.recent_orders">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="商品名称" min-width="170" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'renting' ? 'primary' : 'warning'" size="small" effect="plain">
|
||||
{{ orderStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rent_amount" label="金额" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="amount">{{ money(row.rent_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rent_amount" label="订单金额" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-table v-if="dashboard" class="table-panel" :data="dashboard.recent_disputes">
|
||||
<el-table-column prop="order_no" label="最近申诉" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column prop="type" label="类型" width="160" />
|
||||
<!-- 最近申诉 -->
|
||||
<div v-if="dashboard && dashboard.recent_disputes.length > 0" class="dashboard-section">
|
||||
<h2 class="section-title">
|
||||
<el-icon><Warning /></el-icon>
|
||||
最近申诉
|
||||
</h2>
|
||||
<el-table class="table-panel" :data="dashboard.recent_disputes">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="商品名称" min-width="170" />
|
||||
<el-table-column prop="type" label="申诉类型" width="140" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small" effect="plain">
|
||||
{{ disputeStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
@@ -142,10 +228,266 @@ function money(value?: number) {
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default>
|
||||
<RouterLink to="/admin/disputes">
|
||||
<el-button size="small">处理</el-button>
|
||||
<el-button size="small" type="primary" link>处理</el-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 300px;
|
||||
color: #8f9bba;
|
||||
}
|
||||
|
||||
.loading-state .el-icon {
|
||||
font-size: 40px;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.loading-state span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 300px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-state .el-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.error-state span {
|
||||
font-size: 14px;
|
||||
color: #8f9bba;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-card--primary .metric-icon {
|
||||
background: rgba(79, 124, 255, 0.1);
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.metric-card--success .metric-icon {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.metric-card--warning .metric-icon {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.metric-card--danger .metric-icon {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-content span {
|
||||
font-size: 13px;
|
||||
color: #8f9bba;
|
||||
}
|
||||
|
||||
.metric-content strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1b2559;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-content small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #a3aed0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.panel-header h2 .el-icon {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pending-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pending-row:hover {
|
||||
background: #f8f9fe;
|
||||
}
|
||||
|
||||
.pending-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pending-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.pending-desc {
|
||||
font-size: 12px;
|
||||
color: #a3aed0;
|
||||
}
|
||||
|
||||
.pending-row strong {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #10b981;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pending-row strong.has-pending {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.quick-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
border-radius: 12px;
|
||||
background: #f8f9fe;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-link:hover {
|
||||
background: #eef2ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.quick-link .el-icon {
|
||||
font-size: 24px;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.quick-link span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.section-title .el-icon {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 700;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-links {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metric-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric-content strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { fetchAdminListings, type AdminListingQuery, type Listing } from '@/api/listings'
|
||||
import { useAdminTable } from '@/composables/useAdminTable'
|
||||
import { useMoney } from '@/composables/useMoney'
|
||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const money = useMoney()
|
||||
|
||||
const filters = reactive<AdminListingQuery>({
|
||||
owner_id: '',
|
||||
status: '',
|
||||
@@ -15,21 +17,14 @@ const filters = reactive<AdminListingQuery>({
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loading, data: listings, load: loadListings } = useAdminTable<Listing>({
|
||||
fetchFn: () => fetchAdminListings(filters),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchAdminListings(filters)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.owner_id = ''
|
||||
filters.status = ''
|
||||
@@ -38,10 +33,6 @@ function resetFilters() {
|
||||
void loadListings()
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return `¥${Math.round(Number(value || 0))}`
|
||||
}
|
||||
|
||||
function listingPrice(row: Listing) {
|
||||
return money(row.price)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { fetchAdminOrders, type Order } from '@/api/orders'
|
||||
import { useAdminTable } from '@/composables/useAdminTable'
|
||||
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const status = ref('')
|
||||
|
||||
const { loading, data: orders, load: loadOrders } = useAdminTable<Order>({
|
||||
fetchFn: fetchAdminOrders,
|
||||
})
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!status.value) return orders.value
|
||||
return orders.value.filter((item) => item.status === status.value)
|
||||
})
|
||||
|
||||
onMounted(loadOrders)
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchAdminOrders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
|
||||
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
|
||||
import { userStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const activeUser = ref<AdminUserItem | null>(null)
|
||||
const freezeReason = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
|
||||
users.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
const { loading, data: users, total, currentPage, currentPageSize, load: loadUsers, handleSizeChange } = useAdminPaginatedTable<AdminUserItem>({
|
||||
fetchFn: fetchAdminUsers,
|
||||
})
|
||||
|
||||
function openFreeze(row: AdminUserItem) {
|
||||
activeUser.value = row
|
||||
|
||||
Reference in New Issue
Block a user