first commit
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { createAdmin, deleteAdmin, getAdminList, updateAdmin } from '@/api/admin'
|
||||
import type { AdminInfo } from '@/types/auth'
|
||||
import { ADMIN_ROLE, ADMIN_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<AdminInfo[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAdminList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 创建 / 编辑 ---------------- */
|
||||
const dialogVisible = ref(false)
|
||||
const dialogLoading = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
role: 2,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ min: 3, max: 32, message: '账号长度 3~32 位', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (editId.value === null) {
|
||||
if (!value) return callback(new Error('请输入初始密码'))
|
||||
if (value.length < 6 || value.length > 64) return callback(new Error('密码长度 6~64 位'))
|
||||
} else if (value && (value.length < 6 || value.length > 64)) {
|
||||
return callback(new Error('密码长度 6~64 位'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
nickname: [{ max: 32, message: '昵称不超过 32 字', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, { username: '', password: '', nickname: '', role: 2, status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: AdminInfo) {
|
||||
editId.value = row.id
|
||||
Object.assign(form, {
|
||||
username: row.username,
|
||||
password: '',
|
||||
nickname: row.nickname,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
if (editId.value === null) {
|
||||
await createAdmin({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname || undefined,
|
||||
role: form.role,
|
||||
})
|
||||
ElMessage.success('管理员创建成功')
|
||||
} else {
|
||||
await updateAdmin(editId.value, {
|
||||
nickname: form.nickname || undefined,
|
||||
password: form.password || undefined,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
})
|
||||
ElMessage.success('管理员更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 删除 ---------------- */
|
||||
async function handleDelete(row: AdminInfo) {
|
||||
if (row.id === authStore.adminInfo?.id) {
|
||||
ElMessage.warning('不能删除自己的账号')
|
||||
return
|
||||
}
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`删除后该管理员将无法登录(软删除),确认删除「${row.username}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await deleteAdmin(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.status" placeholder="账号状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ADMIN_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增管理员</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="130" />
|
||||
<el-table-column label="昵称" min-width="120">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatText(row.nickname) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-tag :type="enumTag(ADMIN_ROLE, row.role)" size="small">
|
||||
{{ enumLabel(ADMIN_ROLE, row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-tag :type="enumTag(ADMIN_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(ADMIN_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="150">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.last_login_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="row.id === authStore.adminInfo?.id"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无管理员数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 创建 / 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editId === null ? '新增管理员' : '编辑管理员'"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input v-model="form.username" :disabled="editId !== null" placeholder="3~32 位账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="editId === null ? '初始密码(6~64 位)' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" maxlength="32" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="form.role" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ADMIN_ROLE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId !== null" label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio v-for="opt in enumOptions(ADMIN_STATUS)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||
{{ editId === null ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,711 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import BaseChart from '@/components/BaseChart.vue'
|
||||
import { getDashboardSummary, getDashboardTrends } from '@/api/dashboard'
|
||||
import type { DashboardSummary, DashboardTrends } from '@/types/dashboard'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
import { GOODS_TYPE, enumLabel } from '@/utils/constants'
|
||||
|
||||
const loading = ref(false)
|
||||
const summary = ref<DashboardSummary | null>(null)
|
||||
const trendDays = ref<7 | 30>(7)
|
||||
const trend = ref<DashboardTrends | null>(null)
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
summary.value = await getDashboardSummary()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrends() {
|
||||
try {
|
||||
trend.value = await getDashboardTrends(trendDays.value)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSummary()
|
||||
fetchTrends()
|
||||
})
|
||||
|
||||
watch(trendDays, fetchTrends)
|
||||
|
||||
/* ---------- 图表配置 ---------- */
|
||||
|
||||
function pieOption(
|
||||
data: { name: string; value: number }[],
|
||||
colors: string[],
|
||||
): EChartsOption {
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
icon: 'circle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#5d6685', fontSize: 12 },
|
||||
},
|
||||
color: colors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['48%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: false } },
|
||||
data,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const trendOption = computed<EChartsOption>(() => {
|
||||
const points = trend.value?.points ?? []
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['新增用户', '订单量', '营收(元)'],
|
||||
bottom: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#5d6685' },
|
||||
},
|
||||
color: ['#4f6ef7', '#22c55e', '#f59e0b'],
|
||||
grid: { left: 56, right: 64, top: 40, bottom: 44 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: points.map((p) => p.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#e5e8f2' } },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
minInterval: 1,
|
||||
nameTextStyle: { color: '#9aa3c0' },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
splitLine: { lineStyle: { color: '#eef1f8' } },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '营收(元)',
|
||||
nameTextStyle: { color: '#9aa3c0' },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '新增用户',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: points.map((p) => p.new_users),
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: points.map((p) => p.orders),
|
||||
},
|
||||
{
|
||||
name: '营收(元)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
yAxisIndex: 1,
|
||||
data: points.map((p) => +(p.revenue / 100).toFixed(2)),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const outfitPieOption = computed<EChartsOption>(() => {
|
||||
const o = summary.value?.content.outfit
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '待审核', value: o?.pending ?? 0 },
|
||||
{ name: '已通过', value: o?.passed ?? 0 },
|
||||
{ name: '未通过', value: o?.rejected ?? 0 },
|
||||
],
|
||||
['#f59e0b', '#22c55e', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const clothesPieOption = computed<EChartsOption>(() => {
|
||||
const c = summary.value?.content.clothes
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '处理中', value: c?.processing ?? 0 },
|
||||
{ name: '成功', value: c?.success ?? 0 },
|
||||
{ name: '失败', value: c?.failed ?? 0 },
|
||||
],
|
||||
['#06b6d4', '#22c55e', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const sharePieOption = computed<EChartsOption>(() => {
|
||||
const s = summary.value?.content.share
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '正常', value: s?.normal ?? 0 },
|
||||
{ name: '已取消', value: s?.cancelled ?? 0 },
|
||||
{ name: '已过期', value: s?.expired ?? 0 },
|
||||
{ name: '已封禁', value: s?.banned ?? 0 },
|
||||
],
|
||||
['#22c55e', '#9aa3c0', '#cbd5e1', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const vipPieOption = computed<EChartsOption>(() => {
|
||||
const v = summary.value?.vip
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '月度会员', value: v?.monthly ?? 0 },
|
||||
{ name: '年度会员', value: v?.yearly ?? 0 },
|
||||
{ name: '体验会员', value: v?.trial ?? 0 },
|
||||
],
|
||||
['#4f6ef7', '#8b5cf6', '#22c55e'],
|
||||
)
|
||||
})
|
||||
|
||||
const goodsTypePieOption = computed<EChartsOption>(() => {
|
||||
const list = summary.value?.goods.by_type ?? []
|
||||
return pieOption(
|
||||
list.map((item) => ({ name: enumLabel(GOODS_TYPE, item.type), value: item.count })),
|
||||
['#4f6ef7', '#06b6d4', '#8b5cf6', '#f59e0b', '#22c55e'],
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="dashboard">
|
||||
<!-- 欢迎横幅 -->
|
||||
<div class="hero-banner">
|
||||
<div>
|
||||
<h2 class="hero-title">纸条衣橱 · 运营总览</h2>
|
||||
<p class="hero-sub">掌握用户、交易与内容审核的核心数据动态</p>
|
||||
</div>
|
||||
<div class="hero-todo">
|
||||
<div class="todo-item">
|
||||
<span class="todo-num">{{ summary?.todo.refund_pending ?? 0 }}</span>
|
||||
<span class="todo-label">待处理退款</span>
|
||||
</div>
|
||||
<div class="todo-divider"></div>
|
||||
<div class="todo-item">
|
||||
<span class="todo-num">{{ summary?.todo.outfit_pending_audit ?? 0 }}</span>
|
||||
<span class="todo-label">待审核穿搭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 营收卡片 -->
|
||||
<div class="stat-row">
|
||||
<div class="stat-card revenue-card">
|
||||
<div class="stat-icon icon-revenue"><el-icon :size="22"><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">累计营收(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.total_revenue) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-today"><el-icon :size="22"><TrendCharts /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">今日营收(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.today_revenue) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-refund"><el-icon :size="22"><RefreshLeft /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">累计退款(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.refund_total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-shelf"><el-icon :size="22"><ShoppingCart /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">上架商品数</div>
|
||||
<div class="stat-value">{{ summary?.todo.goods_on_shelf ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日消耗与内容互动 -->
|
||||
<div class="stat-row two-col">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">今日消耗</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-blue">{{ summary?.usage.ai_recommend ?? 0 }}</div>
|
||||
<div class="mini-stat-label">AI 推荐</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.usage.closet_share ?? 0 }}</div>
|
||||
<div class="mini-stat-label">衣柜分享</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.usage.outfit_share ?? 0 }}</div>
|
||||
<div class="mini-stat-label">社区分享</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">内容互动</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-green">{{ summary?.social.cos_image_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">COS 总图数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-warn">{{ summary?.social.like_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">点赞数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.social.collect_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">收藏数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据趋势 -->
|
||||
<div class="panel trend-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">数据趋势</span>
|
||||
<el-radio-group v-model="trendDays" size="small">
|
||||
<el-radio-button :value="7">近 7 天</el-radio-button>
|
||||
<el-radio-button :value="30">近 30 天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<BaseChart :option="trendOption" height="320px" />
|
||||
</div>
|
||||
|
||||
<!-- 三列分布 -->
|
||||
<div class="stat-row three-col">
|
||||
<!-- 内容审核概况 -->
|
||||
<div class="panel dist-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">内容审核概况</span>
|
||||
<el-tag size="small" type="warning" effect="light">待审穿搭 {{ summary?.content.outfit.pending ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">穿搭审核</div>
|
||||
<BaseChart :option="outfitPieOption" height="120px" />
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">衣物图片处理</div>
|
||||
<BaseChart :option="clothesPieOption" height="120px" />
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">衣柜分享</div>
|
||||
<BaseChart :option="sharePieOption" height="120px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIP 会员概况 -->
|
||||
<div class="panel dist-panel dist-panel--center">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">VIP 会员概况</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.vip.member_total ?? 0 }}</div>
|
||||
<div class="mini-stat-label">有效会员</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-warn">{{ summary?.vip.expiring_soon ?? 0 }}</div>
|
||||
<div class="mini-stat-label">7 天内到期</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseChart :option="vipPieOption" height="220px" />
|
||||
</div>
|
||||
|
||||
<!-- 商品分类统计 -->
|
||||
<div class="panel dist-panel dist-panel--center">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">商品分类统计</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid three">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.goods.total ?? 0 }}</div>
|
||||
<div class="mini-stat-label">总数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-green">{{ summary?.goods.on_shelf ?? 0 }}</div>
|
||||
<div class="mini-stat-label">上架</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-gray">{{ summary?.goods.off_shelf ?? 0 }}</div>
|
||||
<div class="mini-stat-label">下架</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseChart :option="goodsTypePieOption" height="220px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<!-- 用户分布 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">用户概况</span>
|
||||
<el-tag size="small" type="primary" effect="light">今日新增 {{ summary?.users.today_new ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="panel-main-number">{{ summary?.users.total ?? 0 }}</div>
|
||||
<div class="panel-sub">用户总数</div>
|
||||
<div class="dist-list">
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-green"></span>
|
||||
<span class="dist-label">正常</span>
|
||||
<span class="dist-value">{{ summary?.users.normal ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-orange"></span>
|
||||
<span class="dist-label">临时禁用</span>
|
||||
<span class="dist-value">{{ summary?.users.temp_ban ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-red"></span>
|
||||
<span class="dist-label">永久禁用</span>
|
||||
<span class="dist-value">{{ summary?.users.perm_ban ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-gray"></span>
|
||||
<span class="dist-label">已注销</span>
|
||||
<span class="dist-value">{{ summary?.users.deactivated ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单分布 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">订单概况</span>
|
||||
<el-tag size="small" type="success" effect="light">已支付 {{ summary?.orders.paid ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="panel-main-number">{{ summary?.orders.total ?? 0 }}</div>
|
||||
<div class="panel-sub">订单总数</div>
|
||||
<div class="dist-list">
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-orange"></span>
|
||||
<span class="dist-label">待支付</span>
|
||||
<span class="dist-value">{{ summary?.orders.pending ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-blue"></span>
|
||||
<span class="dist-label">已取消</span>
|
||||
<span class="dist-value">{{ summary?.orders.cancelled ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-gray"></span>
|
||||
<span class="dist-label">已过期</span>
|
||||
<span class="dist-value">{{ summary?.orders.expired ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-red"></span>
|
||||
<span class="dist-label">已退款</span>
|
||||
<span class="dist-value">{{ summary?.orders.refunded ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.hero-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #4f6ef7 0%, #6a5cf0 55%, #8b5cf6 100%);
|
||||
box-shadow: 0 10px 26px rgba(79, 110, 247, 0.32);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-todo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.todo-num {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.todo-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.todo-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.three-col {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.two-col {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 22px rgba(30, 36, 56, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-revenue {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||
}
|
||||
.icon-today {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
}
|
||||
.icon-refund {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
.icon-shelf {
|
||||
background: linear-gradient(135deg, #06b6d4, #0ea5e9);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
}
|
||||
|
||||
.trend-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dist-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dist-panel--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.panel-main-number {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel-sub {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audit-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.audit-block:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5d6685;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mini-stat-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
flex: 1;
|
||||
background: #f7f9fd;
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-warn {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-green {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-gray {
|
||||
color: #9aa3c0;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-blue {
|
||||
color: #4f6ef7;
|
||||
}
|
||||
|
||||
.mini-stat-label {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5d6685;
|
||||
}
|
||||
|
||||
.dist-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot-green {
|
||||
background: #22c55e;
|
||||
}
|
||||
.dot-orange {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot-red {
|
||||
background: #ef4444;
|
||||
}
|
||||
.dot-blue {
|
||||
background: #4f6ef7;
|
||||
}
|
||||
.dot-gray {
|
||||
background: #9aa3c0;
|
||||
}
|
||||
|
||||
.dist-value {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { createGoods, deleteGoods, getGoodsList, updateGoods, updateGoodsStatus } from '@/api/goods'
|
||||
import type { GoodsItem } from '@/types/goods'
|
||||
import { GOODS_STATUS, GOODS_TYPE, PRODUCT_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText, yuanToCents } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<GoodsItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
goods_type: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getGoodsList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
goods_type: query.goods_type,
|
||||
status: query.status,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.goods_type = undefined
|
||||
query.status = undefined
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 新增 / 编辑 ---------------- */
|
||||
const dialogVisible = ref(false)
|
||||
const dialogLoading = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
interface GoodsForm {
|
||||
goods_code: string
|
||||
goods_name: string
|
||||
goods_type: number
|
||||
price: number
|
||||
discount_price: number
|
||||
member_days: number
|
||||
upload_quota: number
|
||||
ai_recommend_quota: number
|
||||
closet_share_quota: number
|
||||
outfit_share_quota: number
|
||||
valid_days: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const form = reactive<GoodsForm>({
|
||||
goods_code: '',
|
||||
goods_name: '',
|
||||
goods_type: 1,
|
||||
price: 0,
|
||||
discount_price: 0,
|
||||
member_days: 0,
|
||||
upload_quota: 0,
|
||||
ai_recommend_quota: 0,
|
||||
closet_share_quota: 0,
|
||||
outfit_share_quota: 0,
|
||||
valid_days: 0,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
goods_code: [
|
||||
{ required: true, message: '请输入商品编码', trigger: 'blur' },
|
||||
{ max: 64, message: '编码不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
goods_name: [
|
||||
{ required: true, message: '请输入商品名称', trigger: 'blur' },
|
||||
{ max: 64, message: '名称不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
goods_type: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
|
||||
price: [{ required: true, message: '请输入原价', trigger: 'blur' }],
|
||||
discount_price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
goods_code: '',
|
||||
goods_name: '',
|
||||
goods_type: 1,
|
||||
price: 0,
|
||||
discount_price: 0,
|
||||
member_days: 0,
|
||||
upload_quota: 0,
|
||||
ai_recommend_quota: 0,
|
||||
closet_share_quota: 0,
|
||||
outfit_share_quota: 0,
|
||||
valid_days: 0,
|
||||
description: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: GoodsItem) {
|
||||
editId.value = row.id
|
||||
Object.assign(form, {
|
||||
goods_code: row.goods_code,
|
||||
goods_name: row.goods_name,
|
||||
goods_type: row.goods_type,
|
||||
price: row.price / 100,
|
||||
discount_price: row.discount_price / 100,
|
||||
member_days: row.member_days,
|
||||
upload_quota: row.upload_quota,
|
||||
ai_recommend_quota: row.ai_recommend_quota,
|
||||
closet_share_quota: row.closet_share_quota,
|
||||
outfit_share_quota: row.outfit_share_quota,
|
||||
valid_days: row.valid_days,
|
||||
description: row.description,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const price = yuanToCents(form.price)
|
||||
const discountPrice = yuanToCents(form.discount_price)
|
||||
if (editId.value === null) {
|
||||
await createGoods({
|
||||
goods_code: form.goods_code,
|
||||
goods_name: form.goods_name,
|
||||
goods_type: form.goods_type,
|
||||
price,
|
||||
discount_price: discountPrice,
|
||||
member_days: form.member_days,
|
||||
upload_quota: form.upload_quota,
|
||||
ai_recommend_quota: form.ai_recommend_quota,
|
||||
closet_share_quota: form.closet_share_quota,
|
||||
outfit_share_quota: form.outfit_share_quota,
|
||||
valid_days: form.valid_days,
|
||||
description: form.description || undefined,
|
||||
})
|
||||
ElMessage.success('商品创建成功')
|
||||
} else {
|
||||
await updateGoods(editId.value, {
|
||||
goods_name: form.goods_name,
|
||||
price,
|
||||
discount_price: discountPrice,
|
||||
member_days: form.member_days,
|
||||
upload_quota: form.upload_quota,
|
||||
ai_recommend_quota: form.ai_recommend_quota,
|
||||
closet_share_quota: form.closet_share_quota,
|
||||
outfit_share_quota: form.outfit_share_quota,
|
||||
valid_days: form.valid_days,
|
||||
description: form.description || undefined,
|
||||
})
|
||||
ElMessage.success('商品更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 上下架 / 删除 ---------------- */
|
||||
async function toggleStatus(row: GoodsItem) {
|
||||
const nextStatus = row.status === 1 ? 2 : 1
|
||||
const action = nextStatus === 2 ? '下架' : '上架'
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认将商品「${row.goods_name}」${action}吗?`,
|
||||
`${action}确认`,
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await updateGoodsStatus(row.id, { status: nextStatus })
|
||||
ElMessage.success(`${action}成功`)
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleDelete(row: GoodsItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`删除后商品将不可见(软删除),确认删除「${row.goods_name}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await deleteGoods(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.goods_type" placeholder="商品类型" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.status" placeholder="状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="商品名称 / 编码"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增商品</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="goods_code" label="商品编码" width="130" />
|
||||
<el-table-column prop="goods_name" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="110">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag :type="enumTag(GOODS_TYPE, row.goods_type)" size="small">
|
||||
{{ enumLabel(GOODS_TYPE, row.goods_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原价" width="100" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<span class="price-original">¥{{ formatMoney(row.price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="售价" width="100" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.discount_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_days" label="会员天数" width="90" align="right" />
|
||||
<el-table-column label="上传额度" width="90" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ row.upload_quota }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="AI 额度" width="90" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ row.ai_recommend_quota }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟支付" width="110">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag v-if="row.product_id" :type="enumTag(PRODUCT_STATUS, row.product_status)" size="small">
|
||||
{{ enumLabel(PRODUCT_STATUS, row.product_status) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag :type="enumTag(GOODS_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(GOODS_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 1" link type="warning" size="small" @click="toggleStatus(row)">
|
||||
下架
|
||||
</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="toggleStatus(row)">上架</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无商品数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editId === null ? '新增商品' : '编辑商品'"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品编码" prop="goods_code">
|
||||
<el-input v-model="form.goods_code" :disabled="editId !== null" placeholder="唯一编码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品名称" prop="goods_name">
|
||||
<el-input v-model="form.goods_name" placeholder="商品名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品类型" prop="goods_type">
|
||||
<el-select v-model="form.goods_type" style="width: 100%" :disabled="editId !== null">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="会员天数">
|
||||
<el-input-number v-model="form.member_days" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="原价(元)" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="售价(元)" prop="discount_price">
|
||||
<el-input-number v-model="form.discount_price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上传额度">
|
||||
<el-input-number v-model="form.upload_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="AI 推荐额度">
|
||||
<el-input-number v-model="form.ai_recommend_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="衣柜分享额度">
|
||||
<el-input-number v-model="form.closet_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="搭配分享额度">
|
||||
<el-input-number v-model="form.outfit_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="有效期天数">
|
||||
<el-input-number v-model="form.valid_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||
<div class="form-tip">加量包有效期;会员包填 0 表示跟随 VIP</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商品描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||
{{ editId === null ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
color: #9aa3c0;
|
||||
text-decoration: line-through;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Lock, User } from '@element-plus/icons-vue'
|
||||
import { login } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminLoginRequest } from '@/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<AdminLoginRequest>({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules<AdminLoginRequest> = {
|
||||
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login({ username: form.username.trim(), password: form.password })
|
||||
authStore.setSession(res)
|
||||
ElMessage.success(`欢迎回来,${res.admin_info.nickname || res.admin_info.username}`)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
router.replace(redirect)
|
||||
} catch {
|
||||
// 错误提示已由请求层统一处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg-blob blob-a"></div>
|
||||
<div class="login-bg-blob blob-b"></div>
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="login-logo">
|
||||
<el-icon :size="30"><User /></el-icon>
|
||||
</div>
|
||||
<h1 class="login-title">纸条衣橱管理后台</h1>
|
||||
<p class="login-subtitle">ZhiTiao Closet Admin Console</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="登录账号" :prefix-icon="User" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-submit"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1e2438 0%, #2c3560 55%, #4f6ef7 130%);
|
||||
}
|
||||
|
||||
.login-bg-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.45;
|
||||
animation: blob-float 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.blob-a {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: #4f6ef7;
|
||||
top: -120px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.blob-b {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: #8b5cf6;
|
||||
bottom: -120px;
|
||||
right: -80px;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
@keyframes blob-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(40px, -30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 400px;
|
||||
padding: 44px 40px 36px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 24px 64px rgba(10, 16, 40, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
margin: 0 auto 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||
box-shadow: 0 8px 20px rgba(79, 110, 247, 0.4);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Bell, Promotion } from '@element-plus/icons-vue'
|
||||
import { broadcastNotification, sendNotification } from '@/api/notification'
|
||||
|
||||
/* ---------------- 单用户通知 ---------------- */
|
||||
const singleFormRef = ref<FormInstance>()
|
||||
const singleLoading = ref(false)
|
||||
|
||||
const singleForm = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
const singleRules: FormRules = {
|
||||
user_id: [{ required: true, message: '请输入用户 ID', trigger: 'blur' }],
|
||||
title: [
|
||||
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function submitSingle() {
|
||||
const valid = await singleFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !singleForm.user_id) return
|
||||
singleLoading.value = true
|
||||
try {
|
||||
await sendNotification({
|
||||
user_id: singleForm.user_id,
|
||||
title: singleForm.title,
|
||||
content: singleForm.content,
|
||||
})
|
||||
ElMessage.success('通知已发送')
|
||||
singleForm.title = ''
|
||||
singleForm.content = ''
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
singleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 全量广播 ---------------- */
|
||||
const broadcastFormRef = ref<FormInstance>()
|
||||
const broadcastLoading = ref(false)
|
||||
|
||||
const broadcastForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
const broadcastRules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function submitBroadcast() {
|
||||
const valid = await broadcastFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
'全量广播将向所有用户推送该通知(分批写入),确认发送吗?',
|
||||
'广播确认',
|
||||
{ type: 'warning', confirmButtonText: '确认广播' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
broadcastLoading.value = true
|
||||
try {
|
||||
const res = await broadcastNotification({
|
||||
title: broadcastForm.title,
|
||||
content: broadcastForm.content,
|
||||
})
|
||||
ElMessage.success(`广播成功,已推送 ${res.sent_count} 位用户`)
|
||||
broadcastForm.title = ''
|
||||
broadcastForm.content = ''
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
broadcastLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify-page">
|
||||
<div class="notify-grid">
|
||||
<!-- 单用户通知 -->
|
||||
<div class="zt-card notify-card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-icon icon-single"><el-icon :size="20"><Bell /></el-icon></div>
|
||||
<div>
|
||||
<div class="zt-card-title" style="margin-bottom: 2px">单用户通知</div>
|
||||
<div class="card-sub">向指定用户发送系统通知,实时送达小程序</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="singleFormRef" :model="singleForm" :rules="singleRules" label-width="80px">
|
||||
<el-form-item label="用户 ID" prop="user_id">
|
||||
<el-input-number
|
||||
v-model="singleForm.user_id"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
placeholder="请输入用户 ID"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="singleForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="singleForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="通知内容(≤500 字)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Bell" :loading="singleLoading" @click="submitSingle">
|
||||
发送通知
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 全量广播 -->
|
||||
<div class="zt-card notify-card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-icon icon-broadcast"><el-icon :size="20"><Promotion /></el-icon></div>
|
||||
<div>
|
||||
<div class="zt-card-title" style="margin-bottom: 2px">全量广播</div>
|
||||
<div class="card-sub">向全部用户推送,分批写入并同步未读计数</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="broadcastFormRef" :model="broadcastForm" :rules="broadcastRules" label-width="80px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="broadcastForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="broadcastForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="通知内容(≤500 字)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="danger" :icon="Promotion" :loading="broadcastLoading" @click="submitBroadcast">
|
||||
全量广播
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.notify-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-head-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-single {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||
}
|
||||
|
||||
.icon-broadcast {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { getOrderDetail, getOrderList } from '@/api/order'
|
||||
import type { OrderItem } from '@/types/order'
|
||||
import { ORDER_STATUS, PAY_CHANNEL, XPAY_ENV, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<OrderItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
order_no: '',
|
||||
user_id: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOrderList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
order_no: query.order_no || undefined,
|
||||
user_id: query.user_id || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.order_no = ''
|
||||
query.user_id = undefined
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<OrderItem | null>(null)
|
||||
|
||||
async function openDetail(row: OrderItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getOrderDetail(row.order_no)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model="query.order_no"
|
||||
placeholder="订单号(精确)"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="订单状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ORDER_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column prop="goods_name" label="商品" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="实付金额" width="110" align="right">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.pay_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<el-tag :type="enumTag(ORDER_STATUS, row.order_status)" size="small">
|
||||
{{ enumLabel(ORDER_STATUS, row.order_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付渠道" width="100">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
{{ enumLabel(PAY_CHANNEL, row.pay_channel) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="150">
|
||||
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.pay_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无订单数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 订单详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="订单详情" size="480px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<el-descriptions v-if="detail" :column="1" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.order_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称">{{ formatText(detail.goods_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(ORDER_STATUS, detail.order_status)" size="small">
|
||||
{{ enumLabel(ORDER_STATUS, detail.order_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付渠道">
|
||||
{{ enumLabel(PAY_CHANNEL, detail.pay_channel) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="微信交易号">{{ formatText(detail.transaction_id) }}</el-descriptions-item>
|
||||
<template v-if="detail.xpay_product_id">
|
||||
<el-descriptions-item label="虚拟支付道具 ID">{{ formatText(detail.xpay_product_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟支付平台">{{ formatText(detail.xpay_platform) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟支付环境">{{ enumLabel(XPAY_ENV, detail.xpay_env) }}</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="单价">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.unit_price) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单总额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.total_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实付金额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.pay_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="退款金额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.refund_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="退款时间">{{ formatDate(detail.refund_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员天数">{{ detail.member_days }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上传额度">{{ detail.upload_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="AI 推荐额度">{{ detail.ai_recommend_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="衣柜分享额度">{{ detail.closet_share_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="搭配分享额度">{{ detail.outfit_share_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="权益到期">{{ formatDate(detail.expire_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">{{ formatDate(detail.pay_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { approveRefund, getRefundList, rejectRefund } from '@/api/order'
|
||||
import type { RefundItem } from '@/types/order'
|
||||
import { REFUND_STATUS, enumLabel, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<RefundItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
/**
|
||||
* 状态筛选:undefined = 全部(不传),0 = 待处理(显式传 0)
|
||||
* 后端用指针区分「不传」与「传 0」,前端用 -1 作为「全部」的占位值
|
||||
*/
|
||||
const query = reactive({
|
||||
status: -1 as number,
|
||||
order_no: '',
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: -1, label: '全部状态' },
|
||||
{ value: 0, label: '待处理' },
|
||||
{ value: 1, label: '退款中' },
|
||||
{ value: 2, label: '退款成功' },
|
||||
{ value: 3, label: '退款失败' },
|
||||
]
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRefundList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: query.status === -1 ? undefined : query.status,
|
||||
order_no: query.order_no || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = -1
|
||||
query.order_no = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 审核(通过 / 驳回) ---------------- */
|
||||
const auditVisible = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const auditMode = ref<'approve' | 'reject'>('approve')
|
||||
const auditTarget = ref<RefundItem | null>(null)
|
||||
const auditFormRef = ref<FormInstance>()
|
||||
|
||||
const auditForm = reactive({
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const auditRules: FormRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写审核备注', trigger: 'blur' },
|
||||
{ max: 200, message: '备注不超过 200 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
function openAudit(mode: 'approve' | 'reject', row: RefundItem) {
|
||||
auditMode.value = mode
|
||||
auditTarget.value = row
|
||||
auditForm.reason = ''
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !auditTarget.value) return
|
||||
auditLoading.value = true
|
||||
try {
|
||||
if (auditMode.value === 'approve') {
|
||||
await approveRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||
ElMessage.success('已通过,退款单进入「退款中」状态')
|
||||
} else {
|
||||
await rejectRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||
ElMessage.success('已驳回')
|
||||
}
|
||||
auditVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReject(row: RefundItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`驳回后该退款单将标记为「退款失败」,确认驳回订单 ${row.order_no} 的退款吗?`,
|
||||
'驳回确认',
|
||||
{ type: 'warning', confirmButtonText: '继续驳回' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
openAudit('reject', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.status" placeholder="退款状态">
|
||||
<el-option
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.order_no"
|
||||
placeholder="关联订单号"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="refund_no" label="退款单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="order_no" label="关联订单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="退款金额" width="110" align="right">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.refund_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<el-tag :type="enumTag(REFUND_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(REFUND_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请原因" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.refund_reason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" width="150">
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatDate(row.apply_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.fail_reason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<template v-if="row.status === 0">
|
||||
<el-button link type="success" size="small" @click="openAudit('approve', row)">通过</el-button>
|
||||
<el-button link type="danger" size="small" @click="confirmReject(row)">驳回</el-button>
|
||||
</template>
|
||||
<span v-else class="no-action">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无退款单数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog
|
||||
v-model="auditVisible"
|
||||
:title="auditMode === 'approve' ? '退款审核通过' : '退款审核驳回'"
|
||||
width="460px"
|
||||
>
|
||||
<el-alert
|
||||
v-if="auditTarget"
|
||||
:title="`退款单 ${auditTarget.refund_no} · 金额 ¥${formatMoney(auditTarget.refund_amount)}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="audit-alert"
|
||||
/>
|
||||
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||
<el-form-item :label="auditMode === 'approve' ? '审核备注' : '驳回原因'" prop="reason">
|
||||
<el-input
|
||||
v-model="auditForm.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
:placeholder="auditMode === 'approve' ? '如:已核实,同意退款(必填)' : '请填写驳回原因(必填)'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button
|
||||
:type="auditMode === 'approve' ? 'success' : 'danger'"
|
||||
:loading="auditLoading"
|
||||
@click="submitAudit"
|
||||
>
|
||||
{{ auditMode === 'approve' ? '确认通过' : '确认驳回' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.no-action {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { getClothesDetail, getClothesList } from '@/api/content'
|
||||
import type { ClothesItem } from '@/types/content'
|
||||
import { IMAGE_PROCESS_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<ClothesItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
image_process_status: undefined as number | undefined,
|
||||
category_main: '',
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getClothesList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
image_process_status: query.image_process_status,
|
||||
category_main: query.category_main || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.image_process_status = undefined
|
||||
query.category_main = ''
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ClothesItem | null>(null)
|
||||
|
||||
async function openDetail(row: ClothesItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getClothesDetail(row.clothes_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.image_process_status" placeholder="图片处理状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(IMAGE_PROCESS_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.category_main"
|
||||
placeholder="主类目"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="衣物名称 / 描述"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="衣物图片" width="80">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-image
|
||||
v-if="row.processed_image"
|
||||
:src="row.processed_image"
|
||||
:preview-src-list="[row.processed_image]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="clothes-thumb"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column prop="category_main" label="主类目" width="100" />
|
||||
<el-table-column prop="category_sub" label="子类目" width="100" />
|
||||
<el-table-column label="处理状态" width="100">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: ClothesItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无衣物数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 衣物详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="衣物详情" size="520px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-image">
|
||||
<el-image
|
||||
v-if="detail.processed_image"
|
||||
:src="detail.processed_image"
|
||||
:preview-src-list="[detail.processed_image]"
|
||||
preview-teleported
|
||||
fit="contain"
|
||||
class="detail-img"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder large">
|
||||
<el-icon :size="32"><Picture /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="名称" :span="2">{{ formatText(detail.name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主类目">{{ formatText(detail.category_main) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子类目">{{ formatText(detail.category_sub) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风格">{{ formatText(detail.style) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主色">{{ formatText(detail.color_main) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="副色">{{ formatText(detail.color_sub) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="厚薄">{{ formatText(detail.thickness) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版型">{{ formatText(detail.version) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材质">{{ formatText(detail.material) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="图案">{{ formatText(detail.pattern) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="袖型">{{ formatText(detail.sleeve_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="衣型">{{ formatText(detail.clothes_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="领型">{{ formatText(detail.neckline_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="正式度">{{ formatText(detail.formality) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="场合">{{ formatText(detail.occasions) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="视觉冲击">{{ formatText(detail.visual_impact) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="图片大小">{{ detail.image_size }} KB</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, detail.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, detail.image_process_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因">{{ formatText(detail.fail_reason) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clothes-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f4f6fb;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.thumb-placeholder.large {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
background: #f4f6fb;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { auditOutfit, banOutfit, getOutfitDetail, getOutfitList, unbanOutfit } from '@/api/content'
|
||||
import type { OutfitDetailResponse, OutfitListItem } from '@/types/content'
|
||||
import {
|
||||
IMAGE_PROCESS_STATUS,
|
||||
OUTFIT_AUDIT_STATUS,
|
||||
OUTFIT_PUBLIC,
|
||||
OUTFIT_SOURCE,
|
||||
OUTFIT_STATUS,
|
||||
enumLabel,
|
||||
enumOptions,
|
||||
enumTag,
|
||||
} from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<OutfitListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
is_public: undefined as number | undefined,
|
||||
audit_status: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOutfitList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
is_public: query.is_public,
|
||||
audit_status: query.audit_status,
|
||||
status: query.status,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.is_public = undefined
|
||||
query.audit_status = undefined
|
||||
query.status = undefined
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<OutfitDetailResponse | null>(null)
|
||||
|
||||
async function openDetail(row: OutfitListItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getOutfitDetail(row.outfit_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 审核 ---------------- */
|
||||
const auditVisible = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const auditTarget = ref<OutfitListItem | null>(null)
|
||||
const auditFormRef = ref<FormInstance>()
|
||||
|
||||
const auditForm = reactive({
|
||||
result: 1,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const auditRules: FormRules = {
|
||||
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
|
||||
reason: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (auditForm.result === 2 && !value.trim()) {
|
||||
callback(new Error('驳回时必须填写原因'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function openAudit(row: OutfitListItem) {
|
||||
auditTarget.value = row
|
||||
auditForm.result = 1
|
||||
auditForm.reason = ''
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !auditTarget.value) return
|
||||
auditLoading.value = true
|
||||
try {
|
||||
await auditOutfit(auditTarget.value.outfit_uuid, {
|
||||
result: auditForm.result,
|
||||
reason: auditForm.result === 2 ? auditForm.reason : undefined,
|
||||
})
|
||||
ElMessage.success(auditForm.result === 1 ? '审核通过' : '已驳回')
|
||||
auditVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 封禁 / 解封 ---------------- */
|
||||
async function handleBan(row: OutfitListItem) {
|
||||
const result = (await ElMessageBox.prompt('封禁原因(选填)', `封禁穿搭「${row.outfit_name}」`, {
|
||||
confirmButtonText: '确认封禁',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '选填,最多 200 字',
|
||||
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||
}).catch(() => null)) as { value: string } | null
|
||||
if (!result) return
|
||||
const reason = result.value
|
||||
await banOutfit(row.outfit_uuid, { reason: reason || undefined })
|
||||
ElMessage.success('封禁成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleUnban(row: OutfitListItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认解封穿搭「${row.outfit_name}」吗?`,
|
||||
'解封确认',
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await unbanOutfit(row.outfit_uuid)
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.is_public" placeholder="公开状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_PUBLIC)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.audit_status" placeholder="审核状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_AUDIT_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.status" placeholder="穿搭状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="穿搭名称 / 描述"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="outfit_name" label="穿搭名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="来源" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_SOURCE, row.source)" size="small">
|
||||
{{ enumLabel(OUTFIT_SOURCE, row.source) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公开" width="80">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
{{ enumLabel(OUTFIT_PUBLIC, row.is_public) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, row.audit_status)" size="small">
|
||||
{{ enumLabel(OUTFIT_AUDIT_STATUS, row.audit_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(OUTFIT_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="场景" min-width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: OutfitListItem }">{{ formatText(row.scene) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: OutfitListItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.audit_status === 1" link type="warning" size="small" @click="openAudit(row)">
|
||||
审核
|
||||
</el-button>
|
||||
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 2" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无穿搭数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 穿搭详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="穿搭详情" size="520px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="名称">{{ formatText(detail.outfit_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ formatText(detail.outfit_desc) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="场景">{{ formatText(detail.scene) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ enumLabel(OUTFIT_SOURCE, detail.source) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公开状态">
|
||||
{{ enumLabel(OUTFIT_PUBLIC, detail.is_public) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">
|
||||
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, detail.audit_status)" size="small">
|
||||
{{ enumLabel(OUTFIT_AUDIT_STATUS, detail.audit_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(OUTFIT_STATUS, detail.status)" size="small">
|
||||
{{ enumLabel(OUTFIT_STATUS, detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">衣物明细({{ detail.items.length }})</h4>
|
||||
<el-table :data="detail.items" size="small" border>
|
||||
<el-table-column prop="name" label="衣物名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="category_main" label="主类目" width="90" />
|
||||
<el-table-column prop="category_sub" label="子类目" width="90" />
|
||||
<el-table-column label="图片状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog v-model="auditVisible" :title="`穿搭审核 · ${auditTarget?.outfit_name ?? ''}`" width="460px">
|
||||
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||
<el-form-item label="审核结果" prop="result">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio :value="1">通过</el-radio>
|
||||
<el-radio :value="2">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auditForm.result === 2" label="驳回原因" prop="reason">
|
||||
<el-input
|
||||
v-model="auditForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="驳回时必填原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button :type="auditForm.result === 1 ? 'success' : 'danger'" :loading="auditLoading" @click="submitAudit">
|
||||
{{ auditForm.result === 1 ? '确认通过' : '确认驳回' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2437;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { banClosetShare, getClosetShareList, unbanClosetShare } from '@/api/content'
|
||||
import type { ClosetShareItem } from '@/types/content'
|
||||
import { SHARE_STATUS, SHARE_TYPE, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<ClosetShareItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getClosetShareList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 封禁 / 解封 ---------------- */
|
||||
async function handleBan(row: ClosetShareItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`封禁后该分享将对外不可见,确认封禁分享 ${row.share_uuid} 吗?`,
|
||||
'封禁确认',
|
||||
{ type: 'warning', confirmButtonText: '确认封禁' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await banClosetShare(row.share_uuid)
|
||||
ElMessage.success('封禁成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleUnban(row: ClosetShareItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认解封分享 ${row.share_uuid} 吗?`,
|
||||
'解封确认',
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await unbanClosetShare(row.share_uuid)
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="分享状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(SHARE_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="share_uuid" label="分享 UUID" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="分享类型" width="110">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
{{ enumLabel(SHARE_TYPE, row.share_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
<el-tag :type="enumTag(SHARE_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(SHARE_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="150">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.expire_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 4" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||
<span v-if="row.status === 2 || row.status === 3" class="no-action">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分享数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-action {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import {
|
||||
adjustUserQuota,
|
||||
adjustUserVip,
|
||||
banUser,
|
||||
getUserDetail,
|
||||
getUserList,
|
||||
revokeGrant,
|
||||
unbanUser,
|
||||
} from '@/api/user'
|
||||
import type {
|
||||
UserDetailResponse,
|
||||
UserGrantItem,
|
||||
UserListItem,
|
||||
} from '@/types/user'
|
||||
import {
|
||||
BAN_TYPE,
|
||||
GRANT_SOURCE,
|
||||
QUOTA_TYPE,
|
||||
USER_SEX,
|
||||
USER_STATUS,
|
||||
VIP_TYPE,
|
||||
enumLabel,
|
||||
enumOptions,
|
||||
enumTag,
|
||||
} from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<UserListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<UserDetailResponse | null>(null)
|
||||
|
||||
async function openDetail(row: UserListItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getUserDetail(row.user_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 封禁 ---------------- */
|
||||
const banVisible = ref(false)
|
||||
const banLoading = ref(false)
|
||||
const banTarget = ref<UserListItem | null>(null)
|
||||
const banFormRef = ref<FormInstance>()
|
||||
|
||||
const banForm = reactive({
|
||||
ban_type: 2,
|
||||
ban_days: undefined as number | undefined,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const banRules: FormRules = {
|
||||
ban_type: [{ required: true, message: '请选择封禁类型', trigger: 'change' }],
|
||||
ban_days: [
|
||||
{
|
||||
validator: (_rule, value: number | undefined, callback) => {
|
||||
if (banForm.ban_type === 1 && (!value || value < 1 || value > 3650)) {
|
||||
callback(new Error('临时封禁需填写 1~3650 天'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
reason: [{ required: true, message: '请填写封禁原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openBan(row: UserListItem) {
|
||||
banTarget.value = row
|
||||
banForm.ban_type = 2
|
||||
banForm.ban_days = 7
|
||||
banForm.reason = ''
|
||||
banVisible.value = true
|
||||
}
|
||||
|
||||
async function submitBan() {
|
||||
const valid = await banFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !banTarget.value) return
|
||||
banLoading.value = true
|
||||
try {
|
||||
await banUser(banTarget.value.user_uuid, {
|
||||
ban_type: banForm.ban_type,
|
||||
ban_days: banForm.ban_type === 1 ? banForm.ban_days : undefined,
|
||||
reason: banForm.reason,
|
||||
})
|
||||
ElMessage.success('封禁成功')
|
||||
banVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
banLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 解封 ---------------- */
|
||||
async function handleUnban(row: UserListItem) {
|
||||
const result = (await ElMessageBox.prompt('解封原因(选填)', '解封用户', {
|
||||
confirmButtonText: '确认解封',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '选填,最多 200 字',
|
||||
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||
}).catch(() => null)) as { value: string } | null
|
||||
if (!result) return
|
||||
const reason = result.value
|
||||
if (reason === null) return
|
||||
await unbanUser(row.user_uuid, { reason: reason || undefined })
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
/* ---------------- 额度调整 ---------------- */
|
||||
const quotaVisible = ref(false)
|
||||
const quotaLoading = ref(false)
|
||||
const quotaTarget = ref<UserListItem | null>(null)
|
||||
const quotaFormRef = ref<FormInstance>()
|
||||
|
||||
const quotaForm = reactive({
|
||||
quota_type: 1,
|
||||
amount: 1,
|
||||
expire_days: 30,
|
||||
})
|
||||
|
||||
const quotaRules: FormRules = {
|
||||
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入发放数量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 1 || value > 100000000) callback(new Error('发放数量范围 1~100000000'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
expire_days: [
|
||||
{ required: true, message: '请输入有效期天数', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 1 || value > 3650) callback(new Error('有效期天数范围 1~3650'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function openQuota(row: UserListItem) {
|
||||
quotaTarget.value = row
|
||||
quotaForm.quota_type = 1
|
||||
quotaForm.amount = 1
|
||||
quotaForm.expire_days = 30
|
||||
quotaVisible.value = true
|
||||
}
|
||||
|
||||
async function submitQuota() {
|
||||
const valid = await quotaFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !quotaTarget.value) return
|
||||
quotaLoading.value = true
|
||||
try {
|
||||
await adjustUserQuota(quotaTarget.value.user_uuid, {
|
||||
quota_type: quotaForm.quota_type,
|
||||
amount: quotaForm.amount,
|
||||
expire_days: quotaForm.expire_days,
|
||||
})
|
||||
ElMessage.success('额度发放成功')
|
||||
quotaVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
quotaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 额度凭证回收 ---------------- */
|
||||
async function handleRevokeGrant(row: UserGrantItem) {
|
||||
if (!detail.value) return
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认回收该凭证(类型:${enumLabel(QUOTA_TYPE, row.quota_type)},数量:${row.amount})吗?`,
|
||||
'回收确认',
|
||||
{ type: 'warning', confirmButtonText: '确认回收', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await revokeGrant(detail.value.user_uuid, row.id)
|
||||
ElMessage.success('回收成功')
|
||||
detail.value = await getUserDetail(detail.value.user_uuid)
|
||||
}
|
||||
|
||||
/* ---------------- VIP 调整 ---------------- */
|
||||
const vipVisible = ref(false)
|
||||
const vipLoading = ref(false)
|
||||
const vipTarget = ref<UserListItem | null>(null)
|
||||
const vipFormRef = ref<FormInstance>()
|
||||
|
||||
const vipForm = reactive({
|
||||
vip_type: 2,
|
||||
vip_days: 30,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const vipRules: FormRules = {
|
||||
vip_type: [{ required: true, message: '请选择会员类型', trigger: 'change' }],
|
||||
vip_days: [
|
||||
{ required: true, message: '请输入会员天数', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 0 || value > 3650) callback(new Error('天数范围 0~3650(0 表示取消会员)'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openVip(row: UserListItem) {
|
||||
vipTarget.value = row
|
||||
vipForm.vip_type = 2
|
||||
vipForm.vip_days = 30
|
||||
vipForm.reason = ''
|
||||
vipVisible.value = true
|
||||
}
|
||||
|
||||
async function submitVip() {
|
||||
const valid = await vipFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !vipTarget.value) return
|
||||
vipLoading.value = true
|
||||
try {
|
||||
await adjustUserVip(vipTarget.value.user_uuid, {
|
||||
vip_type: vipForm.vip_type,
|
||||
vip_days: vipForm.vip_days,
|
||||
reason: vipForm.reason,
|
||||
})
|
||||
ElMessage.success('VIP 调整成功')
|
||||
vipVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
vipLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="昵称 / 手机号 / OpenID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="用户状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(USER_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="用户" min-width="160">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<div class="cell-user">
|
||||
<el-avatar :size="30" :src="row.avatar || undefined">
|
||||
{{ (row.username || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<span class="cell-name">{{ formatText(row.username) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="64">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ enumLabel(USER_SEX, row.sex) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" width="130">
|
||||
<template #default="{ row }: { row: UserListItem }">{{ formatText(row.phone) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-tag :type="enumTag(USER_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(USER_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员" width="100">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-tag :type="enumTag(VIP_TYPE, row.vip_type)" size="small">
|
||||
{{ enumLabel(VIP_TYPE, row.vip_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员到期" width="150">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ formatDate(row.vip_expire) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="150">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 1" link type="warning" size="small" @click="openBan(row)">
|
||||
封禁
|
||||
</el-button>
|
||||
<el-button v-else-if="row.status === 2 || row.status === 3" link type="success" size="small" @click="handleUnban(row)">
|
||||
解封
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openQuota(row)">额度</el-button>
|
||||
<el-button link type="primary" size="small" @click="openVip(row)">VIP</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无用户数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 用户详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="用户详情" size="460px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-header">
|
||||
<el-avatar :size="56" :src="detail.avatar || undefined">
|
||||
{{ (detail.username || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="detail-name">{{ formatText(detail.username) }}</div>
|
||||
<div class="detail-uuid">{{ detail.user_uuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ enumLabel(USER_SEX, detail.sex) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ formatText(detail.phone) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(USER_STATUS, detail.status)" size="small">
|
||||
{{ enumLabel(USER_STATUS, detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_reason" label="封禁原因">
|
||||
{{ detail.ban_reason }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_start" label="封禁开始">
|
||||
{{ formatDate(detail.ban_start) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_expire" label="封禁到期">
|
||||
{{ formatDate(detail.ban_expire) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会员类型">
|
||||
<el-tag :type="enumTag(VIP_TYPE, detail.vip_type)" size="small">
|
||||
{{ enumLabel(VIP_TYPE, detail.vip_type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会员到期">{{ formatDate(detail.vip_expire) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ formatDate(detail.last_login_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">额度信息</h4>
|
||||
<el-table :data="detail.quotas" size="small" border>
|
||||
<el-table-column label="类型">
|
||||
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总量">
|
||||
<template #default="{ row }">{{ row.total }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用">
|
||||
<template #default="{ row }">{{ row.used }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余">
|
||||
<template #default="{ row }">{{ row.remain }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<h4 class="section-title">额度凭证</h4>
|
||||
<el-table :data="detail.grants" size="small" border>
|
||||
<el-table-column label="类型">
|
||||
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源">
|
||||
<template #default="{ row }">{{ enumLabel(GRANT_SOURCE, row.source) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量">
|
||||
<template #default="{ row }">{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用">
|
||||
<template #default="{ row }">{{ row.used }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期">
|
||||
<template #default="{ row }">
|
||||
{{ row.expire_time === 0 ? '跟随 VIP' : formatDate(row.expire_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联 ID">
|
||||
<template #default="{ row }">{{ formatText(row.ref_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleRevokeGrant(row)">回收</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无凭证" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 封禁对话框 -->
|
||||
<el-dialog v-model="banVisible" :title="`封禁用户 · ${banTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="banFormRef" :model="banForm" :rules="banRules" label-width="88px">
|
||||
<el-form-item label="封禁类型" prop="ban_type">
|
||||
<el-radio-group v-model="banForm.ban_type">
|
||||
<el-radio v-for="opt in enumOptions(BAN_TYPE)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="banForm.ban_type === 1" label="封禁天数" prop="ban_days">
|
||||
<el-input-number v-model="banForm.ban_days" :min="1" :max="3650" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封禁原因" prop="reason">
|
||||
<el-input v-model="banForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写封禁原因(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="banVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="banLoading" @click="submitBan">确认封禁</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 额度发放对话框 -->
|
||||
<el-dialog v-model="quotaVisible" :title="`额度发放 · ${quotaTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="88px">
|
||||
<el-form-item label="额度类型" prop="quota_type">
|
||||
<el-select v-model="quotaForm.quota_type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(QUOTA_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放数量" prop="amount">
|
||||
<el-input-number v-model="quotaForm.amount" :min="1" :max="100000000" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期天数" prop="expire_days">
|
||||
<el-input-number v-model="quotaForm.expire_days" :min="1" :max="3650" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="quotaVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="quotaLoading" @click="submitQuota">确认发放</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- VIP 调整对话框 -->
|
||||
<el-dialog v-model="vipVisible" :title="`VIP 调整 · ${vipTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="vipFormRef" :model="vipForm" :rules="vipRules" label-width="88px">
|
||||
<el-form-item label="会员类型" prop="vip_type">
|
||||
<el-select v-model="vipForm.vip_type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(VIP_TYPE).filter((o) => o.value !== 1)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="会员天数" prop="vip_days">
|
||||
<el-input-number v-model="vipForm.vip_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||
<div class="form-tip">天数为 0 表示取消会员;其余按当前到期时间顺延</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" prop="reason">
|
||||
<el-input v-model="vipForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写调整原因(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="vipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="vipLoading" @click="submitVip">确认调整</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cell-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.detail-uuid {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user