feat: 成就管理与梦想图标后台页面
deploy-admin-frontend-prod / deploy (push) Waiting to run

- 成就主题/等级管理
- 梦想目标图标管理(dream-presets)与路由「梦想图标」

Made-with: Cursor
This commit is contained in:
nepiedg
2026-04-04 14:55:57 +08:00
parent 66713b110f
commit 8186e36d3d
5 changed files with 600 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import request from '../utils/request'
export function listThemes() {
return request({ url: '/api/admin/achievement/themes', method: 'get' })
}
export function getTheme(id) {
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'get' })
}
export function createTheme(data) {
return request({ url: '/api/admin/achievement/themes', method: 'post', data })
}
export function updateTheme(id, data) {
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'put', data })
}
export function deleteTheme(id) {
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'delete' })
}
export function listLevels(themeId) {
return request({ url: `/api/admin/achievement/themes/${themeId}/levels`, method: 'get' })
}
export function createLevel(data) {
return request({ url: '/api/admin/achievement/levels', method: 'post', data })
}
export function updateLevel(id, data) {
return request({ url: `/api/admin/achievement/levels/${id}`, method: 'put', data })
}
export function deleteLevel(id) {
return request({ url: `/api/admin/achievement/levels/${id}`, method: 'delete' })
}
+17
View File
@@ -0,0 +1,17 @@
import request from '../utils/request'
export function listDreamPresets() {
return request({ url: '/api/admin/dream-presets', method: 'get' })
}
export function createDreamPreset(data) {
return request({ url: '/api/admin/dream-presets', method: 'post', data })
}
export function updateDreamPreset(id, data) {
return request({ url: `/api/admin/dream-presets/${id}`, method: 'put', data })
}
export function deleteDreamPreset(id) {
return request({ url: `/api/admin/dream-presets/${id}`, method: 'delete' })
}
+12
View File
@@ -102,6 +102,18 @@ const routes = [
component: () => import('../views/smoke/index.vue'), component: () => import('../views/smoke/index.vue'),
meta: { title: '戒烟小程序', icon: 'Opportunity' } meta: { title: '戒烟小程序', icon: 'Opportunity' }
}, },
{
path: 'achievement',
name: 'Achievement',
component: () => import('../views/achievement/index.vue'),
meta: { title: '成就管理', icon: 'Trophy' }
},
{
path: 'dream-presets',
name: 'DreamPresets',
component: () => import('../views/dream-presets/index.vue'),
meta: { title: '梦想图标', icon: 'Flag' }
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="achievement-page">
<el-card>
<template #header>
<div class="card-header">
<span>成就主题管理</span>
<el-button type="primary" @click="openThemeDialog()">新增主题</el-button>
</div>
</template>
<el-table :data="themes" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="icon" label="图标" width="60" />
<el-table-column prop="name" label="名称" width="100" />
<el-table-column prop="key" label="标识" width="100" />
<el-table-column label="等级数" width="80">
<template #default="{ row }">{{ row.levels?.length || 0 }}</template>
</el-table-column>
<el-table-column label="等级预览" min-width="280">
<template #default="{ row }">
<span v-for="(level, idx) in (row.levels || [])" :key="level.id" class="level-preview">
{{ level.name }}({{ level.required_days }})<span v-if="idx < row.levels.length - 1"> </span>
</span>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="60" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openLevelDialog(row)">管理等级</el-button>
<el-button size="small" @click="openThemeDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeleteTheme(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 主题编辑弹窗 -->
<el-dialog v-model="themeDialogVisible" :title="editingTheme ? '编辑主题' : '新增主题'" width="460px">
<el-form :model="themeForm" label-width="80px">
<el-form-item label="名称">
<el-input v-model="themeForm.name" placeholder="如:修仙" />
</el-form-item>
<el-form-item label="标识">
<el-input v-model="themeForm.key" placeholder="如:xiuxian" />
</el-form-item>
<el-form-item label="图标">
<el-input v-model="themeForm.icon" placeholder="如:⚔️" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="themeForm.sort_order" :min="0" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="themeForm.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="themeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTheme" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 等级管理弹窗 -->
<el-dialog v-model="levelDialogVisible" :title="`管理等级 - ${currentTheme?.name || ''}`" width="680px">
<div class="level-toolbar">
<el-button type="primary" size="small" @click="openLevelForm()">新增等级</el-button>
</div>
<el-table :data="levels" v-loading="levelLoading" stripe size="small">
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="icon" label="图标" width="60" />
<el-table-column prop="required_days" label="所需天数" width="100" />
<el-table-column prop="sort_order" label="排序" width="60" />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" @click="openLevelForm(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeleteLevel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-divider v-if="showLevelForm" />
<el-form v-if="showLevelForm" :model="levelForm" label-width="80px" size="small" class="level-form">
<el-form-item label="名称">
<el-input v-model="levelForm.name" placeholder="如:炼体" />
</el-form-item>
<el-form-item label="图标">
<el-input v-model="levelForm.icon" placeholder="可选" />
</el-form-item>
<el-form-item label="所需天数">
<el-input-number v-model="levelForm.required_days" :min="0" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="levelForm.sort_order" :min="0" />
</el-form-item>
<el-form-item>
<el-button @click="showLevelForm = false">取消</el-button>
<el-button type="primary" @click="saveLevel" :loading="levelSaving">保存</el-button>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listThemes, createTheme, updateTheme, deleteTheme,
listLevels, createLevel, updateLevel, deleteLevel
} from '../../api/achievement'
const loading = ref(false)
const saving = ref(false)
const themes = ref([])
const themeDialogVisible = ref(false)
const editingTheme = ref(null)
const themeForm = ref({ name: '', key: '', icon: '', sort_order: 0, is_active: true })
const levelDialogVisible = ref(false)
const levelLoading = ref(false)
const levelSaving = ref(false)
const currentTheme = ref(null)
const levels = ref([])
const showLevelForm = ref(false)
const editingLevel = ref(null)
const levelForm = ref({ name: '', icon: '', required_days: 0, sort_order: 0 })
async function loadThemes() {
loading.value = true
try {
const res = await listThemes()
themes.value = res.data?.themes || []
} catch (e) {
ElMessage.error('加载主题失败')
} finally {
loading.value = false
}
}
function openThemeDialog(theme = null) {
editingTheme.value = theme
if (theme) {
themeForm.value = { name: theme.name, key: theme.key, icon: theme.icon, sort_order: theme.sort_order, is_active: theme.is_active }
} else {
themeForm.value = { name: '', key: '', icon: '', sort_order: 0, is_active: true }
}
themeDialogVisible.value = true
}
async function saveTheme() {
if (!themeForm.value.name || !themeForm.value.key) {
ElMessage.warning('名称和标识必填')
return
}
saving.value = true
try {
if (editingTheme.value) {
await updateTheme(editingTheme.value.id, themeForm.value)
ElMessage.success('更新成功')
} else {
await createTheme(themeForm.value)
ElMessage.success('创建成功')
}
themeDialogVisible.value = false
await loadThemes()
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function handleDeleteTheme(theme) {
try {
await ElMessageBox.confirm(`确定删除主题「${theme.name}」?`, '确认')
await deleteTheme(theme.id)
ElMessage.success('删除成功')
await loadThemes()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
async function openLevelDialog(theme) {
currentTheme.value = theme
levelDialogVisible.value = true
showLevelForm.value = false
await loadLevels(theme.id)
}
async function loadLevels(themeId) {
levelLoading.value = true
try {
const res = await listLevels(themeId)
levels.value = res.data?.levels || []
} catch (e) {
ElMessage.error('加载等级失败')
} finally {
levelLoading.value = false
}
}
function openLevelForm(level = null) {
editingLevel.value = level
if (level) {
levelForm.value = { name: level.name, icon: level.icon || '', required_days: level.required_days, sort_order: level.sort_order }
} else {
levelForm.value = { name: '', icon: '', required_days: 0, sort_order: levels.value.length }
}
showLevelForm.value = true
}
async function saveLevel() {
if (!levelForm.value.name) {
ElMessage.warning('名称必填')
return
}
levelSaving.value = true
try {
if (editingLevel.value) {
await updateLevel(editingLevel.value.id, levelForm.value)
ElMessage.success('更新成功')
} else {
await createLevel({ ...levelForm.value, theme_id: currentTheme.value.id })
ElMessage.success('创建成功')
}
showLevelForm.value = false
await loadLevels(currentTheme.value.id)
await loadThemes()
} catch (e) {
ElMessage.error('保存失败')
} finally {
levelSaving.value = false
}
}
async function handleDeleteLevel(level) {
try {
await ElMessageBox.confirm(`确定删除等级「${level.name}」?`, '确认')
await deleteLevel(level.id)
ElMessage.success('删除成功')
await loadLevels(currentTheme.value.id)
await loadThemes()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
onMounted(() => {
loadThemes()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.level-preview {
font-size: 13px;
color: #606266;
}
.level-toolbar {
margin-bottom: 12px;
}
.level-form {
margin-top: 8px;
}
</style>
+258
View File
@@ -0,0 +1,258 @@
<template>
<div class="dream-icons-page">
<el-card>
<template #header>
<div class="card-header">
<span>梦想目标图标管理</span>
<el-button type="primary" @click="openDialog()">新增图标</el-button>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
用户添加梦想目标时从此处配置的图标中选择名称和价格由用户自行填写
</el-alert>
<el-table :data="icons" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="图标" width="100">
<template #default="{ row }">
<span v-if="row.cover_image && row.cover_image.startsWith('icon:')" style="font-size: 28px">
{{ row.cover_image.replace('icon:', '') }}
</span>
<el-image v-else-if="row.cover_image" :src="row.cover_image" style="width: 44px; height: 44px; border-radius: 8px" fit="cover" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="title" label="图标名称" min-width="120">
<template #default="{ row }">
<span style="color: #999">{{ row.title || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDialog(row)">编辑</el-button>
<el-popconfirm title="确定删除?" @confirm="handleDelete(row.id)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑图标' : '新增图标'" width="480px">
<el-form :model="form" label-width="90px">
<el-form-item label="图标名称">
<el-input v-model="form.title" placeholder="备注名(如:耳机、手机)" maxlength="20" />
</el-form-item>
<el-form-item label="Emoji" required>
<div class="icon-grid">
<div
v-for="icon in emojiOptions"
:key="icon"
class="icon-option"
:class="{ active: form.cover_image === `icon:${icon}` }"
@click="selectEmoji(icon)"
>
{{ icon }}
</div>
</div>
</el-form-item>
<el-form-item label="或图片URL">
<el-input v-model="form.imageUrl" placeholder="https://... 图片链接(优先于 Emoji" @input="onImageUrlInput" />
</el-form-item>
<el-form-item label="预览" v-if="previewImage">
<div class="preview-box">
<span v-if="previewImage.startsWith('icon:')" style="font-size: 36px">{{ previewImage.replace('icon:', '') }}</span>
<el-image v-else :src="previewImage" style="width: 60px; height: 60px; border-radius: 10px" fit="cover" />
</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listDreamPresets, createDreamPreset, updateDreamPreset, deleteDreamPreset } from '../../api/dreamPreset'
const loading = ref(false)
const saving = ref(false)
const icons = ref([])
const dialogVisible = ref(false)
const editingId = ref(null)
const emojiOptions = [
'🎧', '👟', '📱', '⌚', '🎮', '📷',
'💻', '🎸', '🏖️', '🎂', '🎁', '🚲',
'🎒', '👜', '🕶️', '🧸', '🏠', '✈️',
'🚗', '💎', '🎨', '📚', '🍰', '🌸',
]
const defaultForm = () => ({
title: '',
cover_image: '',
imageUrl: '',
sort_order: 0,
is_active: true,
})
const form = ref(defaultForm())
const previewImage = computed(() => {
if (form.value.imageUrl.trim()) return form.value.imageUrl.trim()
if (form.value.cover_image) return form.value.cover_image
return ''
})
function selectEmoji(emoji) {
form.value.cover_image = `icon:${emoji}`
form.value.imageUrl = ''
}
function onImageUrlInput() {
if (form.value.imageUrl.trim()) {
form.value.cover_image = ''
}
}
async function fetchList() {
loading.value = true
try {
const res = await listDreamPresets()
icons.value = res.data?.items || []
} catch (e) {
ElMessage.error('获取失败')
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) {
editingId.value = row.id
const isIcon = row.cover_image?.startsWith('icon:')
form.value = {
title: row.title || '',
cover_image: isIcon ? row.cover_image : '',
imageUrl: isIcon ? '' : (row.cover_image || ''),
sort_order: row.sort_order,
is_active: row.is_active,
}
} else {
editingId.value = null
form.value = defaultForm()
}
dialogVisible.value = true
}
async function handleSave() {
const coverImage = form.value.imageUrl.trim() || form.value.cover_image || ''
if (!coverImage) {
ElMessage.warning('请选择 Emoji 或填入图片链接')
return
}
saving.value = true
const data = {
title: form.value.title.trim(),
cover_image: coverImage,
sort_order: form.value.sort_order,
is_active: form.value.is_active,
}
try {
if (editingId.value) {
await updateDreamPreset(editingId.value, data)
ElMessage.success('更新成功')
} else {
await createDreamPreset(data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await fetchList()
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(id) {
try {
await deleteDreamPreset(id)
ElMessage.success('已删除')
await fetchList()
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(fetchList)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
}
.icon-option {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
border-radius: 8px;
border: 2px solid transparent;
background: #f5f7fa;
cursor: pointer;
transition: all 0.15s;
}
.icon-option:hover {
border-color: #c6e7d9;
}
.icon-option.active {
border-color: #14936d;
background: #ecfdf5;
}
.preview-box {
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
border-radius: 12px;
background: #f9fafb;
border: 1px dashed #e5e7eb;
}
</style>