chore: 首次提交 - 关联 GitHub fnvtk/MBTI_wang
Made-with: Cursor
This commit is contained in:
392
admin/src/views/admin/Dashboard.vue
Normal file
392
admin/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="dashboard-container" v-loading="loading">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-value">{{ stats.totalUsers }}</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已完成测试</div>
|
||||
<div class="stat-value">{{ stats.testsCompleted }}</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">今日活跃</div>
|
||||
<div class="stat-value">{{ stats.activeToday }}</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">待审核</div>
|
||||
<div class="stat-value">{{ stats.pendingReviews }}</div>
|
||||
</div>
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试趋势折线图 -->
|
||||
<div class="activity-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">测试趋势</h2>
|
||||
<p class="section-subtitle">最近 14 天人脸分析、MBTI、PDP、DISC 等关键测试的完成情况</p>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart-wrapper" v-if="testTrends.length">
|
||||
<VChart class="trend-chart-echarts" :option="chartOption" autoresize />
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-else>
|
||||
<span>暂无测试趋势数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请二维码 -->
|
||||
<div class="activity-section invite-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">专属邀请小程序码</h2>
|
||||
<p class="section-subtitle">生成专属邀请二维码,员工/客户扫码即可进入小程序完成测试</p>
|
||||
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="invite-content">
|
||||
<div v-if="inviteQrcode">
|
||||
<img :src="inviteQrcode" alt="邀请小程序码" class="invite-qrcode" />
|
||||
<div class="invite-tip">右键/长按保存二维码,用于宣传物料或群内邀请</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<el-button type="primary" size="small" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
生成邀请二维码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import {
|
||||
User,
|
||||
Document,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const stats = reactive({
|
||||
totalUsers: 0,
|
||||
testsCompleted: 0,
|
||||
activeToday: 0,
|
||||
pendingReviews: 0
|
||||
})
|
||||
|
||||
const testTrends = ref<
|
||||
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
|
||||
>([])
|
||||
const loading = ref(false)
|
||||
const inviteLoading = ref(false)
|
||||
const inviteQrcode = ref<string>('')
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const dates = testTrends.value.map(d => d.date.slice(5))
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['人脸分析', 'MBTI', 'PDP', 'DISC'],
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 30,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '人脸分析',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#22c55e' },
|
||||
data: testTrends.value.map(d => d.face)
|
||||
},
|
||||
{
|
||||
name: 'MBTI',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
data: testTrends.value.map(d => d.mbti)
|
||||
},
|
||||
{
|
||||
name: 'PDP',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: testTrends.value.map(d => d.pdp)
|
||||
},
|
||||
{
|
||||
name: 'DISC',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
data: testTrends.value.map(d => d.disc)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/dashboard')
|
||||
if (response.code === 200 && response.data) {
|
||||
stats.totalUsers = response.data.totalUsers || 0
|
||||
stats.testsCompleted = response.data.testsCompleted || 0
|
||||
stats.activeToday = response.data.activeToday || 0
|
||||
stats.pendingReviews = response.data.pendingReviews || 0
|
||||
testTrends.value = response.data.testTrends || []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据失败:', error)
|
||||
ElMessage.error(error.message || '加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// 加载邀请小程序码
|
||||
const loadInviteQrcode = async () => {
|
||||
if (inviteLoading.value) return
|
||||
inviteLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/invite/qrcode')
|
||||
const data = res.data ?? res
|
||||
if (data && data.qrcode) {
|
||||
inviteQrcode.value = data.qrcode
|
||||
} else if (data.code === 200 && data.data?.qrcode) {
|
||||
inviteQrcode.value = data.data.qrcode
|
||||
} else {
|
||||
ElMessage.error('生成邀请二维码失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('生成邀请二维码失败:', error)
|
||||
ElMessage.error(error?.message || '生成邀请二维码失败')
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
background-color: #f9fafb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
|
||||
&.blue {
|
||||
background-color: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #f0fdf4;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.purple {
|
||||
background-color: #faf5ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fffbeb;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-section {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.trend-chart-echarts {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-section {
|
||||
margin-top: 24px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-qrcode {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.invite-tip {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.activity-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1231
admin/src/views/admin/Distribution.vue
Normal file
1231
admin/src/views/admin/Distribution.vue
Normal file
File diff suppressed because it is too large
Load Diff
379
admin/src/views/admin/Finance.vue
Normal file
379
admin/src/views/admin/Finance.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div class="finance-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>企业余额</h2>
|
||||
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额、测试收入和充值流水</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="loadAll" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前余额</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.balanceFen) }}</div>
|
||||
<div class="stat-desc">可用于企业佣金结算</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.todayIncomeFen) }}</div>
|
||||
<div class="stat-desc">仅统计 face / MBTI / DISC / PDP</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.monthIncomeFen) }}</div>
|
||||
<div class="stat-desc">企业用户支付后自动入账</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">冻结佣金</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.frozenCommissionFen) }}</div>
|
||||
<div class="stat-desc">补余额后会自动解冻</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<div class="content-header">
|
||||
<h3>财务流水</h3>
|
||||
<span class="content-tip">测试收入会自动进入企业余额,手动充值也会记录在这里</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" v-loading="recordsLoading" class="custom-table">
|
||||
<el-table-column prop="typeLabel" label="类型" width="120" />
|
||||
<el-table-column label="金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.direction === 'out' ? 'amount-out' : 'amount-in'">
|
||||
{{ row.direction === 'out' ? '-' : '+' }}¥{{ fenToYuan(row.amountFen) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动前余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceBeforeFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动后余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceAfterFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="220" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="empty-state" v-if="!recordsLoading && records.length === 0">暂无财务流水</div>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeDialogVisible" title="企业余额充值" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="充值金额(元)" required>
|
||||
<el-input-number v-model="rechargeForm.amountYuan" :min="0.01" :step="100" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="rechargeForm.remark" type="textarea" :rows="2" placeholder="可选,仅用于当前页提示,不会直接入账" />
|
||||
</el-form-item>
|
||||
<div v-if="rechargeQrcode" class="recharge-qrcode">
|
||||
<img :src="rechargeQrcode" alt="充值二维码" class="qrcode-image" />
|
||||
<p class="qrcode-tip">请使用微信扫码,在小程序内完成支付充值</p>
|
||||
<p class="qrcode-amount">本次充值:¥{{ rechargeForm.amountYuan.toFixed(2) }}</p>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rechargeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" color="#7c3aed" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const recordsLoading = ref(false)
|
||||
const rechargeLoading = ref(false)
|
||||
const rechargeDialogVisible = ref(false)
|
||||
const rechargeQrcode = ref('')
|
||||
|
||||
const overview = reactive({
|
||||
enterpriseId: 0,
|
||||
enterpriseName: '',
|
||||
balanceFen: 0,
|
||||
todayIncomeFen: 0,
|
||||
monthIncomeFen: 0,
|
||||
totalIncomeFen: 0,
|
||||
manualRechargeFen: 0,
|
||||
frozenCommissionFen: 0,
|
||||
paidOrderCount: 0
|
||||
})
|
||||
|
||||
const records = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amountYuan: 100,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
const res: any = await request.get('/admin/finance/overview')
|
||||
Object.assign(overview, res.data || {})
|
||||
}
|
||||
|
||||
const loadRecords = async () => {
|
||||
recordsLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/finance/records', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
records.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
recordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadOverview()
|
||||
await loadRecords()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载企业财务数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRechargeDialog = () => {
|
||||
rechargeQrcode.value = ''
|
||||
rechargeDialogVisible.value = true
|
||||
}
|
||||
|
||||
const generateRechargeQrcode = async () => {
|
||||
if (!rechargeForm.amountYuan || rechargeForm.amountYuan <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
const amountFen = Math.round(Number(rechargeForm.amountYuan) * 100)
|
||||
if (amountFen <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/admin/finance/recharge-qrcode', {
|
||||
amountFen,
|
||||
remark: rechargeForm.remark
|
||||
})
|
||||
rechargeQrcode.value = res.data?.qrcode || ''
|
||||
ElMessage.success('充值码已生成')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '生成充值码失败')
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.finance-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #eef2f7;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-tip {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-in {
|
||||
color: #16a34a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-out {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px 0 8px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.recharge-qrcode {
|
||||
margin-top: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
margin: 12px 0 4px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qrcode-amount {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.finance-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
254
admin/src/views/admin/Login.vue
Normal file
254
admin/src/views/admin/Login.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h2 class="title">管理员登录</h2>
|
||||
<p class="description">请输入您的管理员凭据以访问后台</p>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</el-alert>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@submit.prevent="handleLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<label class="form-label">用户名</label>
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
clearable
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<label class="form-label">密码</label>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="security-info">
|
||||
<el-icon class="security-icon"><Lock /></el-icon>
|
||||
<span>安全连接 | 仅限授权人员访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const success = await authStore.adminLogin(loginForm.username, loginForm.password)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/admin/dashboard')
|
||||
} else {
|
||||
errorMessage.value = '用户名或密码错误'
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('表单验证失败:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 448px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 12px 16px;
|
||||
background-color: #f9fafb;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #5b21b6;
|
||||
border-color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
.security-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
gap: 6px;
|
||||
|
||||
.security-icon {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
611
admin/src/views/admin/Orders.vue
Normal file
611
admin/src/views/admin/Orders.vue
Normal file
@@ -0,0 +1,611 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>订单管理</h2>
|
||||
<p class="subtitle">管理所有支付订单,含用户与关联测试数据</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="loadOrders" class="refresh-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总订单数</div>
|
||||
<div class="stat-value">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已完成/已支付</div>
|
||||
<div class="stat-value">{{ paidCount }}</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总收入</div>
|
||||
<div class="stat-value">{{ totalIncome }}</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索订单号/用户ID/昵称/手机号..."
|
||||
clearable
|
||||
class="search-input"
|
||||
@clear="loadOrders"
|
||||
@keyup.enter="loadOrders"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="loadOrders">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<div class="filter-group">
|
||||
<div
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="['filter-item', { active: statusFilter === item.value }]"
|
||||
@click="statusFilter = item.value; currentPage = 1; loadOrders()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-divider"></div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div
|
||||
v-for="item in productOptions"
|
||||
:key="item.value"
|
||||
:class="['filter-item', { active: productFilter === item.value }]"
|
||||
@click="productFilter = item.value; currentPage = 1; loadOrders()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table">
|
||||
<el-table-column label="订单号" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="order-id">{{ row.orderNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<div class="user-name">{{ row.userName || '—' }}</div>
|
||||
<div class="user-phone">{{ formatPhone(row.userPhone) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<div class="product-main">{{ row.productTitle || productTypeLabel(row.productType) }}</div>
|
||||
<div class="product-sub" v-if="row.productTitle">
|
||||
{{ productTypeLabel(row.productType) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付方式" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ payMethodLabel(row.payMethod) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联测试" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="test-data-cell">
|
||||
<template v-if="row.testData && row.testData.length">
|
||||
<div
|
||||
v-for="(t, i) in row.testData"
|
||||
:key="t.id || i"
|
||||
class="test-item"
|
||||
>
|
||||
<span class="test-type">{{ testTypeLabel(t.testType) }}</span>
|
||||
<span class="test-summary">{{ t.resultSummary || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.payTime ? formatDate(row.payTime) : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="empty-state" v-if="orders.length === 0 && !loading">
|
||||
<span>暂无订单数据</span>
|
||||
</div>
|
||||
<div class="empty-state" v-if="orders.length === 0 && loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Refresh, ShoppingCart, Box, Money, Search } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
const productFilter = ref('')
|
||||
const orders = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
{ label: '已退款', value: 'refunded' },
|
||||
{ label: '失败', value: 'failed' }
|
||||
]
|
||||
|
||||
const productOptions = [
|
||||
{ label: '全部产品', value: '' },
|
||||
{ label: 'AI人脸分析', value: 'face' },
|
||||
{ label: 'MBTI', value: 'mbti' },
|
||||
{ label: 'DISC', value: 'disc' },
|
||||
{ label: 'PDP', value: 'pdp' },
|
||||
{ label: '完整报告', value: 'report' }
|
||||
]
|
||||
|
||||
const paidCount = computed(() =>
|
||||
orders.value.filter((o: any) => ['paid', 'completed'].includes(o.status || '')).length
|
||||
)
|
||||
const totalIncome = computed(() => {
|
||||
const sum = orders.value
|
||||
.filter((o: any) => ['paid', 'completed'].includes(o.status || ''))
|
||||
.reduce((acc: number, o: any) => acc + (Number(o.amount) || 0), 0)
|
||||
return '¥' + (sum / 100).toFixed(2)
|
||||
})
|
||||
|
||||
function formatPhone(phone: string) {
|
||||
if (!phone) return '—'
|
||||
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
return phone
|
||||
}
|
||||
|
||||
function formatDate(ts: number | null | undefined) {
|
||||
if (ts == null) return '—'
|
||||
const d = new Date(ts * 1000)
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
|
||||
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatAmount(amountFen: number | string | null | undefined) {
|
||||
if (amountFen == null) return '¥0.00'
|
||||
const n = typeof amountFen === 'number' ? amountFen : Number(amountFen)
|
||||
if (!Number.isFinite(n)) return '¥0.00'
|
||||
return '¥' + (n / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function productTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
face: 'AI人脸分析',
|
||||
mbti: 'MBTI',
|
||||
disc: 'DISC',
|
||||
pdp: 'PDP',
|
||||
report: '完整报告'
|
||||
}
|
||||
return map[type || ''] || type || '—'
|
||||
}
|
||||
|
||||
function testTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
face: '人脸',
|
||||
ai: '人脸',
|
||||
mbti: 'MBTI',
|
||||
disc: 'DISC',
|
||||
pdp: 'PDP'
|
||||
}
|
||||
return map[(type || '').toLowerCase()] || type || '—'
|
||||
}
|
||||
|
||||
function payMethodLabel(m: string) {
|
||||
if (!m) return '—'
|
||||
if (m === 'wechat') return '微信支付'
|
||||
return m
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
failed: '失败'
|
||||
}
|
||||
return map[s || ''] || s || '—'
|
||||
}
|
||||
|
||||
function statusTagType(
|
||||
s: string
|
||||
): 'primary' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
paid: 'success',
|
||||
completed: 'success',
|
||||
cancelled: 'info',
|
||||
refunded: 'info',
|
||||
failed: 'danger'
|
||||
}
|
||||
return map[s || ''] || 'info'
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/orders', {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
pageSize,
|
||||
keyword: searchQuery.value,
|
||||
status: statusFilter.value,
|
||||
productType: productFilter.value
|
||||
}
|
||||
})
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
orders.value = list
|
||||
total.value = res.data?.total ?? res?.total ?? 0
|
||||
} catch {
|
||||
orders.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.stat-info {
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
.realtime-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #22c55e;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
|
||||
&.blue { background-color: #eff6ff; color: #3b82f6; }
|
||||
&.green { background-color: #f0fdf4; color: #22c55e; }
|
||||
&.purple { background-color: #faf5ff; color: #a855f7; }
|
||||
&.orange { background-color: #fffbeb; color: #f59e0b; }
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
gap: 20px;
|
||||
|
||||
.toolbar-left {
|
||||
flex: 1;
|
||||
.search-input {
|
||||
max-width: 320px;
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
background-color: #f9fafb;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
&.is-focus {
|
||||
border-color: #7c3aed;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
background-color: #f3f4f6;
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
gap: 2px;
|
||||
|
||||
.filter-item {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #7c3aed;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
}
|
||||
.user-phone {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-data-cell {
|
||||
font-size: 12px;
|
||||
.test-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.test-type {
|
||||
flex-shrink: 0;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
.test-summary {
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
.no-test {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top: 2px solid #7c3aed;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
981
admin/src/views/admin/PosterEditor.vue
Normal file
981
admin/src/views/admin/PosterEditor.vue
Normal file
@@ -0,0 +1,981 @@
|
||||
<template>
|
||||
<div class="poster-editor-root">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<el-button size="small" @click="addElement('text')">
|
||||
<el-icon><EditPen /></el-icon> 添加文字
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('nickname')">
|
||||
<el-icon><User /></el-icon> 用户昵称
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('avatar')">
|
||||
<el-icon><Avatar /></el-icon> 用户头像
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('qrcode')">
|
||||
<el-icon><Grid /></el-icon> 小程序码
|
||||
</el-button>
|
||||
<el-button size="small" @click="triggerImageAdd">
|
||||
<el-icon><Picture /></el-icon> 上传图片
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button size="small" type="warning" plain @click="addElement('mbti')">MBTI</el-button>
|
||||
<el-button size="small" type="success" plain @click="addElement('pdp')">PDP</el-button>
|
||||
<el-button size="small" type="danger" plain @click="addElement('disc')">DISC</el-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
|
||||
<el-button size="small" @click="loadConfig">重置</el-button>
|
||||
<el-button size="small" type="primary" color="#7c3aed" :loading="saving" @click="saveConfig">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:图层 | 画布 | 属性 -->
|
||||
<div class="editor-body">
|
||||
<!-- 左侧图层面板 -->
|
||||
<div class="editor-layers">
|
||||
<div class="panel-title">图层</div>
|
||||
<div class="layers-list">
|
||||
<div
|
||||
v-for="el in [...elements].reverse()"
|
||||
:key="el.id"
|
||||
:class="['layer-item', { active: selectedId === el.id }]"
|
||||
@click="selectedId = el.id"
|
||||
>
|
||||
<el-icon class="layer-icon">
|
||||
<component :is="typeIconMap[el.type]" />
|
||||
</el-icon>
|
||||
<span class="layer-name">{{ typeLabel(el) }}</span>
|
||||
<div class="layer-actions">
|
||||
<el-icon class="action-icon" @click.stop="moveLayer(el.id, -1)" title="上移"><ArrowUp /></el-icon>
|
||||
<el-icon class="action-icon" @click.stop="moveLayer(el.id, 1)" title="下移"><ArrowDown /></el-icon>
|
||||
<el-icon class="action-icon danger" @click.stop="removeElement(el.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="elements.length === 0" class="layers-empty">暂无元素</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间画布 -->
|
||||
<div class="editor-canvas-area" @click.self="selectedId = null">
|
||||
<div class="canvas-scale-wrap">
|
||||
<div
|
||||
class="poster-canvas"
|
||||
:style="posterCanvasStyle"
|
||||
@click.self="selectedId = null"
|
||||
>
|
||||
<!-- 背景图(点击空白处取消选择) -->
|
||||
<img v-if="config.bgImage" :src="config.bgImage" class="canvas-bg-img" @click="selectedId = null" />
|
||||
<!-- 无背景图时的可点击底衬 -->
|
||||
<div
|
||||
v-else
|
||||
class="canvas-bg-click"
|
||||
@click="selectedId = null"
|
||||
/>
|
||||
|
||||
<!-- 各元素(configReady 后才渲染,避免组件初始化覆盖数据) -->
|
||||
<Vue3DraggableResizable
|
||||
v-for="el in elements"
|
||||
v-if="configReady"
|
||||
:key="`${el.id}_${configVersion}`"
|
||||
:initW="el.w"
|
||||
:initH="el.h"
|
||||
:x="el.x"
|
||||
:y="el.y"
|
||||
:w="el.w"
|
||||
:h="el.h"
|
||||
:active="selectedId === el.id"
|
||||
:parent="true"
|
||||
:draggable="true"
|
||||
:resizable="true"
|
||||
:min-w="20"
|
||||
:min-h="20"
|
||||
class-name-active="vdr-active"
|
||||
@update:x="el.x = Math.round($event)"
|
||||
@update:y="el.y = Math.round($event)"
|
||||
@update:w="el.w = Math.round($event)"
|
||||
@update:h="el.h = Math.round($event)"
|
||||
@activated="selectedId = el.id"
|
||||
>
|
||||
<!-- 文字 -->
|
||||
<div v-if="el.type === 'text'" class="el-text" :style="textElemStyle(el)">
|
||||
{{ el.content || '文字内容' }}
|
||||
</div>
|
||||
|
||||
<!-- 昵称占位 -->
|
||||
<div v-else-if="el.type === 'nickname'" class="el-text el-nickname" :style="textElemStyle(el)">
|
||||
用户昵称
|
||||
</div>
|
||||
|
||||
<!-- 头像占位 -->
|
||||
<div v-else-if="el.type === 'avatar'" class="el-placeholder" :style="shapeElemStyle(el)">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>头像</span>
|
||||
</div>
|
||||
|
||||
<!-- 小程序码占位 -->
|
||||
<div v-else-if="el.type === 'qrcode'" class="el-placeholder el-qrcode" :style="shapeElemStyle(el)">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>小程序码</span>
|
||||
</div>
|
||||
|
||||
<!-- 上传图片 -->
|
||||
<div v-else-if="el.type === 'image'" class="el-placeholder el-image" :style="shapeElemStyle(el)">
|
||||
<img v-if="el.url" :src="el.url" :style="{ borderRadius: el.shape === 'circle' ? '50%' : '0', width: '100%', height: '100%', objectFit: 'cover' }" />
|
||||
<template v-else>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图片</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- MBTI 结果 -->
|
||||
<div v-else-if="el.type === 'mbti'" class="el-text el-test-result mbti-result" :style="textElemStyle(el)">
|
||||
{{ el.content || 'INTJ' }}
|
||||
</div>
|
||||
|
||||
<!-- PDP 结果 -->
|
||||
<div v-else-if="el.type === 'pdp'" class="el-text el-test-result pdp-result" :style="textElemStyle(el)">
|
||||
{{ el.content || '猫头鹰' }}
|
||||
</div>
|
||||
|
||||
<!-- DISC 结果 -->
|
||||
<div v-else-if="el.type === 'disc'" class="el-text el-test-result disc-result" :style="textElemStyle(el)">
|
||||
{{ el.content || 'C型' }}
|
||||
</div>
|
||||
</Vue3DraggableResizable>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-hint">画布尺寸:375 × 667 px(标准小程序屏幕)</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<div class="editor-props">
|
||||
<!-- 无选中:显示画布设置 -->
|
||||
<template v-if="!selectedElement">
|
||||
<div class="panel-title">画布设置</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">背景颜色</label>
|
||||
<el-color-picker v-model="config.bgColor" show-alpha />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">背景图片</label>
|
||||
<div v-if="config.bgImage" class="img-preview-row">
|
||||
<img :src="config.bgImage" class="img-preview" />
|
||||
<el-button size="small" type="danger" plain @click="config.bgImage = ''">清除</el-button>
|
||||
</div>
|
||||
<el-button size="small" :loading="uploadingBg" @click="triggerBgUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ config.bgImage ? '更换背景' : '上传背景' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="props-tip">点击画布上的元素以编辑属性</div>
|
||||
</template>
|
||||
|
||||
<!-- 有选中:显示元素属性 -->
|
||||
<template v-else>
|
||||
<div class="panel-title">
|
||||
{{ typeLabel(selectedElement) }}
|
||||
<el-icon class="delete-icon" @click="removeElement(selectedElement.id)" title="删除"><Delete /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 文字内容(text 类型) -->
|
||||
<div v-if="selectedElement.type === 'text'" class="prop-item">
|
||||
<label class="prop-label">文字内容</label>
|
||||
<el-input v-model="selectedElement.content" type="textarea" :rows="2" placeholder="请输入文字" />
|
||||
</div>
|
||||
|
||||
<!-- 占位文字(mbti / pdp / disc) -->
|
||||
<div v-if="['mbti','pdp','disc'].includes(selectedElement.type)" class="prop-item">
|
||||
<label class="prop-label">预览占位文字</label>
|
||||
<el-input v-model="selectedElement.content" placeholder="如:INTJ" size="small" />
|
||||
<span class="form-hint">实际生成时自动读取用户最近测试结果</span>
|
||||
</div>
|
||||
|
||||
<!-- 字体/字号/颜色/加粗/对齐(text / nickname / mbti / pdp / disc) -->
|
||||
<template v-if="['text','nickname','mbti','pdp','disc'].includes(selectedElement.type)">
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">字体</label>
|
||||
<el-select
|
||||
v-model="selectedElement.fontFamily"
|
||||
placeholder="默认字体"
|
||||
size="small"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="f in fontList"
|
||||
:key="f.key"
|
||||
:label="f.name"
|
||||
:value="f.key"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="serverFontsLoaded && fontList.length === 0" class="form-hint" style="color:#ef4444">
|
||||
服务器未安装字体,请上传字体到 public/fonts/
|
||||
</span>
|
||||
<span v-else-if="selectedElement?.fontFamily && !fontList.find(f => f.key === selectedElement?.fontFamily)" class="form-hint" style="color:#f59e0b">
|
||||
当前字体在服务器上不可用,将使用默认字体
|
||||
</span>
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">字号 (px)</label>
|
||||
<div class="font-size-row">
|
||||
<el-slider
|
||||
:model-value="selectedElement.fontSize ?? 16"
|
||||
:min="10"
|
||||
:max="120"
|
||||
:show-tooltip="false"
|
||||
@input="onFontSizeChange"
|
||||
style="flex:1"
|
||||
/>
|
||||
<el-input-number
|
||||
:model-value="selectedElement.fontSize ?? 16"
|
||||
:min="10"
|
||||
:max="200"
|
||||
:step="1"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
@change="onFontSizeChange"
|
||||
style="width:80px;margin-left:8px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">颜色</label>
|
||||
<el-color-picker v-model="selectedElement.color" show-alpha />
|
||||
</div>
|
||||
<div class="prop-item inline">
|
||||
<label class="prop-label">加粗</label>
|
||||
<el-switch v-model="selectedElement.bold" />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">对齐</label>
|
||||
<el-radio-group v-model="selectedElement.align" size="small">
|
||||
<el-radio-button value="left">左对齐</el-radio-button>
|
||||
<el-radio-button value="center">居中</el-radio-button>
|
||||
<el-radio-button value="right">右对齐</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 形状(avatar / image) -->
|
||||
<template v-if="['avatar','image'].includes(selectedElement.type)">
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">形状</label>
|
||||
<el-radio-group v-model="selectedElement.shape" size="small">
|
||||
<el-radio-button label="circle">圆形</el-radio-button>
|
||||
<el-radio-button label="square">正方形</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片上传(image) -->
|
||||
<div v-if="selectedElement.type === 'image'" class="prop-item">
|
||||
<label class="prop-label">图片</label>
|
||||
<div v-if="selectedElement.url" class="img-preview-row">
|
||||
<img :src="selectedElement.url" class="img-preview" />
|
||||
<el-button size="small" type="danger" plain @click="selectedElement.url = ''">清除</el-button>
|
||||
</div>
|
||||
<el-button size="small" :loading="uploadingImg" @click="triggerElementImageUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ selectedElement.url ? '更换图片' : '上传图片' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 位置与大小 -->
|
||||
<div class="props-section-title">位置与大小</div>
|
||||
<div class="prop-row">
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">X</label>
|
||||
<el-input-number v-model="selectedElement.x" :min="0" size="small" controls-position="right" />
|
||||
</div>
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">Y</label>
|
||||
<el-input-number v-model="selectedElement.y" :min="0" size="small" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">宽</label>
|
||||
<el-input-number v-model="selectedElement.w" :min="20" size="small" controls-position="right" />
|
||||
</div>
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">高</label>
|
||||
<el-input-number v-model="selectedElement.h" :min="20" size="small" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏文件输入 -->
|
||||
<input ref="bgInputRef" type="file" accept="image/*" style="display:none" @change="onBgFileChange" />
|
||||
<input ref="imgInputRef" type="file" accept="image/*" style="display:none" @change="onImgFileChange" />
|
||||
<input ref="addImgInputRef" type="file" accept="image/*" style="display:none" @change="onAddImgFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import Vue3DraggableResizable from 'vue3-draggable-resizable'
|
||||
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
|
||||
import {
|
||||
EditPen, User, Avatar, Grid, Picture, Delete, Upload,
|
||||
ArrowUp, ArrowDown, TrophyBase, Odometer, DataAnalysis
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
type ElemType = 'text' | 'nickname' | 'avatar' | 'qrcode' | 'image' | 'mbti' | 'pdp' | 'disc'
|
||||
type Shape = 'circle' | 'square'
|
||||
type TextAlign = 'left' | 'center' | 'right'
|
||||
|
||||
interface PosterElement {
|
||||
id: string
|
||||
type: ElemType
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
content?: string
|
||||
fontSize?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
align?: TextAlign
|
||||
fontFamily?: string
|
||||
shape?: Shape
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface FontItem {
|
||||
key: string
|
||||
name: string
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
interface PosterConfig {
|
||||
bgColor: string
|
||||
bgImage: string
|
||||
elements: PosterElement[]
|
||||
}
|
||||
|
||||
// ────── 状态 ──────
|
||||
const saving = ref(false)
|
||||
const uploadingBg = ref(false)
|
||||
const uploadingImg = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const configReady = ref(false)
|
||||
const configVersion = ref(0)
|
||||
const fontList = ref<FontItem[]>([])
|
||||
const serverFontsLoaded = ref(false)
|
||||
|
||||
const bgInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imgInputRef = ref<HTMLInputElement | null>(null)
|
||||
const addImgInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const config = reactive<PosterConfig>({
|
||||
bgColor: '#ffffff',
|
||||
bgImage: '',
|
||||
elements: []
|
||||
})
|
||||
|
||||
const elements = computed(() => config.elements)
|
||||
|
||||
const selectedElement = computed(() =>
|
||||
selectedId.value ? config.elements.find(e => e.id === selectedId.value) ?? null : null
|
||||
)
|
||||
|
||||
// ────── 工具映射 ──────
|
||||
const typeIconMap: Record<ElemType, any> = {
|
||||
text: EditPen,
|
||||
nickname: User,
|
||||
avatar: Avatar,
|
||||
qrcode: Grid,
|
||||
image: Picture,
|
||||
mbti: TrophyBase,
|
||||
pdp: Odometer,
|
||||
disc: DataAnalysis
|
||||
}
|
||||
|
||||
const typeLabel = (el: PosterElement) => {
|
||||
const labels: Record<ElemType, string> = {
|
||||
text: `文字:${el.content?.slice(0, 6) || '文字内容'}`,
|
||||
nickname: '用户昵称',
|
||||
avatar: '用户头像',
|
||||
qrcode: '小程序码',
|
||||
image: '图片',
|
||||
mbti: `MBTI:${el.content || 'INTJ'}`,
|
||||
pdp: `PDP:${el.content || '猫头鹰'}`,
|
||||
disc: `DISC:${el.content || 'C型'}`
|
||||
}
|
||||
return labels[el.type]
|
||||
}
|
||||
|
||||
// ────── 画布样式 ──────
|
||||
const posterCanvasStyle = computed(() => ({
|
||||
width: '375px',
|
||||
height: '667px',
|
||||
backgroundColor: config.bgColor,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0
|
||||
}))
|
||||
|
||||
// ────── 字体 CSS 映射(用于画布预览近似渲染) ──────
|
||||
const fontCssMap: Record<string, string> = {
|
||||
'noto-sans': '"Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
'noto-serif': '"Noto Serif SC", "Source Han Serif SC", "STSong", "SimSun", serif',
|
||||
'alimama': '"AlimamaFangYuanTiVF", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
'wqy-microhei': '"WenQuanYi Micro Hei", "Noto Sans SC", "Microsoft YaHei", sans-serif',
|
||||
'simhei': '"SimHei", "Heiti SC", sans-serif',
|
||||
'msyh': '"Microsoft YaHei", "PingFang SC", sans-serif',
|
||||
}
|
||||
|
||||
const getFontCss = (fontKey?: string) => {
|
||||
if (!fontKey) return undefined
|
||||
return fontCssMap[fontKey] ?? undefined
|
||||
}
|
||||
|
||||
// ────── 元素样式 ──────
|
||||
const alignToJustify: Record<TextAlign, string> = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end'
|
||||
}
|
||||
|
||||
const textElemStyle = (el: PosterElement) => {
|
||||
const align = el.align ?? 'left'
|
||||
return {
|
||||
fontSize: `${el.fontSize ?? 16}px`,
|
||||
color: el.color ?? '#333333',
|
||||
fontWeight: el.bold ? 'bold' : 'normal',
|
||||
textAlign: align,
|
||||
justifyContent: alignToJustify[align],
|
||||
fontFamily: getFontCss(el.fontFamily),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 4px',
|
||||
boxSizing: 'border-box' as const,
|
||||
wordBreak: 'break-all' as const,
|
||||
lineHeight: 1.4,
|
||||
userSelect: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
const shapeElemStyle = (el: PosterElement) => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: el.shape === 'circle' ? '50%' : '4px',
|
||||
overflow: 'hidden'
|
||||
})
|
||||
|
||||
// ────── 添加元素 ──────
|
||||
let idCounter = 1
|
||||
const genId = () => `el_${Date.now()}_${idCounter++}`
|
||||
|
||||
const addElement = (type: ElemType) => {
|
||||
const defaults: Record<ElemType, Partial<PosterElement>> = {
|
||||
text: { w: 160, h: 40, content: '文字内容', fontSize: 18, color: '#333333', bold: false, align: 'left' },
|
||||
nickname: { w: 150, h: 36, fontSize: 16, color: '#333333', bold: false, align: 'left' },
|
||||
avatar: { w: 80, h: 80, shape: 'circle' },
|
||||
qrcode: { w: 120, h: 120 },
|
||||
image: { w: 100, h: 100, shape: 'square', url: '' },
|
||||
mbti: { w: 80, h: 32, content: 'INTJ', fontSize: 16, color: '#6366f1', bold: true, align: 'center' },
|
||||
pdp: { w: 80, h: 32, content: '猫头鹰', fontSize: 16, color: '#10b981', bold: true, align: 'center' },
|
||||
disc: { w: 80, h: 32, content: 'C型', fontSize: 16, color: '#f43f5e', bold: true, align: 'center' }
|
||||
}
|
||||
const el: PosterElement = {
|
||||
id: genId(),
|
||||
type,
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 100,
|
||||
h: 100,
|
||||
...defaults[type]
|
||||
}
|
||||
config.elements.push(el)
|
||||
selectedId.value = el.id
|
||||
}
|
||||
|
||||
// ────── 属性修改 ──────
|
||||
const onFontSizeChange = (val: number | number[] | undefined) => {
|
||||
const el = selectedElement.value
|
||||
const v = Array.isArray(val) ? val[0] : val
|
||||
if (el && typeof v === 'number' && v >= 10) {
|
||||
el.fontSize = v
|
||||
}
|
||||
}
|
||||
|
||||
// ────── 删除/移动图层 ──────
|
||||
const removeElement = (id: string) => {
|
||||
const idx = config.elements.findIndex(e => e.id === id)
|
||||
if (idx !== -1) config.elements.splice(idx, 1)
|
||||
if (selectedId.value === id) selectedId.value = null
|
||||
}
|
||||
|
||||
const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const arr = config.elements
|
||||
const idx = arr.findIndex(e => e.id === id)
|
||||
const newIdx = idx + dir
|
||||
if (newIdx < 0 || newIdx >= arr.length) return
|
||||
const tmp = arr[idx]
|
||||
arr[idx] = arr[newIdx]
|
||||
arr[newIdx] = tmp
|
||||
}
|
||||
|
||||
// ────── 上传工具(使用 admin 上传接口) ──────
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL
|
||||
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
|
||||
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
|
||||
: `${import.meta.env.VITE_API_BASE_URL}/api/v1`)
|
||||
: '/api/v1'
|
||||
const res = await axios.post(`${baseURL}/admin/upload/image`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
})
|
||||
if (res.data?.data?.url) return res.data.data.url
|
||||
throw new Error(res.data?.message || '上传失败')
|
||||
}
|
||||
|
||||
// 背景上传
|
||||
const triggerBgUpload = () => bgInputRef.value?.click()
|
||||
|
||||
const onBgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
uploadingBg.value = true
|
||||
try {
|
||||
config.bgImage = await uploadFile(file)
|
||||
ElMessage.success('背景图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingBg.value = false
|
||||
if (bgInputRef.value) bgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 图片元素上传(更换已有图片元素)
|
||||
const triggerElementImageUpload = () => imgInputRef.value?.click()
|
||||
|
||||
const onImgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file || !selectedElement.value) return
|
||||
uploadingImg.value = true
|
||||
try {
|
||||
selectedElement.value.url = await uploadFile(file)
|
||||
ElMessage.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingImg.value = false
|
||||
if (imgInputRef.value) imgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图片元素时先上传
|
||||
const triggerImageAdd = () => addImgInputRef.value?.click()
|
||||
|
||||
const onAddImgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
uploadingImg.value = true
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
const el: PosterElement = {
|
||||
id: genId(),
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 120,
|
||||
h: 120,
|
||||
shape: 'square',
|
||||
url
|
||||
}
|
||||
config.elements.push(el)
|
||||
selectedId.value = el.id
|
||||
ElMessage.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingImg.value = false
|
||||
if (addImgInputRef.value) addImgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ────── 读取 / 保存配置(使用 admin 接口) ──────
|
||||
const loadConfig = async () => {
|
||||
configReady.value = false
|
||||
selectedId.value = null
|
||||
try {
|
||||
const res = await request.get<any>('/admin/settings/poster')
|
||||
const data = res?.data?.poster
|
||||
if (data) {
|
||||
config.bgColor = data.bgColor ?? '#ffffff'
|
||||
config.bgImage = data.bgImage ?? ''
|
||||
config.elements = (data.elements ?? []).map((el: any) => {
|
||||
const align = el.align ?? (el.center ? 'center' : 'left')
|
||||
const { center: _center, ...rest } = el
|
||||
return {
|
||||
...rest,
|
||||
align,
|
||||
x: Number(el.x) || 0,
|
||||
y: Number(el.y) || 0,
|
||||
w: Math.max(20, Number(el.w) || 100),
|
||||
h: Math.max(20, Number(el.h) || 100),
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 未配置时静默失败
|
||||
}
|
||||
configVersion.value++
|
||||
await nextTick()
|
||||
configReady.value = true
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await request.put('/admin/settings/poster', {
|
||||
bgColor: config.bgColor,
|
||||
bgImage: config.bgImage,
|
||||
elements: config.elements
|
||||
})
|
||||
ElMessage.success('海报配置已保存')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFonts = async () => {
|
||||
try {
|
||||
const res = await request.get<any>('/admin/settings/fonts')
|
||||
const remote = res?.data?.fonts
|
||||
serverFontsLoaded.value = true
|
||||
if (Array.isArray(remote) && remote.length > 0) {
|
||||
fontList.value = remote
|
||||
}
|
||||
} catch {
|
||||
serverFontsLoaded.value = false
|
||||
fontList.value = [
|
||||
{ key: 'noto-sans', name: '思源黑体' },
|
||||
{ key: 'noto-serif', name: '思源宋体' },
|
||||
{ key: 'alimama', name: '阿里妈妈方圆体' },
|
||||
{ key: 'wqy-microhei', name: '文泉驿微米黑' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConfig(), loadFonts()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap');
|
||||
|
||||
.poster-editor-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主体三栏 */
|
||||
.editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 260px;
|
||||
gap: 12px;
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
/* 通用面板 */
|
||||
.editor-layers,
|
||||
.editor-props {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.delete-icon {
|
||||
cursor: pointer;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
&:hover { color: #dc2626; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 图层列表 */
|
||||
.layers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover { background: #f3f4f6; border-color: #d1d5db; }
|
||||
&.active { background: #f3e8ff; border-color: #a855f7; color: #7c3aed; }
|
||||
|
||||
.layer-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.layer-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.action-icon {
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
&:hover { background: #e5e7eb; }
|
||||
&.danger { color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .layer-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
.layers-empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 画布区域 */
|
||||
.editor-canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.canvas-scale-wrap {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.poster-canvas {
|
||||
position: relative;
|
||||
width: 375px;
|
||||
height: 667px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-bg-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.canvas-bg-click {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.canvas-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 元素内容 */
|
||||
.el-text,
|
||||
.el-nickname {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.el-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
border: 1.5px dashed rgba(124, 58, 237, 0.4);
|
||||
color: #7c3aed;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
.el-icon { font-size: 20px; }
|
||||
|
||||
&.el-qrcode {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
/* MBTI / PDP / DISC 测试结果占位 */
|
||||
.el-test-result {
|
||||
border: 1.5px dashed rgba(124, 58, 237, 0.3);
|
||||
border-radius: 4px;
|
||||
justify-content: center;
|
||||
|
||||
&.mbti-result { background: rgba(99, 102, 241, 0.06); }
|
||||
&.pdp-result { background: rgba(16, 185, 129, 0.06); border-color: rgba(16, 185, 129, 0.3); }
|
||||
&.disc-result { background: rgba(244, 63, 94, 0.06); border-color: rgba(244, 63, 94, 0.3); }
|
||||
}
|
||||
|
||||
/* vue3-draggable-resizable 激活样式覆盖 */
|
||||
:deep(.vdr-active) {
|
||||
outline: 2px solid #7c3aed !important;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
:deep(.vdr) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 字号行 */
|
||||
.font-size-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 属性面板 */
|
||||
.prop-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&.half {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.props-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 14px 0 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.props-tip {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.img-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
363
admin/src/views/admin/Pricing.vue
Normal file
363
admin/src/views/admin/Pricing.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>价格设置</h2>
|
||||
<p class="subtitle">分别配置个人版和企业版的测试价格</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', activeTab === tab.value ? 'active' : '']"
|
||||
@click="activeTab = tab.value"
|
||||
>{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-content">
|
||||
<div class="tab-content-card">
|
||||
|
||||
<!-- 个人版价格 -->
|
||||
<div v-if="activeTab === 'personal'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认定价,保存后将创建您的个人版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="savePersonal" :loading="loading">
|
||||
保存个人版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业版价格 -->
|
||||
<div v-if="activeTab === 'enterprise'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认企业定价,保存后将创建您的企业版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveEnterprise" :loading="loading">
|
||||
保存企业版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const tabs = [
|
||||
{ label: '个人版价格', value: 'personal' },
|
||||
{ label: '企业版价格', value: 'enterprise' },
|
||||
]
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
|
||||
const loading = ref(false)
|
||||
const isUsingSuperAdminPersonalConfig = ref(false)
|
||||
const isUsingSuperAdminEnterpriseConfig = ref(false)
|
||||
|
||||
const loadPricing = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/pricing')
|
||||
if (response.code === 200 && response.data) {
|
||||
if (response.data.personal) {
|
||||
Object.assign(personal, response.data.personal)
|
||||
}
|
||||
if (response.data.enterprise) {
|
||||
Object.assign(enterprise, response.data.enterprise)
|
||||
}
|
||||
isUsingSuperAdminPersonalConfig.value = response.data.isUsingSuperAdminPersonalConfig
|
||||
?? response.data.isUsingSuperAdminConfig
|
||||
?? false
|
||||
isUsingSuperAdminEnterpriseConfig.value = response.data.isUsingSuperAdminEnterpriseConfig ?? false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载定价配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const savePersonal = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', { personalConfig: personal })
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('个人版价格已保存')
|
||||
isUsingSuperAdminPersonalConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEnterprise = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', {
|
||||
enterpriseConfig: {
|
||||
face: enterprise.face,
|
||||
mbti: enterprise.mbti,
|
||||
disc: enterprise.disc,
|
||||
pdp: enterprise.pdp
|
||||
}
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('企业版价格已保存')
|
||||
isUsingSuperAdminEnterpriseConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPricing()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover { color: #111827; }
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.notice-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full { width: 100%; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content .form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1059
admin/src/views/admin/Questions.vue
Normal file
1059
admin/src/views/admin/Questions.vue
Normal file
File diff suppressed because it is too large
Load Diff
543
admin/src/views/admin/Settings.vue
Normal file
543
admin/src/views/admin/Settings.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理管理员账号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card" :class="{ 'no-pad': activeTab === 'poster' }">
|
||||
<!-- 小程序配置 -->
|
||||
<div v-if="activeTab === 'miniprogram'" class="tab-content" v-loading="miniprogramLoading">
|
||||
<div class="content-header">
|
||||
<h3>小程序配置</h3>
|
||||
<p class="content-description">配置小程序名称及展示文案,与超管共用全局配置,将显示在小程序导航栏等位置</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>小程序名称</label>
|
||||
<el-input
|
||||
v-model="miniprogramConfig.miniprogramName"
|
||||
placeholder="用于小程序导航栏等展示"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-config-section">
|
||||
<div class="section-label">小程序文案配置</div>
|
||||
<p class="section-desc">以下文案将显示在小程序对应位置,留空则使用默认值</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>分析中提示</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.analyzingTitle" placeholder="默认:正在分析中" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(个人版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:开始面相测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(企业版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonEnterprise" placeholder="默认:开始面部测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>报告页标题</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.reportTitle" placeholder="默认:分析报告" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>智能分析文案</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.aiAnalysisText" placeholder="默认:智能分析" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
|
||||
保存小程序配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 海报配置 -->
|
||||
<div v-if="activeTab === 'poster'" class="tab-content poster-tab">
|
||||
<PosterEditor />
|
||||
</div>
|
||||
|
||||
<!-- 账号设置 -->
|
||||
<div v-if="activeTab === 'account'" class="tab-content">
|
||||
<div class="content-header">
|
||||
<h3>管理员账号设置</h3>
|
||||
<p class="content-description">修改管理员账号和密码</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>管理员用户名</label>
|
||||
<el-input
|
||||
v-model="accountConfig.username"
|
||||
placeholder="输入管理员用户名"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label>当前密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.currentPassword"
|
||||
type="password"
|
||||
placeholder="输入当前密码(修改密码时必填)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.password"
|
||||
type="password"
|
||||
placeholder="输入新密码(不修改则留空)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>确认新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAccountSettings" :loading="loading">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>保存凭据</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
|
||||
const activeTab = ref('account')
|
||||
const loading = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ label: '账号设置', value: 'account' },
|
||||
{ label: '小程序配置', value: 'miniprogram' },
|
||||
{ label: '海报配置', value: 'poster' }
|
||||
]
|
||||
|
||||
// ── 小程序配置 ──
|
||||
const miniprogramLoading = ref(false)
|
||||
const miniprogramConfig = reactive({
|
||||
miniprogramName: '神仙团队AI性格测试',
|
||||
textConfig: {
|
||||
analyzingTitle: '正在分析中',
|
||||
startButtonText: '开始面相测试',
|
||||
startButtonEnterprise: '开始面部测试',
|
||||
reportTitle: '分析报告',
|
||||
aiAnalysisText: '智能分析'
|
||||
}
|
||||
})
|
||||
|
||||
const loadMiniprogramConfig = async () => {
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/settings/miniprogram')
|
||||
if (res.code === 200 && res.data) {
|
||||
miniprogramConfig.miniprogramName = res.data.miniprogramName ?? '神仙团队AI性格测试'
|
||||
if (res.data.textConfig && typeof res.data.textConfig === 'object') {
|
||||
Object.assign(miniprogramConfig.textConfig, res.data.textConfig)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载小程序配置失败:', e)
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveMiniprogramConfig = async () => {
|
||||
if (!miniprogramConfig.miniprogramName?.trim()) {
|
||||
ElMessage.error('小程序名称不能为空')
|
||||
return
|
||||
}
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/settings/miniprogram', {
|
||||
miniprogramName: miniprogramConfig.miniprogramName.trim(),
|
||||
textConfig: miniprogramConfig.textConfig
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('小程序配置已保存')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 账号配置
|
||||
const accountConfig = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 加载当前用户信息
|
||||
const loadSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/settings')
|
||||
if (response.code === 200 && response.data) {
|
||||
accountConfig.username = response.data.username || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载设置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存账号设置
|
||||
const saveAccountSettings = async () => {
|
||||
if (!accountConfig.username) {
|
||||
ElMessage.error('用户名不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (accountConfig.password && accountConfig.password !== accountConfig.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/settings/credentials', {
|
||||
username: accountConfig.username,
|
||||
currentPassword: accountConfig.currentPassword,
|
||||
newPassword: accountConfig.password,
|
||||
confirmPassword: accountConfig.confirmPassword
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('账号设置已保存')
|
||||
accountConfig.password = ''
|
||||
accountConfig.currentPassword = ''
|
||||
accountConfig.confirmPassword = ''
|
||||
await loadSettings()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'miniprogram') loadMiniprogramConfig()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.no-pad {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-tab {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.content-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.content-description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.text-config-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.section-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.notice-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1530
admin/src/views/admin/Users.vue
Normal file
1530
admin/src/views/admin/Users.vue
Normal file
File diff suppressed because it is too large
Load Diff
1596
admin/src/views/superadmin/AIConfig.vue
Normal file
1596
admin/src/views/superadmin/AIConfig.vue
Normal file
File diff suppressed because it is too large
Load Diff
483
admin/src/views/superadmin/Database.vue
Normal file
483
admin/src/views/superadmin/Database.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>数据库管理</h2>
|
||||
<p class="subtitle">管理数据库连接、备份和恢复</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" color="#ef4444" @click="handleBackup">
|
||||
<el-icon class="mr-1"><DocumentCopy /></el-icon>备份数据库
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库信息 -->
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库类型</div>
|
||||
<div class="info-value">{{ dbInfo.databaseType }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">连接状态</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="dbInfo.connected ? 'success' : 'danger'" size="small">
|
||||
{{ dbInfo.connected ? '已连接' : '未连接' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库大小</div>
|
||||
<div class="info-value">{{ formatSize(dbInfo.databaseSize * 1024 * 1024) }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">表数量</div>
|
||||
<div class="info-value">{{ dbInfo.tableCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 集合列表 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>数据库集合</h3>
|
||||
<el-input
|
||||
v-model="searchTerm"
|
||||
placeholder="搜索集合名称..."
|
||||
clearable
|
||||
class="search-input"
|
||||
style="max-width: 300px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="filteredCollections"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
class="custom-table"
|
||||
v-if="filteredCollections.length > 0 && !loading"
|
||||
>
|
||||
<el-table-column label="集合名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="collection-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文档数量" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="doc-count">{{ row.docCount.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="索引数" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="index-count">{{ row.indexCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
|
||||
<el-button link @click="handleExport(row)"><el-icon><Download /></el-icon></el-button>
|
||||
<el-button link type="danger" @click="handleClear(row)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 空数据占位图 -->
|
||||
<div v-else-if="!loading" class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><DocumentCopy /></el-icon>
|
||||
<p class="empty-text">暂无数据表</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备份记录 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>备份记录</h3>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="backups"
|
||||
style="width: 100%"
|
||||
class="custom-table"
|
||||
v-if="backups.length > 0"
|
||||
>
|
||||
<el-table-column label="备份时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-time">{{ formatTime(row.time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备份大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleRestore(row)"><el-icon><RefreshLeft /></el-icon>恢复</el-button>
|
||||
<el-button link @click="handleDownload(row)"><el-icon><Download /></el-icon>下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { DocumentCopy, Search, View, Download, Delete, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const dbInfo = ref({
|
||||
databaseType: 'MySQL',
|
||||
databaseName: '',
|
||||
connected: false,
|
||||
databaseSize: 0,
|
||||
tableCount: 0
|
||||
})
|
||||
|
||||
const collections = ref([])
|
||||
const backups = ref([])
|
||||
|
||||
const filteredCollections = computed(() => {
|
||||
if (!searchTerm.value) {
|
||||
return collections.value
|
||||
}
|
||||
return collections.value.filter((item: any) =>
|
||||
item.name.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 加载数据库信息
|
||||
const loadDbInfo = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/info')
|
||||
if (response.code === 200 && response.data) {
|
||||
dbInfo.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据库信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载表列表
|
||||
const loadTables = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/tables')
|
||||
if (response.code === 200 && response.data) {
|
||||
collections.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载表列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载备份记录
|
||||
const loadBackups = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/backups')
|
||||
if (response.code === 200 && response.data) {
|
||||
backups.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载备份记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要备份数据库吗?', '备份数据库', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/backup')
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库备份成功')
|
||||
await loadBackups()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '备份失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async (row: any) => {
|
||||
ElMessage.info('查看表: ' + row.name + ' (功能开发中)')
|
||||
}
|
||||
|
||||
const handleExport = async (row: any) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/export-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 下载文件
|
||||
window.open(response.data.downloadUrl, '_blank')
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '导出失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空表 "${row.name}" 吗?此操作不可恢复!`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/clear-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('表数据已清空')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '清空失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要恢复此备份吗?当前数据将被覆盖!', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/restore', {
|
||||
file: row.filename
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库恢复成功')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '恢复失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (row: any) => {
|
||||
window.open(`/api/v1/superadmin/database/download?file=${encodeURIComponent(row.filename)}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDbInfo()
|
||||
loadTables()
|
||||
loadBackups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name, .doc-count, .size, .index-count, .backup-time, .backup-size {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.el-button {
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.el-button--danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
694
admin/src/views/superadmin/Distribution.vue
Normal file
694
admin/src/views/superadmin/Distribution.vue
Normal file
@@ -0,0 +1,694 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>分销管理</h2>
|
||||
<p class="subtitle">全平台分销商、佣金、提现审核与分销设置</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh" class="refresh-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- 数据概览 -->
|
||||
<div v-if="activeTab === 'overview'" class="overview-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">累计佣金(全平台)</div>
|
||||
<div class="stat-value">¥{{ overview.totalCommission }}</div>
|
||||
</div>
|
||||
<div class="stat-icon purple"><el-icon><Money /></el-icon></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已结算佣金</div>
|
||||
<div class="stat-value">¥{{ overview.paidCommission }}</div>
|
||||
</div>
|
||||
<div class="stat-icon green"><el-icon><Money /></el-icon></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">待审核提现</div>
|
||||
<div class="stat-value">¥{{ overview.pendingWithdraw }}</div>
|
||||
</div>
|
||||
<div class="stat-icon orange"><el-icon><Clock /></el-icon></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">有效绑定人数</div>
|
||||
<div class="stat-value">{{ overview.bindingCount }}</div>
|
||||
</div>
|
||||
<div class="stat-icon blue"><el-icon><User /></el-icon></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-cols">
|
||||
<div class="content-card small-card">
|
||||
<div class="card-title">佣金来源分布</div>
|
||||
<div class="dist-row">
|
||||
<span>个人版</span><strong>¥{{ overview.personalCommission }}</strong>
|
||||
</div>
|
||||
<div class="dist-row">
|
||||
<span>企业版</span><strong>¥{{ overview.enterpriseCommission }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-card small-card">
|
||||
<div class="card-title">今日数据</div>
|
||||
<div class="dist-row">
|
||||
<span>今日结算佣金</span><strong>¥{{ overview.todayCommission }}</strong>
|
||||
</div>
|
||||
<div class="dist-row">
|
||||
<span>全部订单数</span><strong>{{ overview.totalOrders }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提现管理 -->
|
||||
<div v-else-if="activeTab === 'withdrawals'" class="table-section">
|
||||
<div class="content-card">
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<div
|
||||
v-for="item in withOptions"
|
||||
:key="item.value"
|
||||
:class="['filter-item', { active: withFilter === item.value }]"
|
||||
@click="withFilter = item.value"
|
||||
>{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="withdrawals" style="width:100%" class="custom-table" v-loading="loading">
|
||||
<el-table-column label="用户" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="agent-cell">
|
||||
<el-avatar :size="32" :src="row.avatar">{{ row.nickname ? row.nickname[0] : '?' }}</el-avatar>
|
||||
<span class="agent-name">{{ row.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请金额" align="right">
|
||||
<template #default="{ row }">¥{{ row.amountYuan }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" />
|
||||
<el-table-column label="申请时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="160">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.status) === 0">
|
||||
<el-button size="small" type="success" @click="approve(row)">通过</el-button>
|
||||
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#9ca3af;font-size:12px">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="withdrawals.length === 0 && !loading" class="empty-placeholder">暂无提现申请</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 佣金记录 -->
|
||||
<div v-else-if="activeTab === 'commissions'" class="table-section">
|
||||
<div class="content-card">
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<div
|
||||
v-for="item in commOptions"
|
||||
:key="item.value"
|
||||
:class="['filter-item', { active: commFilter === item.value }]"
|
||||
@click="commFilter = item.value"
|
||||
>{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="commissions" style="width:100%" class="custom-table" v-loading="loading">
|
||||
<el-table-column label="推荐人" prop="inviterName" />
|
||||
<el-table-column label="购买者" prop="inviteeName" />
|
||||
<el-table-column label="企业" prop="enterpriseName" />
|
||||
<el-table-column label="订单金额" align="right">
|
||||
<template #default="{ row }">¥{{ row.orderYuan }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="比例" align="center">
|
||||
<template #default="{ row }">{{ row.commissionRate || 0 }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="佣金" align="right">
|
||||
<template #default="{ row }">¥{{ row.commissionYuan }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'pending' ? 'warning' : 'info'" size="small">
|
||||
{{ row.status === 'paid' ? '已结算' : row.status === 'pending' ? '待结算' : '已取消' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="commissions.length === 0 && !loading" class="empty-placeholder">暂无佣金记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分销设置 -->
|
||||
<div v-else-if="activeTab === 'settings'" class="settings-section">
|
||||
<div class="settings-grid">
|
||||
<div class="settings-card">
|
||||
<div class="card-header">基本设置</div>
|
||||
<div class="setting-list">
|
||||
<div class="setting-row">
|
||||
<div class="info">
|
||||
<p class="title">启用分销功能(个人版全局)</p>
|
||||
<p class="desc">关闭后新订单不再产生佣金</p>
|
||||
</div>
|
||||
<el-switch v-model="distEnabled" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="info">
|
||||
<p class="title">推广中心标题</p>
|
||||
<p class="desc">小程序个人中心推广卡片的显示文字</p>
|
||||
</div>
|
||||
<el-input v-model="promoCenterTitle" placeholder="推广中心" maxlength="20" show-word-limit class="promo-title-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="card-header">提现设置</div>
|
||||
<div class="setting-list" style="margin-bottom:16px">
|
||||
<div class="setting-row">
|
||||
<div class="info">
|
||||
<p class="title">开启提现审核</p>
|
||||
<p class="desc">开启后每笔提现申请需管理员人工审核才可打款</p>
|
||||
</div>
|
||||
<el-switch v-model="requireAudit" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>最低提现金额 (元)</label>
|
||||
<el-input-number v-model="minWithdraw" :min="1" :max="200" :precision="2" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>最高提现金额 (元)</label>
|
||||
<el-input-number v-model="maxWithdraw" :min="0" :max="200" :precision="2" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>提现手续费 (%)</label>
|
||||
<el-input-number v-model="withdrawFee" :min="0" :max="100" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试佣金配置 -->
|
||||
<div class="settings-card full-width-card">
|
||||
<div class="card-header">测试佣金配置</div>
|
||||
<p class="card-desc">为每种测试类型独立设置佣金比例或固定金额,并可开启「无需付款」让用户完成测试即触发佣金。</p>
|
||||
<div class="ts-grid">
|
||||
<div v-for="item in testTypeItems" :key="item.key" class="ts-card">
|
||||
<div class="ts-head">
|
||||
<span class="ts-name">{{ item.label }}</span>
|
||||
<el-switch v-model="testSettings[item.key].enabled" size="small" />
|
||||
</div>
|
||||
<template v-if="testSettings[item.key].enabled">
|
||||
<div class="ts-row">
|
||||
<label>佣金类型</label>
|
||||
<el-radio-group v-model="testSettings[item.key].commissionType" size="small">
|
||||
<el-radio-button value="ratio">比例</el-radio-button>
|
||||
<el-radio-button value="amount">固定金额</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="ts-row">
|
||||
<label>{{ testSettings[item.key].commissionType === 'ratio' ? '佣金比例 (%)' : '固定金额 (元)' }}</label>
|
||||
<el-input-number
|
||||
v-if="testSettings[item.key].commissionType === 'ratio'"
|
||||
v-model="testSettings[item.key].commissionRate"
|
||||
:min="0" :max="100" class="w-full"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="testSettings[item.key].commissionAmount"
|
||||
:min="0" :precision="2" :step="0.1" class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="ts-nopay">
|
||||
<div class="ts-nopay-info">
|
||||
<p class="ts-nopay-title">无需付款触发</p>
|
||||
<p class="ts-nopay-desc">用户完成测试即发放佣金,无需付款</p>
|
||||
</div>
|
||||
<el-switch v-model="testSettings[item.key].noPayment" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="ts-disabled">已关闭,该类型不产生佣金</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<el-dialog v-model="rejectVisible" title="拒绝提现" width="400px" destroy-on-close>
|
||||
<el-input v-model="rejectNote" type="textarea" :rows="3" placeholder="请输入拒绝理由(可选)" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmReject" :loading="loading">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { Refresh, User, Money, Clock } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('overview')
|
||||
const loading = ref(false)
|
||||
const commFilter = ref('')
|
||||
const withFilter = ref('')
|
||||
|
||||
const tabs = [
|
||||
{ label: '数据概览', value: 'overview' },
|
||||
{ label: '提现管理', value: 'withdrawals' },
|
||||
{ label: '佣金记录', value: 'commissions' },
|
||||
{ label: '分销设置', value: 'settings' },
|
||||
]
|
||||
|
||||
const commOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待结算', value: 'pending' },
|
||||
{ label: '已结算', value: 'paid' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
]
|
||||
|
||||
const withOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
]
|
||||
|
||||
const overview = reactive({
|
||||
totalCommission: '0.00',
|
||||
paidCommission: '0.00',
|
||||
frozenCommission: '0.00',
|
||||
personalCommission: '0.00',
|
||||
enterpriseCommission: '0.00',
|
||||
totalOrders: 0,
|
||||
bindingCount: 0,
|
||||
pendingWithdraw: '0.00',
|
||||
todayCommission: '0.00',
|
||||
})
|
||||
|
||||
const withdrawals = ref<any[]>([])
|
||||
const commissions = ref<any[]>([])
|
||||
|
||||
const distEnabled = ref(true)
|
||||
const promoCenterTitle = ref('推广中心')
|
||||
const minWithdraw = ref(1)
|
||||
const maxWithdraw = ref(0)
|
||||
const requireAudit = ref(true)
|
||||
const withdrawFee = ref(0)
|
||||
|
||||
const testTypeItems = [
|
||||
{ key: 'face', label: '人脸分析' },
|
||||
{ key: 'mbti', label: 'MBTI 测试' },
|
||||
{ key: 'disc', label: 'DISC 测试' },
|
||||
{ key: 'pdp', label: 'PDP 测试' },
|
||||
]
|
||||
type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean }
|
||||
const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false })
|
||||
const testSettings = reactive<Record<string, TestSetting>>({
|
||||
face: makeDefaultTs(),
|
||||
mbti: makeDefaultTs(),
|
||||
disc: makeDefaultTs(),
|
||||
pdp: makeDefaultTs(),
|
||||
})
|
||||
|
||||
// 拒绝弹窗
|
||||
const rejectVisible = ref(false)
|
||||
const rejectNote = ref('')
|
||||
const rejectTarget = ref<any>(null)
|
||||
|
||||
const fmtTime = (ts: number) => ts ? new Date(ts * 1000).toLocaleString() : '-'
|
||||
// 提现状态:0审核中、1已驳回、2待收款、3已收款、4已过期
|
||||
const statusLabel = (s: any) => {
|
||||
const code = Number(s)
|
||||
switch (code) {
|
||||
case 0: return '审核中'
|
||||
case 1: return '已驳回'
|
||||
case 2: return '待收款'
|
||||
case 3: return '已收款'
|
||||
case 4: return '已过期'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
const statusTagType = (s: any) => {
|
||||
const code = Number(s)
|
||||
switch (code) {
|
||||
case 0: return 'warning' // 审核中
|
||||
case 1: return 'danger' // 已驳回
|
||||
case 2: return 'info' // 待收款
|
||||
case 3: return 'success' // 已收款
|
||||
case 4: return 'info' // 已过期
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据概览
|
||||
const loadOverview = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/distribution/overview')
|
||||
if (res.code === 200 && res.data) Object.assign(overview, res.data)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 提现列表
|
||||
const loadWithdrawals = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/distribution/withdrawals', {
|
||||
params: { status: withFilter.value, pageSize: 100 }
|
||||
})
|
||||
if (res.code === 200 && res.data) withdrawals.value = res.data.list || []
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 审核通过
|
||||
const approve = async (row: any) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/distribution/withdrawals/${row.id}/approve`)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已通过')
|
||||
loadWithdrawals()
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 打开拒绝弹窗
|
||||
const openReject = (row: any) => {
|
||||
rejectTarget.value = row
|
||||
rejectNote.value = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
// ── 确认拒绝
|
||||
const confirmReject = async () => {
|
||||
if (!rejectTarget.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.post(
|
||||
`/superadmin/distribution/withdrawals/${rejectTarget.value.id}/reject`,
|
||||
{ note: rejectNote.value }
|
||||
)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
loadWithdrawals()
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 佣金记录
|
||||
const loadCommissions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/distribution/commissions', {
|
||||
params: { status: commFilter.value, pageSize: 100 }
|
||||
})
|
||||
if (res.code === 200 && res.data) commissions.value = res.data.list || []
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 加载设置
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/distribution/settings')
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data
|
||||
distEnabled.value = d.enabled ?? true
|
||||
promoCenterTitle.value = d.promoCenterTitle ?? '推广中心'
|
||||
minWithdraw.value = d.minWithdraw ?? 1
|
||||
maxWithdraw.value = d.maxWithdraw ?? 0
|
||||
requireAudit.value = d.requireAudit !== false
|
||||
withdrawFee.value = d.withdrawFee ?? 0
|
||||
const ts = d.testSettings ?? {}
|
||||
testTypeItems.forEach(({ key }) => {
|
||||
const s = ts[key] ?? {}
|
||||
testSettings[key] = {
|
||||
enabled: s.enabled !== false,
|
||||
commissionType: s.commissionType ?? 'ratio',
|
||||
commissionRate: s.commissionRate ?? 90,
|
||||
commissionAmount: s.commissionAmount ?? 0,
|
||||
noPayment: s.noPayment ?? false,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 保存设置
|
||||
const saveSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/superadmin/distribution/settings', {
|
||||
enabled: distEnabled.value,
|
||||
promoCenterTitle: promoCenterTitle.value || '推广中心',
|
||||
minWithdraw: minWithdraw.value,
|
||||
maxWithdraw: maxWithdraw.value,
|
||||
requireAudit: requireAudit.value,
|
||||
withdrawFee: withdrawFee.value,
|
||||
testSettings: Object.fromEntries(
|
||||
testTypeItems.map(({ key }) => [key, testSettings[key]])
|
||||
),
|
||||
})
|
||||
if (res.code === 200) ElMessage.success('配置已保存')
|
||||
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'overview') loadOverview()
|
||||
else if (t === 'withdrawals') loadWithdrawals()
|
||||
else if (t === 'commissions') loadCommissions()
|
||||
else if (t === 'settings') loadSettings()
|
||||
})
|
||||
|
||||
watch(withFilter, () => { if (activeTab.value === 'withdrawals') loadWithdrawals() })
|
||||
watch(commFilter, () => { if (activeTab.value === 'commissions') loadCommissions() })
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'overview') await loadOverview()
|
||||
else if (activeTab.value === 'withdrawals') await loadWithdrawals()
|
||||
else if (activeTab.value === 'commissions') await loadCommissions()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
onMounted(() => { loadOverview() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
.header-left {
|
||||
h2 { font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 4px 0; }
|
||||
.subtitle { font-size: 13px; color: #6b7280; margin: 0; }
|
||||
}
|
||||
.refresh-btn {
|
||||
border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px;
|
||||
font-size: 13px; color: #374151; height: 34px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
&:hover { background-color: #f9fafb; }
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6; padding: 4px;
|
||||
border-radius: 8px; display: flex; margin-bottom: 24px; width: 100%;
|
||||
.custom-tabs {
|
||||
display: flex; gap: 4px; width: 100%;
|
||||
.tab-item {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 8px 0; font-size: 13px; color: #6b7280;
|
||||
cursor: pointer; border-radius: 6px; transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
&:hover { color: #111827; }
|
||||
&.active { background: #fff; color: #111827; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff; border-radius: 10px; padding: 20px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
border: 1px solid #f3f4f6; box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
.stat-label { font-size: 13px; color: #6b7280; margin-bottom: 6px; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #111827; }
|
||||
.stat-icon {
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
&.purple { background: #ede9fe; color: #7c3aed; }
|
||||
&.green { background: #d1fae5; color: #059669; }
|
||||
&.orange { background: #fef3c7; color: #d97706; }
|
||||
&.blue { background: #dbeafe; color: #2563eb; }
|
||||
}
|
||||
}
|
||||
.two-cols {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||
}
|
||||
.small-card {
|
||||
padding: 20px;
|
||||
.card-title { font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 16px; }
|
||||
.dist-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0; border-bottom: 1px solid #f3f4f6;
|
||||
span { font-size: 13px; color: #6b7280; }
|
||||
strong { font-size: 15px; font-weight: 700; color: #111827; }
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-section { .content-card { overflow: hidden; } }
|
||||
|
||||
.content-card {
|
||||
background: #fff; border-radius: 10px;
|
||||
border: 1px solid #f3f4f6; box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 16px 20px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
.filter-group {
|
||||
display: flex; background: #f3f4f6; padding: 3px; border-radius: 6px; gap: 2px;
|
||||
.filter-item {
|
||||
padding: 4px 12px; font-size: 12px; color: #6b7280;
|
||||
cursor: pointer; border-radius: 4px; transition: all .2s;
|
||||
&.active { background: #7c3aed; color: #fff; font-weight: 500; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
:deep(.el-table__header) th {
|
||||
background: #f9fafb; color: #6b7280; font-weight: 500; font-size: 13px; padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-cell {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
.agent-name { font-size: 13px; font-weight: 500; color: #111827; }
|
||||
}
|
||||
|
||||
.empty-placeholder { padding: 60px; text-align: center; color: #9ca3af; font-size: 14px; }
|
||||
|
||||
/* 设置区 */
|
||||
.settings-section { }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||
.settings-card {
|
||||
background: #fff; border-radius: 10px; border: 1px solid #f3f4f6; padding: 24px;
|
||||
.card-header { font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 20px; }
|
||||
.card-header-row {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;
|
||||
.card-header { margin-bottom: 0; }
|
||||
}
|
||||
.card-desc { font-size: 12px; color: #6b7280; margin: 0 0 20px; }
|
||||
&.full-width-card { grid-column: 1 / -1; }
|
||||
.setting-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ts-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
|
||||
.ts-card {
|
||||
background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
.ts-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
.ts-name { font-size: 13px; font-weight: 600; color: #374151; }
|
||||
}
|
||||
.ts-row {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
label { font-size: 12px; color: #6b7280; }
|
||||
}
|
||||
.ts-nopay {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 12px; background: #ede9fe; border-radius: 6px; margin-top: 4px;
|
||||
.ts-nopay-info {
|
||||
.ts-nopay-title { font-size: 12px; font-weight: 600; color: #5b21b6; margin: 0; }
|
||||
.ts-nopay-desc { font-size: 11px; color: #7c3aed; margin: 2px 0 0; }
|
||||
}
|
||||
}
|
||||
.ts-disabled { font-size: 12px; color: #9ca3af; text-align: center; padding: 16px 0; }
|
||||
}
|
||||
}
|
||||
.setting-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px; background: #f9fafb; border-radius: 8px;
|
||||
.info {
|
||||
.title { font-size: 14px; font-weight: 600; color: #111827; margin: 0; }
|
||||
.desc { font-size: 12px; color: #6b7280; margin: 4px 0 0; }
|
||||
}
|
||||
.promo-title-input { width: 180px; flex-shrink: 0; }
|
||||
}
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px;
|
||||
.form-item {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
label { font-size: 13px; font-weight: 500; color: #374151; }
|
||||
}
|
||||
.form-hint { font-size: 12px; color: #9ca3af; margin: 8px 0 0; }
|
||||
}
|
||||
}
|
||||
.save-actions {
|
||||
margin-top: 24px;
|
||||
.save-btn { height: 42px; padding: 0 40px; border-radius: 8px; font-weight: 600; }
|
||||
}
|
||||
.w-full { width: 100%; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.overview-section .stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.overview-section .two-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
1215
admin/src/views/superadmin/Enterprises.vue
Normal file
1215
admin/src/views/superadmin/Enterprises.vue
Normal file
File diff suppressed because it is too large
Load Diff
933
admin/src/views/superadmin/Finance.vue
Normal file
933
admin/src/views/superadmin/Finance.vue
Normal file
@@ -0,0 +1,933 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>财务管理</h2>
|
||||
<p class="subtitle">收入、成本及利润分析</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button variant="outline" size="small" @click="exportData">
|
||||
<el-icon class="mr-1"><Download /></el-icon>导出报表
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无已支付订单时的说明 -->
|
||||
<el-alert
|
||||
v-if="!overviewLoading && financialOverview.paidOrderCount === 0"
|
||||
type="info"
|
||||
show-icon
|
||||
class="zero-hint"
|
||||
title="当前暂无已支付订单,因此收入/成本/利润均为 0。"
|
||||
>
|
||||
<template #default>
|
||||
请确认:1)小程序用户是否已完成支付;2)支付成功后是否调用了「支付通知」接口(/api/payment/notify)以将订单状态更新为「已支付」。只有状态为「已支付」或「已完成」的订单才会计入收入。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 核心财务指标(金额:元,接口返回分需 /100) -->
|
||||
<div class="stats-grid" v-loading="overviewLoading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总收入</div>
|
||||
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalRevenue)) }}</div>
|
||||
<div class="stat-trend">
|
||||
<el-icon class="trend-icon"><ArrowUp /></el-icon>
|
||||
<span class="trend-text positive">本月 {{ formatMoneyYuan(fenToYuan(financialOverview.monthRevenue)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总成本(估算)</div>
|
||||
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalCost)) }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">本月 {{ formatMoneyYuan(fenToYuan(financialOverview.monthCost)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">净利润</div>
|
||||
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.netProfit)) }}</div>
|
||||
<div class="stat-trend">
|
||||
<el-icon v-if="financialOverview.monthGrowth >= 0" class="trend-icon"><ArrowUp /></el-icon>
|
||||
<el-icon v-else class="trend-icon down"><Bottom /></el-icon>
|
||||
<span :class="['trend-text', financialOverview.monthGrowth >= 0 ? 'positive' : 'negative']">
|
||||
{{ financialOverview.monthGrowth }}% 环比
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已支付订单数</div>
|
||||
<div class="stat-value">{{ financialOverview.paidOrderCount }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">笔</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">利润率</div>
|
||||
<div class="stat-value">{{ financialOverview.profitRate }}%</div>
|
||||
<div class="profit-bar-container">
|
||||
<div class="profit-bar-bg">
|
||||
<div class="profit-bar-fill" :style="{ width: `${financialOverview.profitRate}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收入和成本明细 Tabs -->
|
||||
<div class="finance-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card">
|
||||
<!-- 收入明细 -->
|
||||
<div v-if="activeTab === 'revenue'" class="tab-content">
|
||||
<div class="detail-card">
|
||||
<div class="card-title">收入构成</div>
|
||||
<div class="detail-list">
|
||||
<div v-for="(item, index) in revenueDetails" :key="index" class="detail-item">
|
||||
<div class="detail-content">
|
||||
<div class="detail-header">
|
||||
<span class="detail-name">{{ item.type }}</span>
|
||||
<div class="detail-amounts">
|
||||
<span class="detail-amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
|
||||
<span class="detail-percent">{{ item.percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
:class="['progress-bar-fill', `color-${index}`]"
|
||||
:style="{ width: `${item.percent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总计 -->
|
||||
<div class="detail-total">
|
||||
<span class="total-label">总收入</span>
|
||||
<span class="total-value">
|
||||
{{ formatMoneyYuan(fenToYuan(revenueDetails.reduce((s: number, i: any) => s + i.amount, 0))) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成本明细 -->
|
||||
<div v-if="activeTab === 'cost'" class="tab-content">
|
||||
<div class="detail-card">
|
||||
<div class="card-title">成本构成(估算)</div>
|
||||
<div class="detail-list">
|
||||
<div v-for="(item, index) in costDetails" :key="index" class="detail-item">
|
||||
<div class="detail-content">
|
||||
<div class="detail-header">
|
||||
<span class="detail-name">{{ item.type }}</span>
|
||||
<div class="detail-amounts">
|
||||
<span class="detail-amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
|
||||
<span class="detail-percent">{{ item.percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill color-cost"
|
||||
:style="{ width: `${item.percent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总计 -->
|
||||
<div class="detail-total">
|
||||
<span class="total-label">总成本(估算)</span>
|
||||
<span class="total-value">
|
||||
{{ formatMoneyYuan(fenToYuan(costDetails.reduce((s: number, i: any) => s + i.amount, 0))) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 利润分析卡片 -->
|
||||
<div class="profit-analysis">
|
||||
<div class="profit-card">
|
||||
<p class="profit-label">总收入</p>
|
||||
<p class="profit-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalRevenue)) }}</p>
|
||||
</div>
|
||||
<div class="profit-card">
|
||||
<p class="profit-label">总成本</p>
|
||||
<p class="profit-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalCost)) }}</p>
|
||||
</div>
|
||||
<div class="profit-card highlight">
|
||||
<p class="profit-label highlight-text">净利润</p>
|
||||
<p class="profit-value highlight-value">{{ formatMoneyYuan(fenToYuan(financialOverview.netProfit)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业支付记录 -->
|
||||
<div v-if="activeTab === 'records'" class="tab-content">
|
||||
<div class="detail-card">
|
||||
<div class="card-title">企业支付记录</div>
|
||||
<div class="table-container" v-if="rechargeRecords.length > 0">
|
||||
<table class="recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">订单号</th>
|
||||
<th class="text-left">企业名称</th>
|
||||
<th class="text-right">金额</th>
|
||||
<th class="text-left">支付方式</th>
|
||||
<th class="text-left">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(record, index) in rechargeRecords" :key="index">
|
||||
<td><span class="order-no">{{ record.orderNo || '—' }}</span></td>
|
||||
<td>
|
||||
<div class="enterprise-cell">
|
||||
<el-icon class="enterprise-icon"><OfficeBuilding /></el-icon>
|
||||
<span class="enterprise-name">{{ record.enterprise }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<span class="amount-positive">+{{ formatMoneyYuan(fenToYuan(record.amount)) }}</span>
|
||||
</td>
|
||||
<td><span class="method-text">{{ record.method }}</span></td>
|
||||
<td><span class="date-text">{{ record.date }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">暂无企业支付记录</p>
|
||||
</div>
|
||||
<div class="pagination-wrap" v-if="rechargeTotal > rechargePageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="rechargePage"
|
||||
:page-size="rechargePageSize"
|
||||
:total="rechargeTotal"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadRechargeRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支付记录(全部已支付订单) -->
|
||||
<div v-if="activeTab === 'payments'" class="tab-content">
|
||||
<div class="detail-card">
|
||||
<div class="card-title">支付记录</div>
|
||||
<div class="toolbar-row">
|
||||
<el-input
|
||||
v-model="paymentKeyword"
|
||||
placeholder="订单号/用户ID"
|
||||
clearable
|
||||
class="search-input"
|
||||
@keyup.enter="loadPaymentRecords"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="loadPaymentRecords">搜索</el-button>
|
||||
</div>
|
||||
<div class="table-container" v-if="paymentRecords.length > 0">
|
||||
<table class="recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">订单号</th>
|
||||
<th class="text-left">用户</th>
|
||||
<th class="text-left">企业</th>
|
||||
<th class="text-left">产品</th>
|
||||
<th class="text-right">金额</th>
|
||||
<th class="text-left">支付方式</th>
|
||||
<th class="text-left">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(record, index) in paymentRecords" :key="index">
|
||||
<td><span class="order-no">{{ record.orderNo }}</span></td>
|
||||
<td><span class="user-name">{{ record.userName }}</span></td>
|
||||
<td>
|
||||
<div class="enterprise-cell">
|
||||
<el-icon v-if="record.enterprise && record.enterprise !== '个人'" class="enterprise-icon"><OfficeBuilding /></el-icon>
|
||||
<span class="enterprise-name">{{ record.enterprise || '个人' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="product-type">{{ record.productType }}</span></td>
|
||||
<td class="text-right">
|
||||
<span class="amount-positive">+{{ formatMoneyYuan(fenToYuan(record.amount)) }}</span>
|
||||
</td>
|
||||
<td><span class="method-text">{{ record.method }}</span></td>
|
||||
<td><span class="date-text">{{ record.date }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">暂无支付记录</p>
|
||||
</div>
|
||||
<div class="pagination-wrap" v-if="paymentTotal > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="paymentPage"
|
||||
:page-size="paymentPageSize"
|
||||
:total="paymentTotal"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadPaymentRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { Download, ArrowUp, Bottom, OfficeBuilding, Money, TrendCharts, Document, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { formatMoneyYuan } from '@/utils/format'
|
||||
|
||||
const activeTab = ref('revenue')
|
||||
const overviewLoading = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ label: '收入明细', value: 'revenue' },
|
||||
{ label: '成本明细', value: 'cost' },
|
||||
{ label: '企业支付', value: 'records' },
|
||||
{ label: '支付记录', value: 'payments' }
|
||||
]
|
||||
|
||||
// 财务概览数据(金额为分,前端展示时 fenToYuan 转元)
|
||||
const financialOverview = reactive({
|
||||
totalRevenue: 0,
|
||||
totalCost: 0,
|
||||
netProfit: 0,
|
||||
profitRate: 0,
|
||||
monthRevenue: 0,
|
||||
monthCost: 0,
|
||||
monthProfit: 0,
|
||||
monthGrowth: 0,
|
||||
paidOrderCount: 0,
|
||||
})
|
||||
|
||||
function fenToYuan(fen: number | undefined | null): number {
|
||||
if (fen == null || !Number.isFinite(fen)) return 0
|
||||
return Number(fen) / 100
|
||||
}
|
||||
|
||||
interface DetailItem { type: string; amount: number; percent: number }
|
||||
interface RechargeRecord { orderNo?: string; enterprise: string; amount: number; method: string; date: string }
|
||||
interface PaymentRecord { orderNo: string; userName: string; enterprise: string; productType: string; amount: number; method: string; date: string }
|
||||
|
||||
// 收入明细
|
||||
const revenueDetails = ref<DetailItem[]>([])
|
||||
|
||||
// 成本明细
|
||||
const costDetails = ref<DetailItem[]>([])
|
||||
|
||||
// 企业支付记录
|
||||
const rechargeRecords = ref<RechargeRecord[]>([])
|
||||
const rechargePage = ref(1)
|
||||
const rechargePageSize = ref(20)
|
||||
const rechargeTotal = ref(0)
|
||||
|
||||
// 支付记录(全部)
|
||||
const paymentRecords = ref<PaymentRecord[]>([])
|
||||
const paymentPage = ref(1)
|
||||
const paymentPageSize = ref(20)
|
||||
const paymentTotal = ref(0)
|
||||
const paymentKeyword = ref('')
|
||||
|
||||
// 加载财务概览
|
||||
const loadFinancialOverview = async () => {
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/finance/overview')
|
||||
if (response.code === 200 && response.data) {
|
||||
Object.assign(financialOverview, response.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error('加载财务概览失败')
|
||||
} finally {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载收入明细
|
||||
const loadRevenueDetails = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/finance/revenue-details')
|
||||
if (response.code === 200 && response.data) {
|
||||
revenueDetails.value = response.data
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载收入明细失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载成本明细
|
||||
const loadCostDetails = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/finance/cost-details')
|
||||
if (response.code === 200 && response.data) {
|
||||
costDetails.value = response.data
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载成本明细失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载企业支付记录
|
||||
const loadRechargeRecords = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/finance/recharge-records', {
|
||||
params: { page: rechargePage.value, pageSize: rechargePageSize.value }
|
||||
})
|
||||
if (response.code === 200 && response.data) {
|
||||
rechargeRecords.value = response.data.list || []
|
||||
rechargeTotal.value = response.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载企业支付记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载支付记录(全部)
|
||||
const loadPaymentRecords = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/finance/payment-records', {
|
||||
params: {
|
||||
page: paymentPage.value,
|
||||
pageSize: paymentPageSize.value,
|
||||
keyword: paymentKeyword.value
|
||||
}
|
||||
})
|
||||
if (response.code === 200 && response.data) {
|
||||
paymentRecords.value = response.data.list || []
|
||||
paymentTotal.value = response.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载支付记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const response: any = await request.post('/superadmin/finance/export')
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('财务报表导出成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFinancialOverview()
|
||||
loadRevenueDetails()
|
||||
loadCostDetails()
|
||||
loadRechargeRecords()
|
||||
})
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'payments') loadPaymentRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.zero-hint {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
.trend-icon {
|
||||
font-size: 14px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
&.positive {
|
||||
color: #22c55e;
|
||||
}
|
||||
&.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
.trend-icon.down {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.profit-bar-container {
|
||||
margin-top: 8px;
|
||||
|
||||
.profit-bar-bg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
|
||||
.profit-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #22c55e;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.purple {
|
||||
background-color: #f3e8ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #dcfce7;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fed7aa;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
background-color: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.finance-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.detail-card {
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
.search-input {
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
.pagination-wrap {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.order-no {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
}
|
||||
.product-type {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
.detail-content {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.detail-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.detail-amounts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.detail-amount {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.detail-percent {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.3s;
|
||||
|
||||
&.color-0 {
|
||||
background-color: #6366f1;
|
||||
}
|
||||
|
||||
&.color-1 {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
&.color-2 {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
&.color-3 {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
&.color-cost {
|
||||
background-color: #f87171;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-total {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.total-label {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.profit-analysis {
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profit-card {
|
||||
padding: 16px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
&.highlight {
|
||||
background-color: #dcfce7;
|
||||
}
|
||||
|
||||
.profit-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 4px 0;
|
||||
|
||||
&.highlight-text {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.profit-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
|
||||
&.highlight-value {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.recharge-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
background-color: rgba(249, 250, 251, 0.8);
|
||||
|
||||
tr {
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
|
||||
&.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(249, 250, 251, 0.5);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
|
||||
&.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.enterprise-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.enterprise-icon {
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.enterprise-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.method-text,
|
||||
.date-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
254
admin/src/views/superadmin/Login.vue
Normal file
254
admin/src/views/superadmin/Login.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h2 class="title">超级管理员登录</h2>
|
||||
<p class="description">请输入您的超级管理员凭据</p>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</el-alert>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@submit.prevent="handleLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<label class="form-label">用户名</label>
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="输入超级管理员用户名"
|
||||
size="large"
|
||||
clearable
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<label class="form-label">密码</label>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="输入超级管理员密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="security-info">
|
||||
<el-icon class="security-icon"><Lock /></el-icon>
|
||||
<span>安全连接 | 仅限授权人员访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const success = await authStore.superAdminLogin(loginForm.username, loginForm.password)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/superadmin/overview')
|
||||
} else {
|
||||
errorMessage.value = '用户名或密码错误'
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('表单验证失败:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 448px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 12px 16px;
|
||||
background-color: #f9fafb;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #5b21b6;
|
||||
border-color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
.security-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
gap: 6px;
|
||||
|
||||
.security-icon {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
824
admin/src/views/superadmin/Overview.vue
Normal file
824
admin/src/views/superadmin/Overview.vue
Normal file
@@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<div class="dashboard-container" v-loading="loading">
|
||||
<!-- 数据概览 -->
|
||||
<div class="overview-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">数据概览</h2>
|
||||
<p class="section-subtitle">超管端全局数据一览</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">企业总数</div>
|
||||
<div class="stat-value">{{ stats.totalEnterprises }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">+{{ stats.newEnterprises }} 本月新增</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-value">{{ stats.totalUsers.toLocaleString() }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">+{{ stats.newUsers }} 本月新增 · 注册 {{ stats.totalRegisteredUsers?.toLocaleString() ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总收入</div>
|
||||
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(stats.totalRevenue)) }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">本月 {{ formatMoneyYuan(fenToYuan(stats.monthRevenue)) }}</span>
|
||||
<el-icon v-if="stats.revenueGrowth > 0" class="trend-icon"><ArrowUp /></el-icon>
|
||||
<span class="trend-text" :class="{ positive: stats.revenueGrowth > 0 }">
|
||||
{{ stats.revenueGrowth > 0 ? '+' : '' }}{{ stats.revenueGrowth }}% 环比
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已支付订单</div>
|
||||
<div class="stat-value">{{ (stats.paidOrderCount ?? 0).toLocaleString() }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">累计已支付笔数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">测试总量</div>
|
||||
<div class="stat-value">{{ stats.totalTests.toLocaleString() }}</div>
|
||||
<div class="stat-trend">
|
||||
<span class="trend-text">+{{ stats.newTests }} 本月</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="quick-actions-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon class="title-icon"><Lightning /></el-icon>
|
||||
快捷操作
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="actions-grid">
|
||||
<div class="action-card blue" @click="handleAction('create-enterprise')">
|
||||
<div class="action-icon">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">新建企业</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card blue" @click="handleAction('enterprise-management')">
|
||||
<div class="action-icon">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">企业管理</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card green" @click="handleAction('finance')">
|
||||
<div class="action-icon">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">财务管理</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card orange" @click="handleAction('pricing')">
|
||||
<div class="action-icon">
|
||||
<el-icon><PriceTag /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">价格管理</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card purple" @click="handleAction('users')">
|
||||
<div class="action-icon">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">用户数据</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card gray" @click="handleAction('settings')">
|
||||
<div class="action-icon">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</div>
|
||||
<div class="action-label">系统设置</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试趋势折线图 -->
|
||||
<div class="trend-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||
测试趋势
|
||||
</h2>
|
||||
<p class="section-subtitle">最近 14 天人脸分析、MBTI、PDP、DISC 等关键测试的完成情况</p>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart-wrapper" v-if="testTrends.length">
|
||||
<VChart class="trend-chart-echarts" :option="chartOption" autoresize />
|
||||
</div>
|
||||
|
||||
<div class="empty-placeholder" v-else>
|
||||
<el-icon class="empty-icon"><TrendCharts /></el-icon>
|
||||
<p class="empty-text">暂无测试趋势数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部两列布局 -->
|
||||
<div class="bottom-section">
|
||||
<!-- 最近动态 -->
|
||||
<div class="recent-dynamics-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||
最近动态
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="dynamics-list" v-if="recentDynamics.length > 0">
|
||||
<div
|
||||
v-for="(item, index) in recentDynamics"
|
||||
:key="index"
|
||||
class="dynamics-item"
|
||||
>
|
||||
<div class="dynamics-icon">
|
||||
<el-icon><component :is="iconMap[item.icon] || Document" /></el-icon>
|
||||
</div>
|
||||
<div class="dynamics-content">
|
||||
<div class="dynamics-text">{{ item.text }}</div>
|
||||
</div>
|
||||
<div class="dynamics-time">{{ item.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 空数据占位图 -->
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">暂无最近动态</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业活跃排行 -->
|
||||
<div class="ranking-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||
企业活跃排行
|
||||
</h2>
|
||||
<el-button link class="view-all-btn" @click="handleViewAll">查看全部</el-button>
|
||||
</div>
|
||||
|
||||
<div class="ranking-list" v-if="enterpriseRanking.length > 0">
|
||||
<div
|
||||
v-for="(item, index) in enterpriseRanking"
|
||||
:key="index"
|
||||
class="ranking-item"
|
||||
>
|
||||
<div class="ranking-number" :class="{ 'top-one': index === 0 }">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="ranking-content">
|
||||
<div class="ranking-name">{{ item.name }}</div>
|
||||
<div class="ranking-stats">
|
||||
<span class="test-count">{{ item.tests }}次测试</span>
|
||||
<span class="amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 空数据占位图 -->
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><TrendCharts /></el-icon>
|
||||
<p class="empty-text">暂无企业排行数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
User,
|
||||
Document,
|
||||
TrendCharts,
|
||||
ArrowUp,
|
||||
Lightning,
|
||||
Plus,
|
||||
PriceTag,
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import { formatMoneyYuan } from '@/utils/format'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
// 金额为分,转元展示
|
||||
function fenToYuan(fen: number | undefined | null): number {
|
||||
if (fen == null || Number.isNaN(Number(fen))) return 0
|
||||
return Number(fen) / 100
|
||||
}
|
||||
|
||||
const stats = reactive({
|
||||
totalEnterprises: 0,
|
||||
newEnterprises: 0,
|
||||
totalRegisteredUsers: 0,
|
||||
totalUsers: 0,
|
||||
newUsers: 0,
|
||||
totalRevenue: 0,
|
||||
monthRevenue: 0,
|
||||
revenueGrowth: 0,
|
||||
paidOrderCount: 0,
|
||||
totalTests: 0,
|
||||
newTests: 0
|
||||
})
|
||||
|
||||
interface DynamicItem { icon: string; text: string; time: string }
|
||||
interface RankingItem { name: string; tests: number; amount: number }
|
||||
|
||||
const recentDynamics = ref<DynamicItem[]>([])
|
||||
const enterpriseRanking = ref<RankingItem[]>([])
|
||||
|
||||
type TrendPoint = { date: string; face: number; mbti: number; pdp: number; disc: number; total: number }
|
||||
const testTrends = ref<TrendPoint[]>([])
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const dates = testTrends.value.map(d => d.date.slice(5))
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['人脸分析', 'MBTI', 'PDP', 'DISC'],
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 30,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '人脸分析',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#22c55e' },
|
||||
data: testTrends.value.map(d => d.face)
|
||||
},
|
||||
{
|
||||
name: 'MBTI',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
data: testTrends.value.map(d => d.mbti)
|
||||
},
|
||||
{
|
||||
name: 'PDP',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: testTrends.value.map(d => d.pdp)
|
||||
},
|
||||
{
|
||||
name: 'DISC',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
data: testTrends.value.map(d => d.disc)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 图标映射
|
||||
const iconMap: Record<string, any> = {
|
||||
Document,
|
||||
User,
|
||||
TrendCharts
|
||||
}
|
||||
|
||||
// 加载数据概览
|
||||
const loadOverview = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/overview')
|
||||
if (response.code === 200 && response.data) {
|
||||
Object.assign(stats, response.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据概览失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近动态
|
||||
const loadRecentDynamics = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/overview/recent-dynamics', {
|
||||
params: { limit: 10 }
|
||||
})
|
||||
if (response.code === 200 && response.data) {
|
||||
recentDynamics.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载最近动态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载企业排行
|
||||
const loadEnterpriseRanking = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/overview/enterprise-ranking', {
|
||||
params: { limit: 5 }
|
||||
})
|
||||
if (response.code === 200 && response.data) {
|
||||
enterpriseRanking.value = response.data.map((item: any) => ({
|
||||
name: item.name,
|
||||
tests: item.tests,
|
||||
amount: item.amount
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载企业排行失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载测试趋势
|
||||
const loadTestTrends = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/overview/test-trends', {
|
||||
params: { days: 14 }
|
||||
})
|
||||
if (response.code === 200 && response.data) {
|
||||
testTrends.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载测试趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
const routes: Record<string, string> = {
|
||||
'create-enterprise': '/superadmin/enterprises',
|
||||
'enterprise-management': '/superadmin/enterprises',
|
||||
'finance': '/superadmin/finance',
|
||||
'pricing': '/superadmin/pricing',
|
||||
'users': '/superadmin/users',
|
||||
'settings': '/superadmin/settings'
|
||||
}
|
||||
if (routes[action]) {
|
||||
router.push(routes[action])
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewAll = () => {
|
||||
router.push('/superadmin/enterprises')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview()
|
||||
loadRecentDynamics()
|
||||
loadEnterpriseRanking()
|
||||
loadTestTrends()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
background-color: #f9fafb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.title-icon {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
.trend-icon {
|
||||
font-size: 14px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
&.positive {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.purple {
|
||||
background-color: #f3e8ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #dcfce7;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fed7aa;
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.blue .action-icon {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
&.green .action-icon {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
&.orange .action-icon {
|
||||
background-color: #f97316;
|
||||
}
|
||||
|
||||
&.purple .action-icon {
|
||||
background-color: #a855f7;
|
||||
}
|
||||
|
||||
&.gray .action-icon {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trend-section {
|
||||
margin-bottom: 28px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
|
||||
.trend-chart-wrapper {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.trend-chart-echarts {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.recent-dynamics-section,
|
||||
.ranking-section {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
.view-all-btn {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dynamics-list {
|
||||
.dynamics-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dynamics-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dynamics-content {
|
||||
flex: 1;
|
||||
|
||||
.dynamics-text {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dynamics-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ranking-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.top-one {
|
||||
background-color: #fef3c7;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-content {
|
||||
flex: 1;
|
||||
|
||||
.ranking-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ranking-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
.test-count {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.quick-actions-section .actions-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.overview-section .stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.bottom-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overview-section .stats-grid,
|
||||
.quick-actions-section .actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
985
admin/src/views/superadmin/PosterEditor.vue
Normal file
985
admin/src/views/superadmin/PosterEditor.vue
Normal file
@@ -0,0 +1,985 @@
|
||||
<template>
|
||||
<div class="poster-editor-root">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<el-button size="small" @click="addElement('text')">
|
||||
<el-icon><EditPen /></el-icon> 添加文字
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('nickname')">
|
||||
<el-icon><User /></el-icon> 用户昵称
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('avatar')">
|
||||
<el-icon><Avatar /></el-icon> 用户头像
|
||||
</el-button>
|
||||
<el-button size="small" @click="addElement('qrcode')">
|
||||
<el-icon><Grid /></el-icon> 小程序码
|
||||
</el-button>
|
||||
<el-button size="small" @click="triggerImageAdd">
|
||||
<el-icon><Picture /></el-icon> 上传图片
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button size="small" type="warning" plain @click="addElement('mbti')">MBTI</el-button>
|
||||
<el-button size="small" type="success" plain @click="addElement('pdp')">PDP</el-button>
|
||||
<el-button size="small" type="danger" plain @click="addElement('disc')">DISC</el-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
|
||||
<el-button size="small" @click="loadConfig">重置</el-button>
|
||||
<el-button size="small" type="primary" color="#6366f1" :loading="saving" @click="saveConfig">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:图层 | 画布 | 属性 -->
|
||||
<div class="editor-body">
|
||||
<!-- 左侧图层面板 -->
|
||||
<div class="editor-layers">
|
||||
<div class="panel-title">图层</div>
|
||||
<div class="layers-list">
|
||||
<div
|
||||
v-for="el in [...elements].reverse()"
|
||||
:key="el.id"
|
||||
:class="['layer-item', { active: selectedId === el.id }]"
|
||||
@click="selectedId = el.id"
|
||||
>
|
||||
<el-icon class="layer-icon">
|
||||
<component :is="typeIconMap[el.type]" />
|
||||
</el-icon>
|
||||
<span class="layer-name">{{ typeLabel(el) }}</span>
|
||||
<div class="layer-actions">
|
||||
<el-icon class="action-icon" @click.stop="moveLayer(el.id, -1)" title="上移"><ArrowUp /></el-icon>
|
||||
<el-icon class="action-icon" @click.stop="moveLayer(el.id, 1)" title="下移"><ArrowDown /></el-icon>
|
||||
<el-icon class="action-icon danger" @click.stop="removeElement(el.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="elements.length === 0" class="layers-empty">暂无元素</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间画布 -->
|
||||
<div class="editor-canvas-area" @click.self="selectedId = null">
|
||||
<div class="canvas-scale-wrap">
|
||||
<div
|
||||
class="poster-canvas"
|
||||
:style="posterCanvasStyle"
|
||||
@click.self="selectedId = null"
|
||||
>
|
||||
<!-- 背景图(点击空白处取消选择) -->
|
||||
<img v-if="config.bgImage" :src="config.bgImage" class="canvas-bg-img" @click="selectedId = null" />
|
||||
<!-- 无背景图时的可点击底衬 -->
|
||||
<div
|
||||
v-else
|
||||
class="canvas-bg-click"
|
||||
@click="selectedId = null"
|
||||
/>
|
||||
|
||||
<!-- 各元素(configReady 后才渲染,避免组件初始化覆盖数据) -->
|
||||
<Vue3DraggableResizable
|
||||
v-for="el in elements"
|
||||
v-if="configReady"
|
||||
:key="`${el.id}_${configVersion}`"
|
||||
:initW="el.w"
|
||||
:initH="el.h"
|
||||
:x="el.x"
|
||||
:y="el.y"
|
||||
:w="el.w"
|
||||
:h="el.h"
|
||||
:active="selectedId === el.id"
|
||||
:parent="true"
|
||||
:draggable="true"
|
||||
:resizable="true"
|
||||
:min-w="20"
|
||||
:min-h="20"
|
||||
class-name-active="vdr-active"
|
||||
@update:x="el.x = Math.round($event)"
|
||||
@update:y="el.y = Math.round($event)"
|
||||
@update:w="el.w = Math.round($event)"
|
||||
@update:h="el.h = Math.round($event)"
|
||||
@activated="selectedId = el.id"
|
||||
>
|
||||
<!-- 文字 -->
|
||||
<div v-if="el.type === 'text'" class="el-text" :style="textElemStyle(el)">
|
||||
{{ el.content || '文字内容' }}
|
||||
</div>
|
||||
|
||||
<!-- 昵称占位 -->
|
||||
<div v-else-if="el.type === 'nickname'" class="el-text el-nickname" :style="textElemStyle(el)">
|
||||
用户昵称
|
||||
</div>
|
||||
|
||||
<!-- 头像占位 -->
|
||||
<div v-else-if="el.type === 'avatar'" class="el-placeholder" :style="shapeElemStyle(el)">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>头像</span>
|
||||
</div>
|
||||
|
||||
<!-- 小程序码占位 -->
|
||||
<div v-else-if="el.type === 'qrcode'" class="el-placeholder el-qrcode" :style="shapeElemStyle(el)">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>小程序码</span>
|
||||
</div>
|
||||
|
||||
<!-- 上传图片 -->
|
||||
<div v-else-if="el.type === 'image'" class="el-placeholder el-image" :style="shapeElemStyle(el)">
|
||||
<img v-if="el.url" :src="el.url" :style="{ borderRadius: el.shape === 'circle' ? '50%' : '0', width: '100%', height: '100%', objectFit: 'cover' }" />
|
||||
<template v-else>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图片</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- MBTI 结果 -->
|
||||
<div v-else-if="el.type === 'mbti'" class="el-text el-test-result mbti-result" :style="textElemStyle(el)">
|
||||
{{ el.content || 'INTJ' }}
|
||||
</div>
|
||||
|
||||
<!-- PDP 结果 -->
|
||||
<div v-else-if="el.type === 'pdp'" class="el-text el-test-result pdp-result" :style="textElemStyle(el)">
|
||||
{{ el.content || '猫头鹰' }}
|
||||
</div>
|
||||
|
||||
<!-- DISC 结果 -->
|
||||
<div v-else-if="el.type === 'disc'" class="el-text el-test-result disc-result" :style="textElemStyle(el)">
|
||||
{{ el.content || 'C型' }}
|
||||
</div>
|
||||
</Vue3DraggableResizable>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-hint">画布尺寸:375 × 667 px(标准小程序屏幕)</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<div class="editor-props">
|
||||
<!-- 无选中:显示画布设置 -->
|
||||
<template v-if="!selectedElement">
|
||||
<div class="panel-title">画布设置</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">背景颜色</label>
|
||||
<el-color-picker v-model="config.bgColor" show-alpha />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">背景图片</label>
|
||||
<div v-if="config.bgImage" class="img-preview-row">
|
||||
<img :src="config.bgImage" class="img-preview" />
|
||||
<el-button size="small" type="danger" plain @click="config.bgImage = ''">清除</el-button>
|
||||
</div>
|
||||
<el-button size="small" :loading="uploadingBg" @click="triggerBgUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ config.bgImage ? '更换背景' : '上传背景' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="props-tip">点击画布上的元素以编辑属性</div>
|
||||
</template>
|
||||
|
||||
<!-- 有选中:显示元素属性 -->
|
||||
<template v-else>
|
||||
<div class="panel-title">
|
||||
{{ typeLabel(selectedElement) }}
|
||||
<el-icon class="delete-icon" @click="removeElement(selectedElement.id)" title="删除"><Delete /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 文字内容(text 类型) -->
|
||||
<div v-if="selectedElement.type === 'text'" class="prop-item">
|
||||
<label class="prop-label">文字内容</label>
|
||||
<el-input v-model="selectedElement.content" type="textarea" :rows="2" placeholder="请输入文字" />
|
||||
</div>
|
||||
|
||||
<!-- 占位文字(mbti / pdp / disc) -->
|
||||
<div v-if="['mbti','pdp','disc'].includes(selectedElement.type)" class="prop-item">
|
||||
<label class="prop-label">预览占位文字</label>
|
||||
<el-input v-model="selectedElement.content" placeholder="如:INTJ" size="small" />
|
||||
<span class="form-hint">实际生成时自动读取用户最近测试结果</span>
|
||||
</div>
|
||||
|
||||
<!-- 字体/字号/颜色/加粗/居中(text / nickname / mbti / pdp / disc) -->
|
||||
<template v-if="['text','nickname','mbti','pdp','disc'].includes(selectedElement.type)">
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">字体</label>
|
||||
<el-select
|
||||
v-model="selectedElement.fontFamily"
|
||||
placeholder="默认字体"
|
||||
size="small"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="f in fontList"
|
||||
:key="f.key"
|
||||
:label="f.name"
|
||||
:value="f.key"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="serverFontsLoaded && fontList.length === 0" class="form-hint" style="color:#ef4444">
|
||||
服务器未安装字体,请上传字体到 public/fonts/
|
||||
</span>
|
||||
<span v-else-if="selectedElement?.fontFamily && !fontList.find(f => f.key === selectedElement?.fontFamily)" class="form-hint" style="color:#f59e0b">
|
||||
当前字体在服务器上不可用,将使用默认字体
|
||||
</span>
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">字号 (px)</label>
|
||||
<div class="font-size-row">
|
||||
<el-slider
|
||||
:model-value="selectedElement.fontSize ?? 16"
|
||||
:min="10"
|
||||
:max="120"
|
||||
:show-tooltip="false"
|
||||
@input="onFontSizeChange"
|
||||
style="flex:1"
|
||||
/>
|
||||
<el-input-number
|
||||
:model-value="selectedElement.fontSize ?? 16"
|
||||
:min="10"
|
||||
:max="200"
|
||||
:step="1"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
@change="onFontSizeChange"
|
||||
style="width:80px;margin-left:8px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">颜色</label>
|
||||
<el-color-picker v-model="selectedElement.color" show-alpha />
|
||||
</div>
|
||||
<div class="prop-item inline">
|
||||
<label class="prop-label">加粗</label>
|
||||
<el-switch v-model="selectedElement.bold" />
|
||||
</div>
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">对齐</label>
|
||||
<el-radio-group v-model="selectedElement.align" size="small">
|
||||
<el-radio-button value="left">左对齐</el-radio-button>
|
||||
<el-radio-button value="center">居中</el-radio-button>
|
||||
<el-radio-button value="right">右对齐</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 形状(avatar / image) -->
|
||||
<template v-if="['avatar','image'].includes(selectedElement.type)">
|
||||
<div class="prop-item">
|
||||
<label class="prop-label">形状</label>
|
||||
<el-radio-group v-model="selectedElement.shape" size="small">
|
||||
<el-radio-button label="circle">圆形</el-radio-button>
|
||||
<el-radio-button label="square">正方形</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片上传(image) -->
|
||||
<div v-if="selectedElement.type === 'image'" class="prop-item">
|
||||
<label class="prop-label">图片</label>
|
||||
<div v-if="selectedElement.url" class="img-preview-row">
|
||||
<img :src="selectedElement.url" class="img-preview" />
|
||||
<el-button size="small" type="danger" plain @click="selectedElement.url = ''">清除</el-button>
|
||||
</div>
|
||||
<el-button size="small" :loading="uploadingImg" @click="triggerElementImageUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ selectedElement.url ? '更换图片' : '上传图片' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 位置与大小 -->
|
||||
<div class="props-section-title">位置与大小</div>
|
||||
<div class="prop-row">
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">X</label>
|
||||
<el-input-number v-model="selectedElement.x" :min="0" size="small" controls-position="right" />
|
||||
</div>
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">Y</label>
|
||||
<el-input-number v-model="selectedElement.y" :min="0" size="small" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">宽</label>
|
||||
<el-input-number v-model="selectedElement.w" :min="20" size="small" controls-position="right" />
|
||||
</div>
|
||||
<div class="prop-item half">
|
||||
<label class="prop-label">高</label>
|
||||
<el-input-number v-model="selectedElement.h" :min="20" size="small" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏文件输入 -->
|
||||
<input ref="bgInputRef" type="file" accept="image/*" style="display:none" @change="onBgFileChange" />
|
||||
<input ref="imgInputRef" type="file" accept="image/*" style="display:none" @change="onImgFileChange" />
|
||||
<input ref="addImgInputRef" type="file" accept="image/*" style="display:none" @change="onAddImgFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import Vue3DraggableResizable from 'vue3-draggable-resizable'
|
||||
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
|
||||
import {
|
||||
EditPen, User, Avatar, Grid, Picture, Delete, Upload,
|
||||
ArrowUp, ArrowDown, TrophyBase, Odometer, DataAnalysis
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
type ElemType = 'text' | 'nickname' | 'avatar' | 'qrcode' | 'image' | 'mbti' | 'pdp' | 'disc'
|
||||
type Shape = 'circle' | 'square'
|
||||
type TextAlign = 'left' | 'center' | 'right'
|
||||
|
||||
interface PosterElement {
|
||||
id: string
|
||||
type: ElemType
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
// text / nickname
|
||||
content?: string
|
||||
fontSize?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
align?: TextAlign
|
||||
fontFamily?: string
|
||||
// avatar / image
|
||||
shape?: Shape
|
||||
// image only
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface FontItem {
|
||||
key: string
|
||||
name: string
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
interface PosterConfig {
|
||||
bgColor: string
|
||||
bgImage: string
|
||||
elements: PosterElement[]
|
||||
}
|
||||
|
||||
// ────── 状态 ──────
|
||||
const saving = ref(false)
|
||||
const uploadingBg = ref(false)
|
||||
const uploadingImg = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const configReady = ref(false)
|
||||
const configVersion = ref(0)
|
||||
const fontList = ref<FontItem[]>([])
|
||||
const serverFontsLoaded = ref(false)
|
||||
|
||||
const bgInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imgInputRef = ref<HTMLInputElement | null>(null)
|
||||
const addImgInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const config = reactive<PosterConfig>({
|
||||
bgColor: '#ffffff',
|
||||
bgImage: '',
|
||||
elements: []
|
||||
})
|
||||
|
||||
const elements = computed(() => config.elements)
|
||||
|
||||
const selectedElement = computed(() =>
|
||||
selectedId.value ? config.elements.find(e => e.id === selectedId.value) ?? null : null
|
||||
)
|
||||
|
||||
// ────── 工具映射 ──────
|
||||
const typeIconMap: Record<ElemType, any> = {
|
||||
text: EditPen,
|
||||
nickname: User,
|
||||
avatar: Avatar,
|
||||
qrcode: Grid,
|
||||
image: Picture,
|
||||
mbti: TrophyBase,
|
||||
pdp: Odometer,
|
||||
disc: DataAnalysis
|
||||
}
|
||||
|
||||
const typeLabel = (el: PosterElement) => {
|
||||
const labels: Record<ElemType, string> = {
|
||||
text: `文字:${el.content?.slice(0, 6) || '文字内容'}`,
|
||||
nickname: '用户昵称',
|
||||
avatar: '用户头像',
|
||||
qrcode: '小程序码',
|
||||
image: '图片',
|
||||
mbti: `MBTI:${el.content || 'INTJ'}`,
|
||||
pdp: `PDP:${el.content || '猫头鹰'}`,
|
||||
disc: `DISC:${el.content || 'C型'}`
|
||||
}
|
||||
return labels[el.type]
|
||||
}
|
||||
|
||||
// ────── 画布样式 ──────
|
||||
const posterCanvasStyle = computed(() => ({
|
||||
width: '375px',
|
||||
height: '667px',
|
||||
backgroundColor: config.bgColor,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0
|
||||
}))
|
||||
|
||||
// ────── 字体 CSS 映射(用于画布预览近似渲染) ──────
|
||||
const fontCssMap: Record<string, string> = {
|
||||
'noto-sans': '"Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
'noto-serif': '"Noto Serif SC", "Source Han Serif SC", "STSong", "SimSun", serif',
|
||||
'alimama': '"AlimamaFangYuanTiVF", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
'wqy-microhei': '"WenQuanYi Micro Hei", "Noto Sans SC", "Microsoft YaHei", sans-serif',
|
||||
'simhei': '"SimHei", "Heiti SC", sans-serif',
|
||||
'msyh': '"Microsoft YaHei", "PingFang SC", sans-serif',
|
||||
}
|
||||
|
||||
const getFontCss = (fontKey?: string) => {
|
||||
if (!fontKey) return undefined
|
||||
return fontCssMap[fontKey] ?? undefined
|
||||
}
|
||||
|
||||
// ────── 元素样式 ──────
|
||||
const alignToJustify: Record<TextAlign, string> = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end'
|
||||
}
|
||||
|
||||
const textElemStyle = (el: PosterElement) => {
|
||||
const align = el.align ?? 'left'
|
||||
return {
|
||||
fontSize: `${el.fontSize ?? 16}px`,
|
||||
color: el.color ?? '#333333',
|
||||
fontWeight: el.bold ? 'bold' : 'normal',
|
||||
textAlign: align,
|
||||
justifyContent: alignToJustify[align],
|
||||
fontFamily: getFontCss(el.fontFamily),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 4px',
|
||||
boxSizing: 'border-box' as const,
|
||||
wordBreak: 'break-all' as const,
|
||||
lineHeight: 1.4,
|
||||
userSelect: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
const shapeElemStyle = (el: PosterElement) => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: el.shape === 'circle' ? '50%' : '4px',
|
||||
overflow: 'hidden'
|
||||
})
|
||||
|
||||
// ────── 添加元素 ──────
|
||||
let idCounter = 1
|
||||
const genId = () => `el_${Date.now()}_${idCounter++}`
|
||||
|
||||
const addElement = (type: ElemType) => {
|
||||
const defaults: Record<ElemType, Partial<PosterElement>> = {
|
||||
text: { w: 160, h: 40, content: '文字内容', fontSize: 18, color: '#333333', bold: false, align: 'left' },
|
||||
nickname: { w: 150, h: 36, fontSize: 16, color: '#333333', bold: false, align: 'left' },
|
||||
avatar: { w: 80, h: 80, shape: 'circle' },
|
||||
qrcode: { w: 120, h: 120 },
|
||||
image: { w: 100, h: 100, shape: 'square', url: '' },
|
||||
mbti: { w: 80, h: 32, content: 'INTJ', fontSize: 16, color: '#6366f1', bold: true, align: 'center' },
|
||||
pdp: { w: 80, h: 32, content: '猫头鹰', fontSize: 16, color: '#10b981', bold: true, align: 'center' },
|
||||
disc: { w: 80, h: 32, content: 'C型', fontSize: 16, color: '#f43f5e', bold: true, align: 'center' }
|
||||
}
|
||||
const el: PosterElement = {
|
||||
id: genId(),
|
||||
type,
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 100,
|
||||
h: 100,
|
||||
...defaults[type]
|
||||
}
|
||||
config.elements.push(el)
|
||||
selectedId.value = el.id
|
||||
}
|
||||
|
||||
// ────── 属性修改 ──────
|
||||
const onFontSizeChange = (val: number | number[] | undefined) => {
|
||||
const el = selectedElement.value
|
||||
const v = Array.isArray(val) ? val[0] : val
|
||||
if (el && typeof v === 'number' && v >= 10) {
|
||||
el.fontSize = v
|
||||
}
|
||||
}
|
||||
|
||||
// ────── 删除/移动图层 ──────
|
||||
const removeElement = (id: string) => {
|
||||
const idx = config.elements.findIndex(e => e.id === id)
|
||||
if (idx !== -1) config.elements.splice(idx, 1)
|
||||
if (selectedId.value === id) selectedId.value = null
|
||||
}
|
||||
|
||||
const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const arr = config.elements
|
||||
const idx = arr.findIndex(e => e.id === id)
|
||||
const newIdx = idx + dir
|
||||
if (newIdx < 0 || newIdx >= arr.length) return
|
||||
const tmp = arr[idx]
|
||||
arr[idx] = arr[newIdx]
|
||||
arr[newIdx] = tmp
|
||||
}
|
||||
|
||||
// ────── 上传工具 ──────
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL
|
||||
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
|
||||
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
|
||||
: `${import.meta.env.VITE_API_BASE_URL}/api/v1`)
|
||||
: '/api/v1'
|
||||
const res = await axios.post(`${baseURL}/superadmin/upload/image`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
})
|
||||
if (res.data?.data?.url) return res.data.data.url
|
||||
throw new Error(res.data?.message || '上传失败')
|
||||
}
|
||||
|
||||
// 背景上传
|
||||
const triggerBgUpload = () => bgInputRef.value?.click()
|
||||
|
||||
const onBgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
uploadingBg.value = true
|
||||
try {
|
||||
config.bgImage = await uploadFile(file)
|
||||
ElMessage.success('背景图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingBg.value = false
|
||||
if (bgInputRef.value) bgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 图片元素上传(更换已有图片元素)
|
||||
const triggerElementImageUpload = () => imgInputRef.value?.click()
|
||||
|
||||
const onImgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file || !selectedElement.value) return
|
||||
uploadingImg.value = true
|
||||
try {
|
||||
selectedElement.value.url = await uploadFile(file)
|
||||
ElMessage.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingImg.value = false
|
||||
if (imgInputRef.value) imgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图片元素时先上传
|
||||
const triggerImageAdd = () => addImgInputRef.value?.click()
|
||||
|
||||
const onAddImgFileChange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
uploadingImg.value = true
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
const el: PosterElement = {
|
||||
id: genId(),
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 120,
|
||||
h: 120,
|
||||
shape: 'square',
|
||||
url
|
||||
}
|
||||
config.elements.push(el)
|
||||
selectedId.value = el.id
|
||||
ElMessage.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploadingImg.value = false
|
||||
if (addImgInputRef.value) addImgInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ────── 读取 / 保存配置 ──────
|
||||
const loadConfig = async () => {
|
||||
configReady.value = false
|
||||
selectedId.value = null
|
||||
try {
|
||||
const res = await request.get<any>('/superadmin/settings/poster')
|
||||
const data = res?.data?.poster
|
||||
if (data) {
|
||||
config.bgColor = data.bgColor ?? '#ffffff'
|
||||
config.bgImage = data.bgImage ?? ''
|
||||
config.elements = (data.elements ?? []).map((el: any) => {
|
||||
// 兼容旧数据:center: true → align: 'center'
|
||||
const align = el.align ?? (el.center ? 'center' : 'left')
|
||||
const { center: _center, ...rest } = el
|
||||
return {
|
||||
...rest,
|
||||
align,
|
||||
x: Number(el.x) || 0,
|
||||
y: Number(el.y) || 0,
|
||||
w: Math.max(20, Number(el.w) || 100),
|
||||
h: Math.max(20, Number(el.h) || 100),
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 未配置时静默失败
|
||||
}
|
||||
configVersion.value++
|
||||
await nextTick()
|
||||
configReady.value = true
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await request.put('/superadmin/settings/poster', {
|
||||
bgColor: config.bgColor,
|
||||
bgImage: config.bgImage,
|
||||
elements: config.elements
|
||||
})
|
||||
ElMessage.success('海报配置已保存')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFonts = async () => {
|
||||
try {
|
||||
const res = await request.get<any>('/superadmin/settings/fonts')
|
||||
const remote = res?.data?.fonts
|
||||
serverFontsLoaded.value = true
|
||||
if (Array.isArray(remote)) {
|
||||
fontList.value = remote
|
||||
}
|
||||
} catch {
|
||||
serverFontsLoaded.value = false
|
||||
fontList.value = [
|
||||
{ key: 'noto-sans', name: '思源黑体' },
|
||||
{ key: 'noto-serif', name: '思源宋体' },
|
||||
{ key: 'alimama', name: '阿里妈妈方圆体' },
|
||||
{ key: 'wqy-microhei', name: '文泉驿微米黑' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConfig(), loadFonts()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap');
|
||||
|
||||
.poster-editor-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主体三栏 */
|
||||
.editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 260px;
|
||||
gap: 12px;
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
/* 通用面板 */
|
||||
.editor-layers,
|
||||
.editor-props {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.delete-icon {
|
||||
cursor: pointer;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
&:hover { color: #dc2626; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 图层列表 */
|
||||
.layers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover { background: #f3f4f6; border-color: #d1d5db; }
|
||||
&.active { background: #ede9fe; border-color: #8b5cf6; color: #6d28d9; }
|
||||
|
||||
.layer-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.layer-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.action-icon {
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
&:hover { background: #e5e7eb; }
|
||||
&.danger { color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .layer-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
.layers-empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 画布区域 */
|
||||
.editor-canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.canvas-scale-wrap {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.poster-canvas {
|
||||
position: relative;
|
||||
width: 375px;
|
||||
height: 667px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-bg-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.canvas-bg-click {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.canvas-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 元素内容 */
|
||||
.el-text,
|
||||
.el-nickname {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.el-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1.5px dashed rgba(99, 102, 241, 0.4);
|
||||
color: #6366f1;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
.el-icon { font-size: 20px; }
|
||||
|
||||
&.el-qrcode {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
/* MBTI / PDP / DISC 测试结果占位 */
|
||||
.el-test-result {
|
||||
border: 1.5px dashed rgba(99, 102, 241, 0.3);
|
||||
border-radius: 4px;
|
||||
justify-content: center;
|
||||
|
||||
&.mbti-result { background: rgba(99, 102, 241, 0.06); }
|
||||
&.pdp-result { background: rgba(16, 185, 129, 0.06); border-color: rgba(16, 185, 129, 0.3); }
|
||||
&.disc-result { background: rgba(244, 63, 94, 0.06); border-color: rgba(244, 63, 94, 0.3); }
|
||||
}
|
||||
|
||||
/* vue3-draggable-resizable 激活样式覆盖 */
|
||||
:deep(.vdr-active) {
|
||||
outline: 2px solid #6366f1 !important;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
:deep(.vdr) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 字号行 */
|
||||
.font-size-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 属性面板 */
|
||||
.prop-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&.half {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.props-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 14px 0 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.props-tip {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.img-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
1072
admin/src/views/superadmin/Pricing.vue
Normal file
1072
admin/src/views/superadmin/Pricing.vue
Normal file
File diff suppressed because it is too large
Load Diff
1116
admin/src/views/superadmin/Questions.vue
Normal file
1116
admin/src/views/superadmin/Questions.vue
Normal file
File diff suppressed because it is too large
Load Diff
871
admin/src/views/superadmin/Settings.vue
Normal file
871
admin/src/views/superadmin/Settings.vue
Normal file
@@ -0,0 +1,871 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理系统配置、API密钥和管理员账户</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存成功提示 -->
|
||||
<el-alert
|
||||
v-if="saveSuccess"
|
||||
type="success"
|
||||
:closable="true"
|
||||
@close="saveSuccess = null"
|
||||
class="success-alert"
|
||||
>
|
||||
设置已保存成功
|
||||
</el-alert>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
<el-icon class="tab-icon"><component :is="tab.icon" /></el-icon>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card">
|
||||
<!-- 系统配置 -->
|
||||
<div v-if="activeTab === 'system'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span>系统基础配置</span>
|
||||
</div>
|
||||
<p class="header-description">管理网站名称、维护模式等基础设置</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label class="form-label">网站名称</label>
|
||||
<el-input
|
||||
v-model="systemConfig.siteName"
|
||||
class="form-input"
|
||||
placeholder="用于管理后台、Web 端展示"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">小程序名称</label>
|
||||
<el-input
|
||||
v-model="systemConfig.miniprogramName"
|
||||
class="form-input"
|
||||
placeholder="用于小程序导航栏等展示,未填则用网站名称"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">网站描述</label>
|
||||
<el-input
|
||||
v-model="systemConfig.siteDescription"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">每日最大测试数</label>
|
||||
<el-input-number
|
||||
v-model="systemConfig.maxTestsPerDay"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">企业试用测试次数</label>
|
||||
<el-input-number
|
||||
v-model="systemConfig.trialTestCount"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 小程序文案配置 -->
|
||||
<div class="form-section text-config-section">
|
||||
<div class="section-label">小程序文案配置</div>
|
||||
<p class="section-desc">以下文案将显示在小程序对应位置,留空则使用默认值</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label class="form-label">分析中提示</label>
|
||||
<el-input
|
||||
v-model="textConfig.analyzingTitle"
|
||||
class="form-input"
|
||||
placeholder="默认:正在分析中"
|
||||
/>
|
||||
<span class="form-hint">原「AI正在分析中」</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">开始按钮(个人版)</label>
|
||||
<el-input
|
||||
v-model="textConfig.startButtonText"
|
||||
class="form-input"
|
||||
placeholder="默认:开始面相测试"
|
||||
/>
|
||||
<span class="form-hint">原「开始AI面相测试」</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">开始按钮(企业版)</label>
|
||||
<el-input
|
||||
v-model="textConfig.startButtonEnterprise"
|
||||
class="form-input"
|
||||
placeholder="默认:开始面部测试"
|
||||
/>
|
||||
<span class="form-hint">原「开始AI面部测试」</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">报告页标题</label>
|
||||
<el-input
|
||||
v-model="textConfig.reportTitle"
|
||||
class="form-input"
|
||||
placeholder="默认:分析报告"
|
||||
/>
|
||||
<span class="form-hint">原「AI分析报告」</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">智能分析文案</label>
|
||||
<el-input
|
||||
v-model="textConfig.aiAnalysisText"
|
||||
class="form-input"
|
||||
placeholder="默认:智能分析"
|
||||
/>
|
||||
<span class="form-hint">用于步骤、立即分析按钮等,原「AI分析」</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开关项 -->
|
||||
<div class="switch-section">
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">维护模式</p>
|
||||
<p class="switch-desc">开启后前台将显示维护页面</p>
|
||||
</div>
|
||||
<el-switch v-model="systemConfig.maintenanceMode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#6366f1"
|
||||
class="save-button"
|
||||
@click="handleSave('system')"
|
||||
>
|
||||
<el-icon class="mr-1"><Document /></el-icon>保存系统配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div v-if="activeTab === 'notification'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><Bell /></el-icon>
|
||||
<span>通知与告警设置</span>
|
||||
</div>
|
||||
<p class="header-description">管理系统通知和企业告警规则</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="switch-section">
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">邮件通知</p>
|
||||
<p class="switch-desc">发送重要事件邮件通知</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.emailNotification" />
|
||||
</div>
|
||||
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">余额不足告警</p>
|
||||
<p class="switch-desc">企业余额低于阈值时提醒</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.lowBalanceAlert" />
|
||||
</div>
|
||||
|
||||
<div v-if="notificationConfig.lowBalanceAlert" class="threshold-input">
|
||||
<label class="form-label">余额告警阈值(元)</label>
|
||||
<el-input-number
|
||||
v-model="notificationConfig.lowBalanceThreshold"
|
||||
:min="100"
|
||||
:step="100"
|
||||
:controls="false"
|
||||
class="form-input threshold-input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">新企业入驻通知</p>
|
||||
<p class="switch-desc">有新企业入驻时发送通知</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.newEnterpriseNotify" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#6366f1"
|
||||
class="save-button"
|
||||
@click="handleSave('notification')"
|
||||
>
|
||||
<el-icon class="mr-1"><Document /></el-icon>保存通知设置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 提示词配置 -->
|
||||
<div v-if="activeTab === 'prompts'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><ChatDotRound /></el-icon>
|
||||
<span>提示词配置</span>
|
||||
</div>
|
||||
<p class="header-description">配置 AI 面相分析场景使用的系统提示词,保存后将在对应接口中生效</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="prompt-two-col">
|
||||
<div class="form-item prompt-col">
|
||||
<label class="form-label">个人版面相分析提示词</label>
|
||||
<el-input
|
||||
v-model="promptsConfig.faceAnalyze"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder='仅填写 JSON 返回模板,例如:{"mbti":"四字母如INTJ",...},接口会自动在前面拼接固定中文说明'
|
||||
class="form-input prompt-textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item prompt-col">
|
||||
<label class="form-label">企业版面相分析提示词</label>
|
||||
<el-input
|
||||
v-model="promptsConfig.reportSummary"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder="企业版面相分析场景使用的提示词,可按需扩展"
|
||||
class="form-input prompt-textarea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#6366f1"
|
||||
class="save-button"
|
||||
@click="handleSave('prompts')"
|
||||
>
|
||||
<el-icon class="mr-1"><Document /></el-icon>保存提示词配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 海报配置 -->
|
||||
<div v-if="activeTab === 'poster'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card poster-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><Postcard /></el-icon>
|
||||
<span>分销海报配置</span>
|
||||
</div>
|
||||
<p class="header-description">可视化设计分销推广海报,配置背景、文字、昵称、头像、小程序码等元素的位置与样式</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content" style="padding: 0;">
|
||||
<PosterEditor />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 账户安全 -->
|
||||
<div v-if="activeTab === 'security'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><Lock /></el-icon>
|
||||
<span>超管账户安全</span>
|
||||
</div>
|
||||
<p class="header-description">修改超级管理员用户名和密码</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label class="form-label">用户名</label>
|
||||
<el-input
|
||||
v-model="credentials.username"
|
||||
class="form-input credentials-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">当前密码</label>
|
||||
<el-input
|
||||
v-model="credentials.currentPassword"
|
||||
type="password"
|
||||
placeholder="输入当前密码"
|
||||
show-password
|
||||
class="form-input credentials-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">新密码</label>
|
||||
<el-input
|
||||
v-model="credentials.newPassword"
|
||||
type="password"
|
||||
placeholder="输入新密码"
|
||||
show-password
|
||||
class="form-input credentials-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">确认新密码</label>
|
||||
<el-input
|
||||
v-model="credentials.confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
show-password
|
||||
class="form-input credentials-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#6366f1"
|
||||
class="save-button"
|
||||
@click="handleSave('credentials')"
|
||||
>
|
||||
<el-icon class="mr-1"><Document /></el-icon>更新账户信息
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Setting, Bell, Lock, Document, ChatDotRound, Postcard } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
|
||||
const activeTab = ref('system')
|
||||
const saveSuccess = ref<string | null>(null)
|
||||
|
||||
const tabs = [
|
||||
{ label: '系统配置', value: 'system', icon: Setting },
|
||||
{ label: '通知设置', value: 'notification', icon: Bell },
|
||||
{ label: '提示词配置', value: 'prompts', icon: ChatDotRound },
|
||||
{ label: '海报配置', value: 'poster', icon: Postcard },
|
||||
{ label: '账户安全', value: 'security', icon: Lock }
|
||||
]
|
||||
|
||||
// 系统配置
|
||||
const systemConfig = reactive({
|
||||
siteName: '神仙团队AI性格测试',
|
||||
siteDescription: '专业的AI性格测试平台',
|
||||
miniprogramName: '神仙团队AI性格测试',
|
||||
maintenanceMode: false,
|
||||
maxTestsPerDay: 100,
|
||||
trialTestCount: 10,
|
||||
})
|
||||
|
||||
// 超管凭据
|
||||
const credentials = reactive({
|
||||
username: 'admin',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
// 通知配置
|
||||
const notificationConfig = reactive({
|
||||
emailNotification: true,
|
||||
lowBalanceAlert: true,
|
||||
lowBalanceThreshold: 1000,
|
||||
newEnterpriseNotify: true,
|
||||
})
|
||||
|
||||
// 小程序文案配置(分析中提示、按钮、报告标题等)
|
||||
const textConfig = reactive({
|
||||
analyzingTitle: '正在分析中',
|
||||
startButtonText: '开始面相测试',
|
||||
startButtonEnterprise: '开始面部测试',
|
||||
reportTitle: '分析报告',
|
||||
aiAnalysisText: '智能分析',
|
||||
})
|
||||
|
||||
// 提示词配置(faceAnalyze 只存 JSON 返回模板,由后端与固定前缀拼接)
|
||||
const promptsConfig = reactive<Record<string, string>>({
|
||||
faceAnalyze: '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
|
||||
reportSummary: '',
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/settings')
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 加载系统配置
|
||||
if (response.data.system) {
|
||||
Object.assign(systemConfig, response.data.system)
|
||||
}
|
||||
// 加载小程序文案配置
|
||||
if (response.data.textConfig && typeof response.data.textConfig === 'object') {
|
||||
Object.assign(textConfig, response.data.textConfig)
|
||||
}
|
||||
|
||||
// 加载通知配置
|
||||
if (response.data.notification) {
|
||||
Object.assign(notificationConfig, response.data.notification)
|
||||
}
|
||||
|
||||
// 加载提示词配置
|
||||
if (response.data.prompts && typeof response.data.prompts === 'object') {
|
||||
Object.keys(promptsConfig).forEach(k => {
|
||||
if (response.data.prompts[k] !== undefined) {
|
||||
promptsConfig[k] = response.data.prompts[k] || ''
|
||||
}
|
||||
})
|
||||
Object.keys(response.data.prompts).forEach(k => {
|
||||
if (!(k in promptsConfig)) {
|
||||
promptsConfig[k] = response.data.prompts[k] || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载用户名
|
||||
if (response.data.username) {
|
||||
credentials.username = response.data.username
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载设置失败:', error)
|
||||
ElMessage.error(error.message || '加载设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async (section: string) => {
|
||||
try {
|
||||
let response: any
|
||||
|
||||
switch (section) {
|
||||
case 'system':
|
||||
response = await request.put('/superadmin/settings/system', {
|
||||
...systemConfig,
|
||||
textConfig
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('系统配置已保存')
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = null
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'notification':
|
||||
response = await request.put('/superadmin/settings/notification', notificationConfig)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('通知配置已保存')
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = null
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'prompts':
|
||||
response = await request.put('/superadmin/settings/prompts', { prompts: promptsConfig })
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('提示词配置已保存')
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = null
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'credentials':
|
||||
if (credentials.newPassword && credentials.newPassword !== credentials.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
response = await request.put('/superadmin/settings/credentials', {
|
||||
username: credentials.username,
|
||||
currentPassword: credentials.currentPassword,
|
||||
newPassword: credentials.newPassword,
|
||||
confirmPassword: credentials.confirmPassword
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('账户信息已更新')
|
||||
credentials.currentPassword = ''
|
||||
credentials.newPassword = ''
|
||||
credentials.confirmPassword = ''
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = null
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.success-alert {
|
||||
margin-bottom: 24px;
|
||||
background-color: #dcfce7;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
|
||||
:deep(.el-alert__content) {
|
||||
color: #166534;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 16px;
|
||||
color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.header-description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&.text-config-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.section-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
height: 36px;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #6366f1 inset, 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&.threshold-input {
|
||||
margin-left: 16px;
|
||||
margin-top: -8px;
|
||||
|
||||
.threshold-input-field {
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credentials-input {
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.prompt-two-col {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.prompt-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-textarea {
|
||||
max-width: 100%;
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
|
||||
.switch-info {
|
||||
flex: 1;
|
||||
|
||||
.switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
.switch-desc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background-color: #fffbeb;
|
||||
border: 1px solid #fef3c7;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
|
||||
.warning-icon {
|
||||
font-size: 16px;
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #6366f1;
|
||||
border-color: #6366f1;
|
||||
color: #fff;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1961
admin/src/views/superadmin/Users.vue
Normal file
1961
admin/src/views/superadmin/Users.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user