feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强

支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 20:25:46 +08:00
1 parent 0966375076
commit 8cc2c83f9d
41 files changed
+3177 -332

No files matched your search

+12
View File
@@ -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' })
}
+8
View File
@@ -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' })
}
+38
View File
@@ -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' })
}
+18 -10
View File
@@ -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('两次输入的密码不一致'))
+15 -5
View File
@@ -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',
+148 -33
View File
@@ -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>
+10 -2
View File
@@ -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
+247
View File
@@ -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>
+329
View File
@@ -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
View File
@@ -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>
+11 -1
View File
@@ -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)