feat: 完成会员/保质期/系统设置/去水印管理页面开发

This commit is contained in:
root
2026-03-09 21:38:03 +08:00
parent 6213c6961b
commit 1a88be5bb9
9 changed files with 1570 additions and 26 deletions
+11 -7
View File
@@ -17,14 +17,18 @@ npm run build
## 当前已完成
- 管理员登录(`/api/admin/login`
- 管理员信息(`/api/admin/profile`
- 数据看板(总览、小程序统计、用户增长)
- 管理员登录与认证`/api/admin/login``/api/admin/profile`
- 后台基础布局(顶部导航、侧边栏、移动端适配
- 数据看板(总览、小程序统计、用户增长图表
- 小程序管理(列表、新增、编辑、删除)
- 用户管理(列表、筛选、详情)
- 会员管理页面骨架与兑换码管理交互
- 保质期管理页面(总览、列表、增删改、状态流转)
- 去水印管理页面(任务总览与列表)
- 系统设置页面(资料/密码/系统参数)
## 待开发
- 用户管理
- 会员管理
- 保质期管理
- 系统设置
- 会员、保质期、去水印、系统设置接口联调
- 会员管理的记录详情与批量操作
- 系统设置中的操作日志查询
+47
View File
@@ -0,0 +1,47 @@
import request from '../utils/request'
export function getExpirySummary() {
return request({
url: '/api/admin/expiry/summary',
method: 'get'
})
}
export function getExpiryItems(params) {
return request({
url: '/api/admin/expiry/items',
method: 'get',
params
})
}
export function createExpiryItem(data) {
return request({
url: '/api/admin/expiry/items',
method: 'post',
data
})
}
export function updateExpiryItem(id, data) {
return request({
url: `/api/admin/expiry/items/${id}`,
method: 'put',
data
})
}
export function deleteExpiryItem(id) {
return request({
url: `/api/admin/expiry/items/${id}`,
method: 'delete'
})
}
export function updateExpiryItemStatus(id, status) {
return request({
url: `/api/admin/expiry/items/${id}/status`,
method: 'post',
data: { status }
})
}
+32
View File
@@ -0,0 +1,32 @@
import request from '../utils/request'
export function getMembershipOverview() {
return request({
url: '/api/admin/memberships/overview',
method: 'get'
})
}
export function getMembershipRedeemCodes(params) {
return request({
url: '/api/admin/memberships/redeem-codes',
method: 'get',
params
})
}
export function createMembershipRedeemCodes(data) {
return request({
url: '/api/admin/memberships/redeem-codes',
method: 'post',
data
})
}
export function updateMembershipRedeemCodeStatus(id, data) {
return request({
url: `/api/admin/memberships/redeem-codes/${id}/status`,
method: 'post',
data
})
}
+39
View File
@@ -0,0 +1,39 @@
import request from '../utils/request'
export function getAdminSettings() {
return request({
url: '/api/admin/settings',
method: 'get'
})
}
export function updateAdminProfile(data) {
return request({
url: '/api/admin/settings/profile',
method: 'put',
data
})
}
export function updateAdminPassword(data) {
return request({
url: '/api/admin/settings/password',
method: 'put',
data
})
}
export function getSystemConfig() {
return request({
url: '/api/admin/settings/system',
method: 'get'
})
}
export function updateSystemConfig(data) {
return request({
url: '/api/admin/settings/system',
method: 'put',
data
})
}
+30
View File
@@ -0,0 +1,30 @@
import request from '../utils/request'
export function getWatermarkOverview() {
return request({
url: '/api/admin/watermark/overview',
method: 'get'
})
}
export function getWatermarkTasks(params) {
return request({
url: '/api/admin/watermark/tasks',
method: 'get',
params
})
}
export function retryWatermarkTask(id) {
return request({
url: `/api/admin/watermark/tasks/${id}/retry`,
method: 'post'
})
}
export function deleteWatermarkTask(id) {
return request({
url: `/api/admin/watermark/tasks/${id}`,
method: 'delete'
})
}
+470 -2
View File
@@ -1,8 +1,476 @@
<template>
<div class="expiry-page">
<el-row :gutter="16" class="cards">
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value">{{ summary.total_items || 0 }}</div>
<div class="card-label">物品总数</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value warning">{{ summary.expiring_soon || 0 }}</div>
<div class="card-label">即将过期</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value danger">{{ summary.expired || 0 }}</div>
<div class="card-label">已过期</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value success">{{ summary.normal || 0 }}</div>
<div class="card-label">正常</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value">{{ summary.used || 0 }}</div>
<div class="card-label">已使用</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<el-card shadow="hover">
<div class="card-value">{{ summary.discarded || 0 }}</div>
<div class="card-label">已丢弃</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<span>保质期管理</span>
<div class="header-row">
<div class="filters">
<el-input
v-model="query.keyword"
placeholder="搜索物品名称"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.status"
clearable
style="width: 150px"
placeholder="全部状态"
@change="handleSearch"
>
<el-option label="正常" value="normal" />
<el-option label="即将过期" value="expiring" />
<el-option label="已过期" value="expired" />
<el-option label="已使用" value="used" />
<el-option label="已丢弃" value="discarded" />
</el-select>
<el-select
v-model="query.category"
clearable
style="width: 150px"
placeholder="全部分类"
@change="handleSearch"
>
<el-option label="食品" value="food" />
<el-option label="药品" value="medicine" />
<el-option label="化妆品" value="cosmetic" />
<el-option label="其他" value="other" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-button type="primary" @click="openCreateDialog">新增物品</el-button>
</div>
</template>
<el-empty description="开发中,下一步对接保质期后台接口" />
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="物品名称" min-width="160" />
<el-table-column label="分类" width="110">
<template #default="{ row }">
{{ categoryText(row.category) }}
</template>
</el-table-column>
<el-table-column label="过期日期" width="130">
<template #default="{ row }">
{{ formatDate(row.expiry_date) }}
</template>
</el-table-column>
<el-table-column label="剩余天数" width="100">
<template #default="{ row }">
<span :class="daysClass(row.days_left)">{{ row.days_left ?? '-' }}</span>
</template>
</el-table-column>
<el-table-column label="数量" width="80" prop="quantity" />
<el-table-column prop="location" label="位置" min-width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button
v-if="!['used', 'discarded'].includes(row.status)"
link
type="success"
@click="handleMarkStatus(row, 'used')"
>
标记已使用
</el-button>
<el-button
v-if="!['used', 'discarded'].includes(row.status)"
link
type="warning"
@click="handleMarkStatus(row, 'discarded')"
>
标记丢弃
</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑物品' : '新增物品'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="物品名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="分类" prop="category">
<el-select v-model="form.category" style="width: 100%">
<el-option label="食品" value="food" />
<el-option label="药品" value="medicine" />
<el-option label="化妆品" value="cosmetic" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="过期日期" prop="expiry_date">
<el-date-picker
v-model="form.expiry_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :max="999" />
</el-form-item>
<el-form-item label="存放位置">
<el-input v-model="form.location" placeholder="可选" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import {
createExpiryItem,
deleteExpiryItem,
getExpiryItems,
getExpirySummary,
updateExpiryItem,
updateExpiryItemStatus
} from '../../api/expiry'
const loading = ref(false)
const submitting = ref(false)
const list = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const summary = reactive({
total_items: 0,
expiring_soon: 0,
expired: 0,
normal: 0,
used: 0,
discarded: 0
})
const query = reactive({
page: 1,
page_size: 20,
keyword: '',
status: undefined,
category: undefined
})
const form = reactive({
id: undefined,
name: '',
category: 'food',
expiry_date: '',
quantity: 1,
location: '',
remark: ''
})
const rules = {
name: [{ required: true, message: '请输入物品名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择分类', trigger: 'change' }],
expiry_date: [{ required: true, message: '请选择过期日期', trigger: 'change' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }]
}
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatDate = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD')
}
const categoryText = (category) => {
if (category === 'food') return '食品'
if (category === 'medicine') return '药品'
if (category === 'cosmetic') return '化妆品'
if (category === 'other') return '其他'
return category || '-'
}
const statusText = (status) => {
if (status === 'normal') return '正常'
if (status === 'expiring') return '即将过期'
if (status === 'expired') return '已过期'
if (status === 'used') return '已使用'
if (status === 'discarded') return '已丢弃'
return status || '-'
}
const statusTagType = (status) => {
if (status === 'normal') return 'success'
if (status === 'expiring') return 'warning'
if (status === 'expired') return 'danger'
if (status === 'used') return 'info'
if (status === 'discarded') return ''
return 'info'
}
const daysClass = (daysLeft) => {
if (daysLeft == null) return ''
if (daysLeft < 0) return 'danger'
if (daysLeft <= 7) return 'warning'
return 'success'
}
const loadSummary = async () => {
const res = await getExpirySummary()
const payload = res.data || {}
summary.total_items = payload.total_items || 0
summary.expiring_soon = payload.expiring_soon || 0
summary.expired = payload.expired || 0
summary.normal = payload.normal || 0
summary.used = payload.used || 0
summary.discarded = payload.discarded || 0
}
const loadData = async () => {
loading.value = true
try {
const res = await getExpiryItems(query)
const payload = res.data || {}
list.value = payload.list || payload.items || []
total.value = payload.total || 0
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.keyword = ''
query.status = undefined
query.category = undefined
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
const openCreateDialog = () => {
isEdit.value = false
form.id = undefined
form.name = ''
form.category = 'food'
form.expiry_date = ''
form.quantity = 1
form.location = ''
form.remark = ''
dialogVisible.value = true
}
const openEditDialog = (row) => {
isEdit.value = true
form.id = row.id
form.name = row.name || ''
form.category = row.category || 'food'
form.expiry_date = row.expiry_date ? dayjs(row.expiry_date).format('YYYY-MM-DD') : ''
form.quantity = row.quantity || 1
form.location = row.location || ''
form.remark = row.remark || ''
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
return
}
submitting.value = true
try {
const payload = {
name: form.name,
category: form.category,
expiry_date: form.expiry_date,
quantity: form.quantity,
location: form.location,
remark: form.remark
}
if (isEdit.value && form.id) {
await updateExpiryItem(form.id, payload)
ElMessage.success('更新成功')
} else {
await createExpiryItem(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await Promise.all([loadSummary(), loadData()])
} finally {
submitting.value = false
}
}
const handleMarkStatus = async (row, status) => {
const actionText = status === 'used' ? '标记为已使用' : '标记为已丢弃'
await ElMessageBox.confirm(`确认${actionText}吗?`, '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await updateExpiryItemStatus(row.id, status)
ElMessage.success('更新成功')
await Promise.all([loadSummary(), loadData()])
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('删除后不可恢复,确认删除该物品?', '提示', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
await deleteExpiryItem(row.id)
ElMessage.success('删除成功')
await Promise.all([loadSummary(), loadData()])
}
onMounted(async () => {
await Promise.all([loadSummary(), loadData()])
})
</script>
<style scoped>
.cards {
margin-bottom: 16px;
}
.card-value {
font-size: 26px;
font-weight: 600;
color: #303133;
}
.card-value.warning {
color: #e6a23c;
}
.card-value.danger {
color: #f56c6c;
}
.card-value.success {
color: #67c23a;
}
.card-label {
margin-top: 8px;
color: #909399;
font-size: 13px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.danger {
color: #f56c6c;
}
.warning {
color: #e6a23c;
}
.success {
color: #67c23a;
}
</style>
+379 -2
View File
@@ -1,8 +1,385 @@
<template>
<div class="memberships-page">
<el-row :gutter="16" class="cards">
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value">{{ overview.total_members || 0 }}</div>
<div class="card-label">会员总数</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value">{{ overview.active_members || 0 }}</div>
<div class="card-label">有效会员</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value">{{ overview.expiring_soon || 0 }}</div>
<div class="card-label">7天内到期</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value">{{ overview.today_redeemed || 0 }}</div>
<div class="card-label">今日兑换</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<span>会员管理</span>
<div class="header-row">
<div class="filters">
<el-input
v-model="query.keyword"
placeholder="搜索兑换码"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.mini_program_id"
placeholder="全部小程序"
clearable
style="width: 180px"
@change="handleSearch"
>
<el-option
v-for="item in miniProgramOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-select
v-model="query.status"
placeholder="全部状态"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option label="可用" value="active" />
<el-option label="已停用" value="disabled" />
<el-option label="已过期" value="expired" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-button type="primary" @click="openCreateDialog">生成兑换码</el-button>
</div>
</template>
<el-empty description="开发中,下一步对接会员管理接口" />
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="code" label="兑换码" min-width="180" />
<el-table-column label="小程序" min-width="140">
<template #default="{ row }">
{{ row.mini_program_name || row.mini_program?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="套餐类型" width="120">
<template #default="{ row }">
{{ row.package_type || row.plan_name || '-' }}
</template>
</el-table-column>
<el-table-column label="时长(天)" width="100">
<template #default="{ row }">
{{ row.duration_days || '-' }}
</template>
</el-table-column>
<el-table-column label="使用次数" width="100">
<template #default="{ row }">
{{ row.used_count || 0 }}/{{ row.max_uses || 1 }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.expires_at) }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'active'"
link
type="danger"
@click="handleDisable(row)"
>
停用
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<el-dialog v-model="createDialogVisible" title="生成兑换码" width="520px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
<el-form-item label="所属小程序" prop="mini_program_id">
<el-select v-model="createForm.mini_program_id" placeholder="请选择小程序" style="width: 100%">
<el-option
v-for="item in miniProgramOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="套餐类型" prop="package_type">
<el-select v-model="createForm.package_type" style="width: 100%">
<el-option label="月卡" value="month" />
<el-option label="季卡" value="quarter" />
<el-option label="年卡" value="year" />
</el-select>
</el-form-item>
<el-form-item label="时长(天)" prop="duration_days">
<el-input-number v-model="createForm.duration_days" :min="1" :max="3650" />
</el-form-item>
<el-form-item label="生成数量" prop="quantity">
<el-input-number v-model="createForm.quantity" :min="1" :max="200" />
</el-form-item>
<el-form-item label="每码可用次数" prop="max_uses">
<el-input-number v-model="createForm.max_uses" :min="1" :max="100" />
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker
v-model="createForm.expires_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="可选,不填则长期有效"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { getMiniPrograms } from '../../api/miniProgram'
import {
createMembershipRedeemCodes,
getMembershipOverview,
getMembershipRedeemCodes,
updateMembershipRedeemCodeStatus
} from '../../api/membership'
const loading = ref(false)
const creating = ref(false)
const list = ref([])
const total = ref(0)
const miniProgramOptions = ref([])
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const overview = reactive({
total_members: 0,
active_members: 0,
expiring_soon: 0,
today_redeemed: 0
})
const query = reactive({
page: 1,
page_size: 20,
mini_program_id: undefined,
status: undefined,
keyword: ''
})
const createForm = reactive({
mini_program_id: undefined,
package_type: 'month',
duration_days: 30,
quantity: 10,
max_uses: 1,
expires_at: ''
})
const createRules = {
mini_program_id: [{ required: true, message: '请选择小程序', trigger: 'change' }],
package_type: [{ required: true, message: '请选择套餐类型', trigger: 'change' }],
duration_days: [{ required: true, message: '请输入时长', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入生成数量', trigger: 'blur' }],
max_uses: [{ required: true, message: '请输入可用次数', trigger: 'blur' }]
}
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const statusText = (status) => {
if (status === 'active') return '可用'
if (status === 'disabled') return '已停用'
if (status === 'expired') return '已过期'
return status || '-'
}
const statusTagType = (status) => {
if (status === 'active') return 'success'
if (status === 'disabled') return 'info'
if (status === 'expired') return 'danger'
return 'info'
}
const loadMiniPrograms = async () => {
const res = await getMiniPrograms({ page: 1, page_size: 200 })
const payload = res.data || {}
miniProgramOptions.value = payload.list || []
}
const loadOverview = async () => {
const res = await getMembershipOverview()
const payload = res.data || {}
overview.total_members = payload.total_members || 0
overview.active_members = payload.active_members || 0
overview.expiring_soon = payload.expiring_soon || 0
overview.today_redeemed = payload.today_redeemed || 0
}
const loadData = async () => {
loading.value = true
try {
const res = await getMembershipRedeemCodes(query)
const payload = res.data || {}
list.value = payload.list || payload.items || []
total.value = payload.total || 0
query.page = payload.page || query.page
query.page_size = payload.page_size || query.page_size
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.keyword = ''
query.mini_program_id = undefined
query.status = undefined
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
const openCreateDialog = () => {
createDialogVisible.value = true
}
const handleCreate = async () => {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
} catch (error) {
return
}
creating.value = true
try {
const res = await createMembershipRedeemCodes(createForm)
const codes = res?.data?.codes || []
ElMessage.success(`生成成功,共 ${createForm.quantity}`)
if (codes.length > 0) {
await ElMessageBox.alert(codes.join('\n'), '新生成兑换码', {
confirmButtonText: '确定'
})
}
createDialogVisible.value = false
loadData()
} finally {
creating.value = false
}
}
const handleDisable = async (row) => {
await ElMessageBox.confirm('停用后该兑换码将无法再使用,是否继续?', '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await updateMembershipRedeemCodeStatus(row.id, { status: 'disabled' })
ElMessage.success('已停用')
loadData()
}
onMounted(async () => {
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
})
</script>
<style scoped>
.cards {
margin-bottom: 16px;
}
.card-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.card-label {
margin-top: 8px;
color: #909399;
font-size: 13px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
+230 -1
View File
@@ -3,6 +3,235 @@
<template #header>
<span>系统设置</span>
</template>
<el-empty description="开发中,下一步补充密码修改和日志查询" />
<el-tabs v-model="activeTab">
<el-tab-pane label="个人资料" name="profile">
<el-form
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-width="120px"
style="max-width: 680px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="profileForm.username" disabled />
</el-form-item>
<el-form-item label="显示名称" prop="display_name">
<el-input v-model="profileForm.display_name" placeholder="请输入显示名称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="profileForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="profileForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="时区">
<el-select v-model="profileForm.timezone" style="width: 100%">
<el-option label="Asia/Shanghai" value="Asia/Shanghai" />
<el-option label="UTC" value="UTC" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="profileSaving" @click="handleSaveProfile">保存资料</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="安全设置" name="security">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="120px"
style="max-width: 560px"
>
<el-form-item label="当前密码" prop="old_password">
<el-input v-model="passwordForm.old_password" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input v-model="passwordForm.new_password" type="password" show-password />
</el-form-item>
<el-form-item label="确认新密码" prop="confirm_password">
<el-input v-model="passwordForm.confirm_password" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="passwordSaving" @click="handleChangePassword">
更新密码
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="系统参数" name="system">
<el-form label-width="180px" style="max-width: 720px">
<el-form-item label="后台名称">
<el-input v-model="systemForm.site_name" placeholder="系统名称" />
</el-form-item>
<el-form-item label="允许注册">
<el-switch v-model="systemForm.allow_register" />
</el-form-item>
<el-form-item label="登录失败锁定阈值">
<el-input-number v-model="systemForm.login_fail_limit" :min="3" :max="20" />
</el-form-item>
<el-form-item label="默认分页大小">
<el-input-number v-model="systemForm.default_page_size" :min="10" :max="200" />
</el-form-item>
<el-form-item label="操作日志保留天数">
<el-input-number v-model="systemForm.audit_log_retention_days" :min="7" :max="3650" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="systemSaving" @click="handleSaveSystem">保存参数</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</template>
<script setup>
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import {
getAdminSettings,
getSystemConfig,
updateAdminPassword,
updateAdminProfile,
updateSystemConfig
} from '../../api/settings'
const activeTab = ref('profile')
const profileFormRef = ref(null)
const passwordFormRef = ref(null)
const profileSaving = ref(false)
const passwordSaving = ref(false)
const systemSaving = ref(false)
const profileForm = reactive({
username: '',
display_name: '',
email: '',
phone: '',
timezone: 'Asia/Shanghai'
})
const passwordForm = reactive({
old_password: '',
new_password: '',
confirm_password: ''
})
const systemForm = reactive({
site_name: '多小程序管理后台',
allow_register: false,
login_fail_limit: 5,
default_page_size: 20,
audit_log_retention_days: 180
})
const profileRules = {
display_name: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
}
const confirmPasswordValidator = (rule, value, callback) => {
if (!value) {
callback(new Error('请再次输入新密码'))
return
}
if (value !== passwordForm.new_password) {
callback(new Error('两次输入密码不一致'))
return
}
callback()
}
const passwordRules = {
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
],
confirm_password: [{ validator: confirmPasswordValidator, trigger: 'blur' }]
}
const loadProfile = async () => {
const res = await getAdminSettings()
const payload = res.data || {}
profileForm.username = payload.username || ''
profileForm.display_name = payload.display_name || payload.nickname || payload.username || ''
profileForm.email = payload.email || ''
profileForm.phone = payload.phone || ''
profileForm.timezone = payload.timezone || 'Asia/Shanghai'
}
const loadSystemConfig = async () => {
const res = await getSystemConfig()
const payload = res.data || {}
systemForm.site_name = payload.site_name || systemForm.site_name
systemForm.allow_register = payload.allow_register ?? systemForm.allow_register
systemForm.login_fail_limit = payload.login_fail_limit || systemForm.login_fail_limit
systemForm.default_page_size = payload.default_page_size || systemForm.default_page_size
systemForm.audit_log_retention_days =
payload.audit_log_retention_days || systemForm.audit_log_retention_days
}
const handleSaveProfile = async () => {
if (!profileFormRef.value) return
try {
await profileFormRef.value.validate()
} catch (error) {
return
}
profileSaving.value = true
try {
await updateAdminProfile({
display_name: profileForm.display_name,
email: profileForm.email,
phone: profileForm.phone,
timezone: profileForm.timezone
})
ElMessage.success('资料保存成功')
} finally {
profileSaving.value = false
}
}
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
try {
await passwordFormRef.value.validate()
} catch (error) {
return
}
passwordSaving.value = true
try {
await updateAdminPassword({
old_password: passwordForm.old_password,
new_password: passwordForm.new_password
})
passwordForm.old_password = ''
passwordForm.new_password = ''
passwordForm.confirm_password = ''
ElMessage.success('密码更新成功,请使用新密码重新登录')
} finally {
passwordSaving.value = false
}
}
const handleSaveSystem = async () => {
systemSaving.value = true
try {
await updateSystemConfig(systemForm)
ElMessage.success('系统参数已保存')
} finally {
systemSaving.value = false
}
}
onMounted(async () => {
await Promise.all([loadProfile(), loadSystemConfig()])
})
</script>
+320 -2
View File
@@ -1,8 +1,326 @@
<template>
<div class="watermark-page">
<el-row :gutter="16" class="cards">
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value">{{ overview.total_tasks || 0 }}</div>
<div class="card-label">总任务数</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value success">{{ overview.success_tasks || 0 }}</div>
<div class="card-label">成功任务</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value warning">{{ overview.processing_tasks || 0 }}</div>
<div class="card-label">处理中</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<el-card shadow="hover">
<div class="card-value danger">{{ overview.failed_tasks || 0 }}</div>
<div class="card-label">失败任务</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<span>去水印管理</span>
<div class="header-row">
<div class="filters">
<el-input
v-model="query.keyword"
clearable
style="width: 220px"
placeholder="搜索视频链接/用户ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.mini_program_id"
clearable
style="width: 180px"
placeholder="全部小程序"
@change="handleSearch"
>
<el-option
v-for="item in miniProgramOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-select
v-model="query.status"
clearable
style="width: 150px"
placeholder="全部状态"
@change="handleSearch"
>
<el-option label="成功" value="success" />
<el-option label="处理中" value="processing" />
<el-option label="失败" value="failed" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</template>
<el-empty description="开发中,下一步对接去水印任务管理接口" />
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="小程序" min-width="140">
<template #default="{ row }">
{{ row.mini_program_name || row.mini_program?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="{ row }">
{{ row.platform || '-' }}
</template>
</el-table-column>
<el-table-column label="原始链接" min-width="220">
<template #default="{ row }">
<el-link :href="row.source_url" target="_blank" type="primary" :underline="false">
{{ row.source_url || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column label="解析结果" min-width="220">
<template #default="{ row }">
<el-link
v-if="row.cleaned_url || row.result_url"
:href="row.cleaned_url || row.result_url"
target="_blank"
type="success"
:underline="false"
>
查看无水印链接
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="100">
<template #default="{ row }">
{{ row.duration_ms || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="失败原因" min-width="160">
<template #default="{ row }">
{{ row.error_message || row.fail_reason || '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'failed'"
link
type="primary"
@click="handleRetry(row)"
>
重试
</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="loadData"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { getMiniPrograms } from '../../api/miniProgram'
import {
deleteWatermarkTask,
getWatermarkOverview,
getWatermarkTasks,
retryWatermarkTask
} from '../../api/watermark'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const miniProgramOptions = ref([])
const overview = reactive({
total_tasks: 0,
success_tasks: 0,
processing_tasks: 0,
failed_tasks: 0
})
const query = reactive({
page: 1,
page_size: 20,
mini_program_id: undefined,
status: undefined,
keyword: ''
})
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const statusText = (status) => {
if (status === 'success') return '成功'
if (status === 'processing') return '处理中'
if (status === 'failed') return '失败'
return status || '-'
}
const statusTagType = (status) => {
if (status === 'success') return 'success'
if (status === 'processing') return 'warning'
if (status === 'failed') return 'danger'
return 'info'
}
const loadMiniPrograms = async () => {
const res = await getMiniPrograms({ page: 1, page_size: 200 })
const payload = res.data || {}
miniProgramOptions.value = payload.list || []
}
const loadOverview = async () => {
const res = await getWatermarkOverview()
const payload = res.data || {}
overview.total_tasks = payload.total_tasks || 0
overview.success_tasks = payload.success_tasks || 0
overview.processing_tasks = payload.processing_tasks || 0
overview.failed_tasks = payload.failed_tasks || 0
}
const loadData = async () => {
loading.value = true
try {
const res = await getWatermarkTasks(query)
const payload = res.data || {}
list.value = payload.list || payload.items || []
total.value = payload.total || 0
} finally {
loading.value = false
}
}
const refreshAll = async () => {
await Promise.all([loadOverview(), loadData()])
}
const handleSearch = () => {
query.page = 1
loadData()
}
const resetSearch = () => {
query.keyword = ''
query.status = undefined
query.mini_program_id = undefined
handleSearch()
}
const handleSizeChange = () => {
query.page = 1
loadData()
}
const handleRetry = async (row) => {
await retryWatermarkTask(row.id)
ElMessage.success('已提交重试')
refreshAll()
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确认删除该任务记录吗?', '提示', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
await deleteWatermarkTask(row.id)
ElMessage.success('删除成功')
refreshAll()
}
onMounted(async () => {
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
})
</script>
<style scoped>
.cards {
margin-bottom: 16px;
}
.card-value {
font-size: 26px;
font-weight: 600;
color: #303133;
}
.card-value.success {
color: #67c23a;
}
.card-value.warning {
color: #e6a23c;
}
.card-value.danger {
color: #f56c6c;
}
.card-label {
margin-top: 8px;
color: #909399;
font-size: 13px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>