feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0966375076
commit
8cc2c83f9d
41 files changed
+3177
-332
No files matched your search
@@ -29,3 +29,15 @@ export function getLicenseGrowth() {
|
||||
export function getProductDistribution() {
|
||||
return request({ url: '/analytics/product-distribution', method: 'get' })
|
||||
}
|
||||
|
||||
export function getRevenueAnalytics() {
|
||||
return request({ url: '/analytics/revenue', method: 'get' })
|
||||
}
|
||||
|
||||
export function getGeoDistribution() {
|
||||
return request({ url: '/analytics/geo-distribution', method: 'get' })
|
||||
}
|
||||
|
||||
export function getPlatformDistribution() {
|
||||
return request({ url: '/analytics/platform-distribution', method: 'get' })
|
||||
}
|
||||
@@ -49,3 +49,11 @@ export function forceOffline(licenseId, activationId) {
|
||||
export function exportLicenses(params) {
|
||||
return request({ url: '/licenses/export', method: 'get', params, responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function issueOfflineLicense(data) {
|
||||
return request({ url: '/licenses/offline', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getOfflinePublicKey() {
|
||||
return request({ url: '/licenses/offline/public-key', method: 'get' })
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import request from './request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// 系统设置与日志 API // Logan.Li
|
||||
|
||||
@@ -10,6 +12,42 @@ export function updateSetting(data) {
|
||||
return request({ url: '/settings', method: 'put', data })
|
||||
}
|
||||
|
||||
export function getWaffoSettings() {
|
||||
return request({ url: '/settings/waffo', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateWaffoSettings(data) {
|
||||
return request({ url: '/settings/waffo', method: 'put', data })
|
||||
}
|
||||
|
||||
/** Waffo 测试连接 / 调试(优先 payment,404 时回退 settings,避免误弹 Toast) // Logan.Li */
|
||||
export async function testWaffoConnection(data) {
|
||||
const userStore = useUserStore()
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(userStore.token ? { Authorization: `Bearer ${userStore.token}` } : {}),
|
||||
}
|
||||
const paths = ['/payment/waffo/debug', '/settings/waffo/test']
|
||||
let lastError = null
|
||||
for (const url of paths) {
|
||||
try {
|
||||
const res = await axios({ baseURL, url, method: 'post', data, headers, timeout: 30000 })
|
||||
const body = res.data
|
||||
if (body && (body.code === 0 || body.code === 200)) return body
|
||||
lastError = new Error(body?.message || '调试失败')
|
||||
// 业务失败也返回给页面展示,不再试下一路径 // Logan.Li
|
||||
return Promise.reject(Object.assign(lastError, { response: { data: body, status: res.status } }))
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
const status = err?.response?.status
|
||||
if (status === 404 || status === 405) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('调试接口不可用')
|
||||
}
|
||||
|
||||
export function getLicenseTemplates() {
|
||||
return request({ url: '/settings/license-templates', method: 'get' })
|
||||
}
|
||||
|
||||
@@ -25,13 +25,16 @@
|
||||
<span>授权管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/products">
|
||||
<el-icon><Box /></el-icon>产品管理
|
||||
<el-icon><Box /></el-icon>产品
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/customers">
|
||||
<el-icon><User /></el-icon>客户管理
|
||||
<el-icon><User /></el-icon>客户
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/licenses">
|
||||
<el-icon><Key /></el-icon>License 管理
|
||||
<el-icon><Key /></el-icon>在线 License
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/offline-licenses">
|
||||
<el-icon><Lock /></el-icon>离线 License
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
@@ -41,16 +44,18 @@
|
||||
<span>定价与订单</span>
|
||||
</template>
|
||||
<el-menu-item index="/plans">
|
||||
<el-icon><PriceTag /></el-icon>定价计划
|
||||
<el-icon><PriceTag /></el-icon>计划
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/orders">
|
||||
<el-icon><ShoppingCart /></el-icon>订单管理
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/entitlements">
|
||||
<el-icon><Medal /></el-icon>客户权益
|
||||
<el-icon><ShoppingCart /></el-icon>订单
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/payments">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<template #title>支付</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="system">
|
||||
<template #title>
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
@@ -141,7 +146,10 @@ if (userStore.token && !userInfo.value) {
|
||||
})
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith('/payments')) return '/payments'
|
||||
return route.path
|
||||
})
|
||||
|
||||
// 退出登录 / 修改密码 // Logan.Li
|
||||
async function handleCommand(command) {
|
||||
@@ -167,7 +175,7 @@ const pwdRules = {
|
||||
{ min: 6, message: '密码长度至少 6 位', trigger: 'blur' },
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value !== pwdForm.new_password) callback(new Error('两次输入的密码不一致'))
|
||||
|
||||
@@ -35,7 +35,13 @@ const routes = [
|
||||
path: 'licenses',
|
||||
name: 'Licenses',
|
||||
component: () => import('@/views/Licenses.vue'),
|
||||
meta: { title: 'License 管理', icon: 'Key' },
|
||||
meta: { title: '在线 License', icon: 'Key' },
|
||||
},
|
||||
{
|
||||
path: 'offline-licenses',
|
||||
name: 'OfflineLicenses',
|
||||
component: () => import('@/views/OfflineLicenses.vue'),
|
||||
meta: { title: '离线 License', icon: 'Lock' },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
@@ -50,10 +56,14 @@ const routes = [
|
||||
meta: { title: '订单管理', icon: 'ShoppingCart' },
|
||||
},
|
||||
{
|
||||
path: 'entitlements',
|
||||
name: 'Entitlements',
|
||||
component: () => import('@/views/Entitlements.vue'),
|
||||
meta: { title: '客户权益', icon: 'Medal' },
|
||||
path: 'payments',
|
||||
name: 'Payments',
|
||||
component: () => import('@/views/Payments.vue'),
|
||||
meta: { title: '支付配置', icon: 'Wallet' },
|
||||
},
|
||||
{
|
||||
path: 'payments/waffo',
|
||||
redirect: '/payments',
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="card in statCards" :key="card.title">
|
||||
<el-col :xs="12" :sm="8" :md="6" :lg="4" v-for="card in statCards" :key="card.title" class="stat-col">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon :size="36" :color="card.color">
|
||||
<el-icon :size="32" :color="card.color">
|
||||
<component :is="card.icon" />
|
||||
</el-icon>
|
||||
<div class="stat-info">
|
||||
@@ -18,24 +18,42 @@
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<!-- 收入趋势 -->
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>近 6 个月收入趋势</template>
|
||||
<v-chart class="chart" :option="revenueOption" autoresize />
|
||||
<template #header>License 增长趋势</template>
|
||||
<v-chart class="chart" :option="growthOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- 产品分布 -->
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>License 产品分布</template>
|
||||
<template #header>收入趋势</template>
|
||||
<v-chart class="chart" :option="revenueOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>产品分布</template>
|
||||
<v-chart class="chart" :option="productOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>地理分布</template>
|
||||
<v-chart class="chart" :option="geoOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>平台分布</template>
|
||||
<v-chart class="chart" :option="platformOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<!-- 即将到期 -->
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>即将到期 License(30 天内)</template>
|
||||
@@ -54,7 +72,6 @@
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- 最近活动 -->
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>最近操作</template>
|
||||
@@ -70,6 +87,7 @@
|
||||
<div style="color: #606266; font-size: 13px">{{ act.detail }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="!activities.length" description="暂无操作" :image-size="64" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -77,7 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
@@ -85,7 +103,8 @@ import { BarChart, PieChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
|
||||
import {
|
||||
getDashboardStats, getExpiringLicenses, getRecentActivities,
|
||||
getRevenueStats, getProductDistribution,
|
||||
getRevenueStats, getProductDistribution, getLicenseGrowth,
|
||||
getRevenueAnalytics, getGeoDistribution, getPlatformDistribution,
|
||||
} from '@/api/analytics'
|
||||
|
||||
use([CanvasRenderer, BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
|
||||
@@ -95,52 +114,148 @@ const expiring = ref([])
|
||||
const activities = ref([])
|
||||
const revenueData = ref([])
|
||||
const productData = ref([])
|
||||
const growthData = ref([])
|
||||
const geoData = ref([])
|
||||
const platformData = ref([])
|
||||
|
||||
function formatMoney(v) {
|
||||
if (v == null || v === '') return '—'
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ title: 'License 总数', value: stats.value.total_licenses ?? '—', icon: 'Key', color: '#409eff' },
|
||||
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#67c23a' },
|
||||
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#e6a23c' },
|
||||
{ title: '在线设备', value: stats.value.online_devices ?? '—', icon: 'Monitor', color: '#f56c6c' },
|
||||
{ title: '已过期', value: stats.value.expired_licenses ?? stats.value.expired ?? '—', icon: 'WarningFilled', color: '#e6a23c' },
|
||||
{ title: '当前在线', value: stats.value.online_devices ?? stats.value.current_online ?? '—', icon: 'Monitor', color: '#67c23a' },
|
||||
{ title: '本月收入', value: formatMoney(stats.value.month_revenue ?? stats.value.revenue_month), icon: 'Coin', color: '#f56c6c' },
|
||||
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#409eff' },
|
||||
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#909399' },
|
||||
])
|
||||
|
||||
function emptyPie() {
|
||||
return [{ name: '暂无数据', value: 0 }]
|
||||
}
|
||||
|
||||
const growthOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增', '累计'], bottom: 0 },
|
||||
grid: { left: 40, right: 20, top: 24, bottom: 40 },
|
||||
xAxis: { type: 'category', data: growthData.value.map((d) => d.period || d.date || d.month) },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{
|
||||
name: '新增',
|
||||
type: 'bar',
|
||||
data: growthData.value.map((d) => d.new_count ?? d.count ?? d.value ?? 0),
|
||||
color: '#409eff',
|
||||
},
|
||||
{
|
||||
name: '累计',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: growthData.value.map((d) => d.total_count ?? d.total ?? null),
|
||||
color: '#67c23a',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const revenueOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period) },
|
||||
grid: { left: 48, right: 16, top: 24, bottom: 28 },
|
||||
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period || d.date || d.month) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: revenueData.value.map((d) => d.amount), type: 'line', smooth: true, areaStyle: {}, color: '#409eff' }],
|
||||
series: [{
|
||||
data: revenueData.value.map((d) => d.amount ?? d.revenue ?? d.value ?? 0),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.15 },
|
||||
color: '#f56c6c',
|
||||
}],
|
||||
}))
|
||||
|
||||
const productOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '70%'],
|
||||
data: productData.value.map((d) => ({ name: d.product_name || '未知', value: d.count })),
|
||||
type: 'pie',
|
||||
radius: ['40%', '68%'],
|
||||
data: (productData.value.length ? productData.value : emptyPie()).map((d) => ({
|
||||
name: d.product_name || d.name || '未知',
|
||||
value: d.count ?? d.value ?? 0,
|
||||
})),
|
||||
}],
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
const geoOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['35%', '65%'],
|
||||
data: (geoData.value.length ? geoData.value : emptyPie()).map((d) => ({
|
||||
name: d.country || d.region || d.name || '未知',
|
||||
value: d.count ?? d.value ?? 0,
|
||||
})),
|
||||
}],
|
||||
}))
|
||||
|
||||
const platformOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 48, right: 16, top: 24, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: (platformData.value.length ? platformData.value : [{ name: '暂无', count: 0 }]).map(
|
||||
(d) => d.platform || d.name || '未知',
|
||||
),
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: (platformData.value.length ? platformData.value : [{ count: 0 }]).map((d) => d.count ?? d.value ?? 0),
|
||||
color: '#909399',
|
||||
}],
|
||||
}))
|
||||
|
||||
async function safe(fn) {
|
||||
try {
|
||||
const [s, e, a, r, p] = await Promise.all([
|
||||
getDashboardStats(), getExpiringLicenses(30), getRecentActivities(8),
|
||||
getRevenueStats(), getProductDistribution(),
|
||||
])
|
||||
stats.value = s.data
|
||||
expiring.value = e.data
|
||||
activities.value = a.data
|
||||
revenueData.value = r.data
|
||||
productData.value = p.data
|
||||
return await fn()
|
||||
} catch {
|
||||
// 拦截器已处理
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [s, e, a, r, p, g, geo, plat, ra] = await Promise.all([
|
||||
safe(() => getDashboardStats()),
|
||||
safe(() => getExpiringLicenses(30)),
|
||||
safe(() => getRecentActivities(8)),
|
||||
safe(() => getRevenueStats()),
|
||||
safe(() => getProductDistribution()),
|
||||
safe(() => getLicenseGrowth()),
|
||||
safe(() => getGeoDistribution()),
|
||||
safe(() => getPlatformDistribution()),
|
||||
safe(() => getRevenueAnalytics()),
|
||||
])
|
||||
stats.value = s?.data || {}
|
||||
expiring.value = e?.data || []
|
||||
activities.value = a?.data || []
|
||||
const rev = ra?.data || r?.data || []
|
||||
revenueData.value = Array.isArray(rev) ? rev : (rev.list || rev.series || [])
|
||||
productData.value = p?.data || []
|
||||
growthData.value = g?.data || []
|
||||
geoData.value = geo?.data || []
|
||||
platformData.value = plat?.data || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { margin-bottom: 0; }
|
||||
.stat-content { display: flex; align-items: center; gap: 16px; }
|
||||
.stat-info { display: flex; flex-direction: column; }
|
||||
.stat-col { margin-bottom: 16px; }
|
||||
.stat-card { height: 100%; }
|
||||
.stat-content { display: flex; align-items: center; gap: 14px; }
|
||||
.stat-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.stat-title { font-size: 13px; color: #909399; }
|
||||
.stat-value { font-size: 26px; font-weight: 600; color: #303133; }
|
||||
.stat-value { font-size: 24px; font-weight: 600; color: #303133; line-height: 1.2; word-break: break-all; }
|
||||
.chart { height: 280px; }
|
||||
</style>
|
||||
@@ -17,6 +17,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="license_key" label="License Key" min-width="220" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||
@@ -167,6 +175,7 @@ async function loadData() {
|
||||
const res = await getLicenses({
|
||||
page: page.value, page_size: pageSize.value,
|
||||
keyword: keyword.value, status: statusFilter.value, product_id: productFilter.value,
|
||||
channel: 'online',
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
@@ -220,14 +229,13 @@ async function toggle(row, status) {
|
||||
async function handleForceOffline(act) {
|
||||
await ElMessageBox.confirm(`确定强制下线设备「${act.device_name || act.device_id}」?`, '提示', { type: 'warning' })
|
||||
await forceOffline(detail.license.id, act.id)
|
||||
// 刷新详情 // Logan.Li
|
||||
const res = await getLicense(detail.license.id)
|
||||
detail.activations = res.data.activations || []
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
try {
|
||||
const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value })
|
||||
const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value, channel: 'online' })
|
||||
const url = window.URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<el-input v-model="keyword" placeholder="搜索短码 / 设备 ID / 客户" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" />
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="loadData">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" @click="openIssue">签发离线序列号</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线证不可续期:绑定固定设备、最长 365 天;到期请「到期重签」签发新序列号,旧证不会自动延长。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column label="序列号" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="serial-cell">
|
||||
<code class="serial-short">{{ row.license_key_short || row.license_key }}</code>
|
||||
<el-button size="small" link type="primary" @click="copyKey(row.license_key)">复制全文</el-button>
|
||||
<el-button size="small" link @click="showFull(row)">查看</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_id" label="绑定设备" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="end_date" label="到期" width="110" />
|
||||
<el-table-column label="剩余" width="70">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left }}天</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="reissue(row)">到期重签</el-button>
|
||||
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="issueVisible" title="签发离线序列号" width="560px">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="需用户提供 device_id。适用于 Web / PC / 移动端通用离线激活(客户端本地验签)。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="设备 ID" prop="device_id">
|
||||
<el-input v-model="form.device_id" placeholder="客户端复制的 device_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="客户名"><el-input v-model="form.customer_name" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="产品编码"><el-input v-model="form.product_code" placeholder="默认 opsxshell,可改多产品" /></el-form-item>
|
||||
<el-form-item label="计划" prop="plan_type">
|
||||
<el-select v-model="form.plan_type" style="width: 100%" @change="onPlanChange">
|
||||
<el-option value="yearly" label="包年 ≤365 天" />
|
||||
<el-option value="trial" label="体验 ≤180 天" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效天数" prop="duration_days">
|
||||
<el-input-number v-model="form.duration_days" :min="1" :max="maxDays" />
|
||||
<span style="margin-left: 8px; color: #909399">最长 {{ maxDays }} 天 · 禁止永久</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="result" class="result-box">
|
||||
<div class="result-label">完整序列号(仅此时完整展示,请立即复制给用户)</div>
|
||||
<el-input type="textarea" :rows="3" :model-value="result.serial" readonly class="serial-full" />
|
||||
<div class="result-meta">到期:{{ result.expires_at }} · 设备:{{ result.device_id }}</div>
|
||||
<el-button size="small" type="primary" style="margin-top: 8px" @click="copyKey(result.serial)">复制序列号</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="issueVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="issuing" @click="submitIssue">签发</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="fullVisible" title="完整序列号" width="640px">
|
||||
<el-input type="textarea" :rows="5" :model-value="fullSerial" readonly class="serial-full" />
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="copyKey(fullSerial)">复制</el-button>
|
||||
<el-button @click="fullVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getLicenses, issueOfflineLicense, revokeLicense } from '@/api/license'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
|
||||
{ label: '已过期', value: 'expired' }, { label: '已吊销', value: 'revoked' }, { label: '已暂停', value: 'suspended' },
|
||||
]
|
||||
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
|
||||
function statusTag(s) {
|
||||
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || ''
|
||||
}
|
||||
|
||||
const issueVisible = ref(false)
|
||||
const issuing = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
device_id: '', email: '', customer_name: '', product_code: 'opsxshell',
|
||||
plan_type: 'yearly', duration_days: 365, notes: '',
|
||||
})
|
||||
const result = ref(null)
|
||||
const maxDays = computed(() => (form.plan_type === 'trial' ? 180 : 365))
|
||||
const rules = {
|
||||
device_id: [{ required: true, message: '请输入 device_id' }],
|
||||
plan_type: [{ required: true, message: '请选择计划' }],
|
||||
duration_days: [{ required: true, message: '请输入天数' }],
|
||||
}
|
||||
|
||||
const fullVisible = ref(false)
|
||||
const fullSerial = ref('')
|
||||
|
||||
function onPlanChange() {
|
||||
if (form.duration_days > maxDays.value) form.duration_days = maxDays.value
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLicenses({
|
||||
page: page.value, page_size: pageSize.value,
|
||||
keyword: keyword.value, status: statusFilter.value, channel: 'offline',
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openIssue() {
|
||||
Object.assign(form, {
|
||||
device_id: '', email: '', customer_name: '', product_code: 'opsxshell',
|
||||
plan_type: 'yearly', duration_days: 365, notes: '',
|
||||
})
|
||||
result.value = null
|
||||
issueVisible.value = true
|
||||
}
|
||||
|
||||
function reissue(row) {
|
||||
Object.assign(form, {
|
||||
device_id: row.device_id || '',
|
||||
email: '',
|
||||
customer_name: row.customer_name || '',
|
||||
product_code: 'opsxshell',
|
||||
plan_type: row.is_trial ? 'trial' : 'yearly',
|
||||
duration_days: row.is_trial ? 180 : 365,
|
||||
notes: `重签自 #${row.id}`,
|
||||
})
|
||||
result.value = null
|
||||
issueVisible.value = true
|
||||
}
|
||||
|
||||
async function submitIssue() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
issuing.value = true
|
||||
try {
|
||||
const res = await issueOfflineLicense({
|
||||
device_id: form.device_id.trim(),
|
||||
email: form.email.trim() || undefined,
|
||||
customer_name: form.customer_name.trim() || undefined,
|
||||
plan_type: form.plan_type,
|
||||
duration_days: form.duration_days,
|
||||
notes: form.notes,
|
||||
product_code: form.product_code.trim() || 'opsxshell',
|
||||
})
|
||||
result.value = res.data
|
||||
loadData()
|
||||
ElMessage.success('已签发(请复制序列号;列表仅显示短码)')
|
||||
} catch { /* */ } finally {
|
||||
issuing.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showFull(row) {
|
||||
fullSerial.value = row.license_key
|
||||
fullVisible.value = true
|
||||
}
|
||||
|
||||
async function copyKey(text) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择')
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
const { value } = await ElMessageBox.prompt('吊销原因', '吊销离线证', { inputType: 'textarea' })
|
||||
await revokeLicense(row.id, value || '')
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
.filters { display: flex; gap: 10px; }
|
||||
.serial-cell { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.serial-short { font-size: 12px; background: #f5f7fa; padding: 2px 6px; border-radius: 4px; }
|
||||
.serial-full :deep(textarea) { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
|
||||
.result-box { margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
|
||||
.result-label { font-size: 13px; margin-bottom: 6px; color: #606266; }
|
||||
.result-meta { margin-top: 6px; font-size: 12px; color: #909399; }
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="title">Waffo 支付</span>
|
||||
<el-tag size="small" type="info" style="margin-left: 8px">后台配置优先于 .env</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="debugging" @click="testConnection">测试连接 / 调试</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="商户 ID / Store / 私钥来自 Waffo Dashboard「集成」页。复制密钥后粘贴保存即可,无需手改服务器 .env。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<el-form :model="waffo" label-width="140px" style="max-width: 860px">
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="waffo.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商户 ID">
|
||||
<el-input v-model="waffo.merchant_id" placeholder="MER_…" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Store ID">
|
||||
<el-input v-model="waffo.store_id" placeholder="STO_…(备用)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Store Slug">
|
||||
<el-input v-model="waffo.store_slug" placeholder="如 opsxshell-pwsi6m4n" />
|
||||
</el-form-item>
|
||||
<el-form-item label="环境">
|
||||
<el-radio-group v-model="waffo.environment">
|
||||
<el-radio value="test">测试 (test)</el-radio>
|
||||
<el-radio value="live">生产 (live)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 私钥">
|
||||
<div class="secret-row">
|
||||
<el-tag :type="waffo.private_key_configured ? 'success' : 'warning'" size="small">
|
||||
{{ waffo.private_key_configured ? '已配置' : '未配置' }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="showPrivateKey = !showPrivateKey">
|
||||
{{ showPrivateKey ? '取消更换' : (waffo.private_key_configured ? '更换' : '填写') }}
|
||||
</el-button>
|
||||
<el-button v-if="waffo.private_key_configured" size="small" type="danger" link @click="clearPrivateKey">清除</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-if="showPrivateKey"
|
||||
v-model="privateKeyInput"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="粘贴 PEM 或 Dashboard Base64 密钥(保存后不再回显)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="货币">
|
||||
<el-input v-model="waffo.currency" style="width: 120px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SUCCESS_URL">
|
||||
<el-input v-model="waffo.success_url" placeholder="支付成功回跳" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook URL">
|
||||
<el-input :model-value="waffo.webhook_url" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyWebhook">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="hint">在 Waffo Dashboard → Webhooks 配置此地址(POST,Raw JSON,order.completed)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">Product ID 映射</span>
|
||||
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="将计划类型 / 业务 key 映射到 Waffo Product ID(如 yearly → PROD_xxx)。保存时会同步兼容字段 product_id_yearly / product_id_perpetual。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-table :data="productMappings" border stripe>
|
||||
<el-table-column label="计划类型 / Key" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.plan_key"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="如 yearly / perpetual / trial"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in planKeyOptions" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.label" placeholder="可选说明" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Waffo Product ID" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.product_id" placeholder="PROD_…" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" link @click="removeMapping($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="debugResult" shadow="never" style="margin-top: 16px">
|
||||
<template #header>调试结果</template>
|
||||
<pre class="debug-box">{{ debugResult }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getWaffoSettings, updateWaffoSettings, testWaffoConnection } from '@/api/setting'
|
||||
|
||||
const planKeyOptions = ['trial', 'yearly', 'perpetual', 'monthly', 'lifetime']
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const debugging = ref(false)
|
||||
const showPrivateKey = ref(false)
|
||||
const privateKeyInput = ref('')
|
||||
const clearKeyFlag = ref(false)
|
||||
const debugResult = ref('')
|
||||
const productMappings = ref([])
|
||||
|
||||
const waffo = reactive({
|
||||
enabled: true,
|
||||
merchant_id: '',
|
||||
store_id: '',
|
||||
store_slug: '',
|
||||
environment: 'test',
|
||||
currency: 'CNY',
|
||||
product_id: '',
|
||||
product_id_yearly: '',
|
||||
product_id_perpetual: '',
|
||||
product_mappings: [],
|
||||
success_url: '',
|
||||
webhook_url: 'https://l123.91ai.io/api/v1/webhooks/waffo',
|
||||
private_key_configured: false,
|
||||
})
|
||||
|
||||
function normalizeMappings(data = {}) {
|
||||
if (Array.isArray(data.product_mappings) && data.product_mappings.length) {
|
||||
return data.product_mappings.map((m) => ({
|
||||
plan_key: m.plan_key || m.key || m.plan_type || '',
|
||||
label: m.label || '',
|
||||
product_id: m.product_id || '',
|
||||
}))
|
||||
}
|
||||
const rows = []
|
||||
if (data.product_id_yearly) {
|
||||
rows.push({ plan_key: 'yearly', label: '包年', product_id: data.product_id_yearly })
|
||||
}
|
||||
if (data.product_id_perpetual || data.product_id) {
|
||||
rows.push({
|
||||
plan_key: 'perpetual',
|
||||
label: '永久',
|
||||
product_id: data.product_id_perpetual || data.product_id,
|
||||
})
|
||||
}
|
||||
if (!rows.length) {
|
||||
rows.push(
|
||||
{ plan_key: 'yearly', label: '包年', product_id: '' },
|
||||
{ plan_key: 'perpetual', label: '永久', product_id: '' },
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function mappingsToCompatFields(rows) {
|
||||
const map = {}
|
||||
for (const row of rows) {
|
||||
const key = (row.plan_key || '').trim()
|
||||
if (!key) continue
|
||||
map[key] = (row.product_id || '').trim()
|
||||
}
|
||||
return {
|
||||
product_mappings: rows
|
||||
.filter((r) => (r.plan_key || '').trim())
|
||||
.map((r) => ({
|
||||
plan_key: r.plan_key.trim(),
|
||||
label: r.label || '',
|
||||
product_id: (r.product_id || '').trim(),
|
||||
})),
|
||||
product_id_yearly: map.yearly || '',
|
||||
product_id_perpetual: map.perpetual || map.lifetime || '',
|
||||
product_id: map.perpetual || map.lifetime || map.yearly || '',
|
||||
}
|
||||
}
|
||||
|
||||
function addMapping() {
|
||||
productMappings.value.push({ plan_key: '', label: '', product_id: '' })
|
||||
}
|
||||
|
||||
function removeMapping(index) {
|
||||
productMappings.value.splice(index, 1)
|
||||
if (!productMappings.value.length) addMapping()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getWaffoSettings()
|
||||
Object.assign(waffo, res.data || {})
|
||||
if (!waffo.environment) waffo.environment = waffo.sandbox_mode === false ? 'live' : 'test'
|
||||
productMappings.value = normalizeMappings(res.data || {})
|
||||
showPrivateKey.value = false
|
||||
privateKeyInput.value = ''
|
||||
clearKeyFlag.value = false
|
||||
debugResult.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const compat = mappingsToCompatFields(productMappings.value)
|
||||
const payload = {
|
||||
enabled: waffo.enabled,
|
||||
merchant_id: waffo.merchant_id,
|
||||
store_id: waffo.store_id,
|
||||
store_slug: waffo.store_slug,
|
||||
environment: waffo.environment,
|
||||
currency: waffo.currency,
|
||||
success_url: waffo.success_url,
|
||||
clear_private_key: clearKeyFlag.value,
|
||||
...compat,
|
||||
}
|
||||
if (showPrivateKey.value && privateKeyInput.value.trim()) {
|
||||
payload.private_key = privateKeyInput.value.trim()
|
||||
}
|
||||
const res = await updateWaffoSettings(payload)
|
||||
Object.assign(waffo, res.data || {})
|
||||
productMappings.value = normalizeMappings(res.data || { ...compat })
|
||||
showPrivateKey.value = false
|
||||
privateKeyInput.value = ''
|
||||
clearKeyFlag.value = false
|
||||
ElMessage.success('支付配置已保存')
|
||||
} catch { /* */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearPrivateKey() {
|
||||
clearKeyFlag.value = true
|
||||
waffo.private_key_configured = false
|
||||
privateKeyInput.value = ''
|
||||
showPrivateKey.value = false
|
||||
ElMessage.info('将在保存后清除私钥')
|
||||
}
|
||||
|
||||
async function copyWebhook() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(waffo.webhook_url || '')
|
||||
ElMessage.success('已复制 Webhook URL')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
debugging.value = true
|
||||
debugResult.value = ''
|
||||
try {
|
||||
const compat = mappingsToCompatFields(productMappings.value)
|
||||
const res = await testWaffoConnection({
|
||||
merchant_id: waffo.merchant_id,
|
||||
store_id: waffo.store_id,
|
||||
store_slug: waffo.store_slug,
|
||||
environment: waffo.environment,
|
||||
...compat,
|
||||
})
|
||||
debugResult.value = JSON.stringify(res.data ?? res, null, 2)
|
||||
ElMessage.success('调试请求已完成')
|
||||
} catch (err) {
|
||||
debugResult.value = JSON.stringify({
|
||||
error: err?.response?.data || err?.message || String(err),
|
||||
}, null, 2)
|
||||
} finally {
|
||||
debugging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.title { font-weight: 600; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
.secret-row { display: flex; align-items: center; gap: 10px; }
|
||||
.hint { margin-top: 6px; font-size: 12px; color: #909399; }
|
||||
.debug-box {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
overflow: auto;
|
||||
max-height: 360px;
|
||||
}
|
||||
</style>
|
||||
+108
-16
@@ -2,14 +2,24 @@
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 200px" @change="loadData" />
|
||||
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 220px" @change="onFilterChange">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="openCreate">新增计划</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="product_name" label="产品" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="primary" effect="plain">{{ productLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="计划名称" min-width="130" />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
|
||||
<el-table-column label="类型" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ planTypeLabel(row.plan_type) }}
|
||||
<span class="type-code">{{ row.plan_type }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="100">
|
||||
<template #default="{ row }">{{ row.price }} {{ row.currency }}</template>
|
||||
@@ -43,18 +53,36 @@
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="540px">
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="560px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="产品" prop="product_id">
|
||||
<el-select v-model="form.product_id" filterable :disabled="!!form.id" style="width: 100%">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-form-item label="产品" prop="product_id" class="product-field">
|
||||
<div class="product-picker">
|
||||
<el-select
|
||||
v-model="form.product_id"
|
||||
filterable
|
||||
:disabled="!!form.id"
|
||||
placeholder="请选择所属产品"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id">
|
||||
<span>{{ p.name }}</span>
|
||||
<span class="opt-code">{{ p.code || p.product_code || '' }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="product-hint">
|
||||
{{ form.id ? '编辑时不可更换产品,避免影响已售订单' : '计划必须归属某个产品,列表与筛选均按产品维度管理' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:体验 / 包年 / 永久" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="类型" prop="plan_type">
|
||||
<el-select v-model="form.plan_type" style="width: 100%">
|
||||
<el-option value="perpetual" label="买断" /><el-option value="lifetime" label="终身" />
|
||||
<el-option value="monthly" label="月订阅" /><el-option value="yearly" label="年订阅" />
|
||||
<el-select v-model="form.plan_type" style="width: 100%" @change="onPlanTypeChange">
|
||||
<el-option value="trial" label="体验 (Trial)" />
|
||||
<el-option value="yearly" label="包年 (Yearly)" />
|
||||
<el-option value="perpetual" label="永久 (Perpetual)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="price"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||
@@ -89,7 +117,7 @@ const total = ref(0)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||
const form = reactive({ id: null, product_id: null, name: '', plan_type: 'yearly', price: 0, currency: 'CNY', duration: 365, max_devices: 2, features: [] })
|
||||
const rules = {
|
||||
product_id: [{ required: true, message: '请选择产品' }],
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
@@ -97,19 +125,60 @@ const rules = {
|
||||
price: [{ required: true, message: '请输入价格' }],
|
||||
}
|
||||
|
||||
function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t }
|
||||
const PLAN_DEFAULTS = {
|
||||
trial: { name: '体验', duration: 180, max_devices: 1, price: 0 },
|
||||
yearly: { name: '包年', duration: 365, max_devices: 2, price: 99 },
|
||||
perpetual: { name: '永久', duration: 0, max_devices: 5, price: 199 },
|
||||
}
|
||||
|
||||
function planTypeLabel(t) {
|
||||
return { trial: '体验', yearly: '包年', perpetual: '永久', lifetime: '永久', monthly: '月订阅' }[t] || t
|
||||
}
|
||||
|
||||
function productLabel(row) {
|
||||
if (row.product_name) return row.product_name
|
||||
const p = products.value.find((x) => x.id === row.product_id)
|
||||
return p?.name || `产品 #${row.product_id || '—'}`
|
||||
}
|
||||
|
||||
function onPlanTypeChange(t) {
|
||||
const d = PLAN_DEFAULTS[t]
|
||||
if (!d) return
|
||||
if (!form.id) {
|
||||
form.name = d.name
|
||||
form.duration = d.duration
|
||||
form.max_devices = d.max_devices
|
||||
form.price = d.price
|
||||
form.currency = 'CNY'
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value })
|
||||
const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value || undefined })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
product_id: productFilter.value || null,
|
||||
name: '包年',
|
||||
plan_type: 'yearly',
|
||||
price: 99,
|
||||
currency: 'CNY',
|
||||
duration: 365,
|
||||
max_devices: 2,
|
||||
features: [],
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -149,4 +218,27 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
.type-code { display: block; font-size: 11px; color: #909399; }
|
||||
.product-field :deep(.el-form-item__label) {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
.product-picker {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9ecff;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #f0f7ff 0%, #fff 100%);
|
||||
}
|
||||
.product-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.opt-code {
|
||||
float: right;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -46,6 +46,14 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Waffo 支付配置已迁至「支付」菜单,可在侧边栏直接进入。"
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
|
||||
<!-- 模板对话框 -->
|
||||
<el-dialog v-model="tmplVisible" :title="tmplForm.id ? '编辑模板' : '新增模板'" width="440px">
|
||||
<el-form :model="tmplForm" label-width="90px">
|
||||
@@ -65,7 +73,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate } from '@/api/setting'
|
||||
import {
|
||||
getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate,
|
||||
} from '@/api/setting'
|
||||
|
||||
const setting = reactive({})
|
||||
const loadingSetting = ref(false)
|
||||
|
||||
Reference in new issue
Block a user