You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
532 lines
20 KiB
532 lines
20 KiB
<template>
|
|
<div>
|
|
<!-- 列表视图 -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">应用注册</h2>
|
|
<p class="text-sm text-slate-400 mt-1">注册各应用的地址与业务库 DSN(仅「重置设备码」清用户绑定时用);厂家/产品/设备码统一在 console 主库,无需逐应用配。</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 注册应用</button>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th><th>名称</th><th>区域</th><th>应用地址</th><th>业务库</th><th>启用</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
|
|
</tr>
|
|
<tr v-else-if="!rows.length">
|
|
<td colspan="7" class="text-center text-slate-400 py-8">暂无注册应用</td>
|
|
</tr>
|
|
<tr v-for="a in rows" :key="a.id">
|
|
<td class="font-mono">{{ a.id }}</td>
|
|
<td class="font-medium">{{ a.name || '' }}</td>
|
|
<td>{{ a.region || '' }}</td>
|
|
<td class="text-xs text-slate-500">{{ a.base_url || '' }}</td>
|
|
<td class="font-mono text-xs">{{ dbName(a.service_dsn) }}</td>
|
|
<td>
|
|
<span v-if="a.enabled" class="text-green-600">是</span>
|
|
<span v-else class="text-slate-400">否</span>
|
|
</td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-xs text-indigo-600" @click="openAppConfig(a)">服务配置</button>
|
|
<button class="btn btn-ghost btn-xs" @click="showForm(a)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-emerald-600" @click="openAnalyze(a.id, a.name || ('应用#' + a.id))">分析历史</button>
|
|
<button class="btn btn-ghost btn-xs text-indigo-500" @click="notifyApp(a.id)">发事件</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delApp(a.id)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 表单视图 -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑应用' : '注册应用' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
|
|
<form @submit.prevent="saveApp">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">名称 *</span></label>
|
|
<input v-model="form.name" type="text" class="input input-bordered w-full" required placeholder="如 Voitrans(国内)" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">区域</span></label>
|
|
<select v-model="form.region" class="select select-bordered w-full">
|
|
<option value="">未指定</option>
|
|
<option value="中国">中国</option>
|
|
<option value="美国">美国</option>
|
|
<option value="东亚">东亚</option>
|
|
<option value="东南亚">东南亚</option>
|
|
<option value="南亚">南亚</option>
|
|
<option value="中东">中东</option>
|
|
<option value="欧洲">欧洲</option>
|
|
<option value="俄罗斯">俄罗斯</option>
|
|
<option value="非洲">非洲</option>
|
|
<option value="南美">南美</option>
|
|
<option value="北美">北美</option>
|
|
<option value="大洋洲">大洋洲</option>
|
|
<option value="巴西">巴西</option>
|
|
<option value="印度">印度</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">应用地址 *</span></label>
|
|
<input v-model="form.base_url" type="text" class="input input-bordered w-full" required placeholder="https://api.xxx.com/" @input="resetTest" />
|
|
<span class="text-xs text-slate-400 mt-1">用于通知应用业务服务事件(如配置更新)</span>
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">业务库 DSN *</span></label>
|
|
<input v-model="form.service_dsn" type="text" class="input input-bordered w-full font-mono text-xs" required placeholder="root:pwd@tcp(host:3306)/earphone?charset=utf8mb4&parseTime=True&loc=Local" @input="resetTest" />
|
|
<span class="text-xs text-slate-400 mt-1">用于读取业务数据(重置设备码时清 userdevice/product_stat)</span>
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<input v-model="form.remark" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input v-model="form.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="text-sm">启用</span>
|
|
</label>
|
|
</div>
|
|
<div v-show="testResult.show" class="mt-4 text-sm">
|
|
<div :class="testResult.apiOk ? 'text-green-700' : 'text-red-600'">
|
|
<span :class="testResult.apiOk ? 'text-green-600' : 'text-red-500'">{{ testResult.apiOk ? '✓' : '✗' }}</span>
|
|
应用地址:{{ testResult.apiMsg }}
|
|
</div>
|
|
<div :class="testResult.dbOk ? 'text-green-700' : 'text-red-600'">
|
|
<span :class="testResult.dbOk ? 'text-green-600' : 'text-red-500'">{{ testResult.dbOk ? '✓' : '✗' }}</span>
|
|
业务库:{{ testResult.dbMsg }}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 mt-6">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="button" class="btn btn-outline" :disabled="testing" @click="testConn">{{ testing ? '测试中...' : '测试连接' }}</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="!testPassed">保存</button>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-2">需先「测试连接」通过(应用地址可达 + 业务库可连)才能保存。</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 历史数据分析弹窗 -->
|
|
<dialog ref="analyzeModal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg">分析历史数据</h3>
|
|
<p class="text-sm text-slate-500 mt-1">{{ az.appLabel }}</p>
|
|
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed">
|
|
扫描该应用业务库,按 <b>天 + 产品 + 区域</b> 生成历史统计(新增用户 / 订单 / 资源发放·使用 / 激活绑定设备),汇入 console 主库。<br>
|
|
· <b>分析前会先清理该应用+区域在所选区间内的旧统计行</b>,避免数据重叠。<br>
|
|
· 登录、活跃用户、AI对话等无每日原始记录,历史无法还原(由实时管道往后采集)。<br>
|
|
· 仅对实时管道上线前的区间运行。留空 = 全部历史。大库可能耗时。
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mt-4">
|
|
<div><label class="text-xs text-slate-500">起始日(含)</label><input v-model="az.start" type="date" class="input input-bordered input-sm w-full" /></div>
|
|
<div><label class="text-xs text-slate-500">结束日(含)</label><input v-model="az.end" type="date" class="input input-bordered input-sm w-full" /></div>
|
|
</div>
|
|
<div v-show="az.resultShow" class="text-sm mt-3" :class="az.resultClass">
|
|
<!-- 进度态 -->
|
|
<div v-if="az.mode === 'progress'" class="bg-slate-50 border border-slate-200 rounded-lg p-3">
|
|
<div class="flex justify-between items-center text-xs mb-1.5">
|
|
<span class="text-slate-600">
|
|
<template v-if="az.curName">
|
|
<span class="loading loading-spinner loading-xs"></span> 正在:{{ az.curName }} ({{ az.curIndex }}/{{ az.curTotal }})
|
|
</template>
|
|
<template v-else>准备中...</template>
|
|
</span>
|
|
<span class="font-mono text-slate-400">{{ az.pct }}%</span>
|
|
</div>
|
|
<progress class="progress progress-primary w-full h-2" :value="az.pct" max="100"></progress>
|
|
<div class="mt-2 text-xs space-y-0.5">
|
|
<div v-for="(nm, i) in az.steps" :key="i" :class="i === az.curIndex - 1 ? 'text-slate-600' : 'text-green-600'">
|
|
<template v-if="i === az.curIndex - 1"><span class="loading loading-spinner loading-xs"></span> {{ nm }}</template>
|
|
<template v-else>✓ {{ nm }}</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- 错误态 -->
|
|
<div v-else-if="az.mode === 'error'" class="text-red-600">✗ {{ az.errMsg }}</div>
|
|
<!-- 完成报告 -->
|
|
<div v-else-if="az.mode === 'done' && az.report" class="bg-green-50 border border-green-100 rounded-lg p-3">
|
|
<div class="font-semibold text-green-700 mb-2">✓ 分析完成</div>
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-600 mb-2">
|
|
<div>应用:<b>{{ az.report.app_name || '' }}</b></div>
|
|
<div>区域:{{ az.report.region || '未指定' }}</div>
|
|
<div>区间:{{ (az.report.start_day || '不限') + ' ~ ' + (az.report.end_day || '不限') }}</div>
|
|
<div>清理旧行:{{ az.report.cleared_rows || 0 }}</div>
|
|
<div>写入天数:<b>{{ az.report.days_written || 0 }}</b></div>
|
|
<div>写入行数:{{ az.report.rows_written || 0 }}</div>
|
|
</div>
|
|
<div class="border-t border-green-100 pt-2 text-xs">
|
|
<div v-for="(s, i) in (az.report.steps || [])" :key="i" class="flex justify-between py-0.5">
|
|
<span class="text-slate-500">{{ s.name }}</span>
|
|
<span class="font-mono text-slate-700">{{ (s.groups || 0) + ' 组' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm" @click="closeAnalyze">关闭</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="az.running" @click="runAnalyze">{{ az.btnText }}</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
|
|
definePageMeta({ title: '应用注册' })
|
|
|
|
interface AppItem {
|
|
id: number
|
|
name: string
|
|
region: string
|
|
base_url: string
|
|
service_dsn: string
|
|
remark: string
|
|
enabled: boolean
|
|
}
|
|
|
|
interface ReportStep {
|
|
name: string
|
|
groups: number
|
|
}
|
|
|
|
interface AnalyzeReport {
|
|
app_name: string
|
|
region: string
|
|
start_day: number
|
|
end_day: number
|
|
cleared_rows: number
|
|
days_written: number
|
|
rows_written: number
|
|
steps: ReportStep[]
|
|
}
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
const auth = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
const view = ref<'list' | 'form'>('list')
|
|
const rows = ref<AppItem[]>([])
|
|
const loading = ref(false)
|
|
const isEditing = ref(false)
|
|
|
|
// 测试连接态:通过才允许保存;改了地址/DSN 需重测
|
|
const testPassed = ref(false)
|
|
const testing = ref(false)
|
|
const testResult = reactive({
|
|
show: false,
|
|
apiOk: false,
|
|
apiMsg: '',
|
|
dbOk: false,
|
|
dbMsg: '',
|
|
})
|
|
|
|
const form = reactive({
|
|
id: 0,
|
|
name: '',
|
|
region: '',
|
|
base_url: '',
|
|
service_dsn: '',
|
|
remark: '',
|
|
enabled: true,
|
|
})
|
|
|
|
function dbName(dsn?: string) {
|
|
const m = (dsn || '').match(/\/([^/?]+)\?/)
|
|
return m ? m[1] : '-'
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
// consoleApi('apps/list') 已解包,返回应用数组本体
|
|
const data = await consoleApi<AppItem[]>('apps/list', {})
|
|
rows.value = data ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取应用列表失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function resetTest() {
|
|
testPassed.value = false
|
|
testResult.show = false
|
|
testResult.apiMsg = ''
|
|
testResult.dbMsg = ''
|
|
}
|
|
|
|
function showList() {
|
|
view.value = 'list'
|
|
loadData()
|
|
}
|
|
|
|
function showForm(a?: AppItem) {
|
|
if (a) {
|
|
isEditing.value = true
|
|
form.id = a.id
|
|
form.name = a.name || ''
|
|
form.region = a.region || ''
|
|
form.base_url = a.base_url || ''
|
|
form.service_dsn = a.service_dsn || ''
|
|
form.remark = a.remark || ''
|
|
form.enabled = !!a.enabled
|
|
} else {
|
|
isEditing.value = false
|
|
form.id = 0
|
|
form.name = ''
|
|
form.region = ''
|
|
form.base_url = ''
|
|
form.service_dsn = ''
|
|
form.remark = ''
|
|
form.enabled = true
|
|
}
|
|
resetTest() // 进入表单先清空测试态,必须重新「测试连接」通过才能保存
|
|
view.value = 'form'
|
|
}
|
|
|
|
async function testConn() {
|
|
const baseUrl = form.base_url.trim()
|
|
const serviceDsn = form.service_dsn.trim()
|
|
if (!baseUrl || !serviceDsn) {
|
|
error('请先填应用地址和业务库 DSN')
|
|
return
|
|
}
|
|
testing.value = true
|
|
try {
|
|
const d = await consoleApi<{
|
|
ok: boolean
|
|
api_ok: boolean
|
|
api_msg: string
|
|
db_ok: boolean
|
|
db_msg: string
|
|
}>('apps/test', { base_url: baseUrl, service_dsn: serviceDsn })
|
|
testResult.apiOk = !!d.api_ok
|
|
testResult.apiMsg = d.api_msg
|
|
testResult.dbOk = !!d.db_ok
|
|
testResult.dbMsg = d.db_msg
|
|
testResult.show = true
|
|
testPassed.value = !!d.ok
|
|
if (testPassed.value) success('检测通过,可以保存')
|
|
else error('检测未通过')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '测试失败')
|
|
} finally {
|
|
testing.value = false
|
|
}
|
|
}
|
|
|
|
async function saveApp() {
|
|
if (!testPassed.value) {
|
|
error('请先「测试连接」通过再保存')
|
|
return
|
|
}
|
|
const data: Record<string, unknown> = {
|
|
name: form.name,
|
|
region: form.region,
|
|
base_url: form.base_url,
|
|
service_dsn: form.service_dsn,
|
|
remark: form.remark,
|
|
enabled: form.enabled,
|
|
}
|
|
let path = 'apps/add'
|
|
if (isEditing.value && form.id) {
|
|
data.id = Number(form.id)
|
|
path = 'apps/update'
|
|
}
|
|
try {
|
|
await consoleApi(path, data)
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '失败')
|
|
}
|
|
}
|
|
|
|
function openAppConfig(a: AppItem) {
|
|
// 跳转到该应用的服务配置页(微信登录 / 支付 / 推送 / 基础信息)
|
|
router.push({ path: '/appconfig', query: { id: a.id, name: a.name || ('应用#' + a.id), region: a.region || '' } })
|
|
}
|
|
|
|
function delApp(id: number) {
|
|
if (!window.confirm(`删除注册应用 #${id} ?(不影响应用自身的库)`)) return
|
|
consoleApi('apps/del', { id })
|
|
.then(() => { success('已删除'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
// ---- 历史数据分析 ----
|
|
const analyzeModal = ref<HTMLDialogElement | null>(null)
|
|
const az = reactive({
|
|
appId: 0,
|
|
appLabel: '',
|
|
start: '',
|
|
end: '',
|
|
resultShow: false,
|
|
resultClass: '',
|
|
mode: '' as '' | 'progress' | 'error' | 'done',
|
|
// 进度
|
|
steps: [] as string[],
|
|
curIndex: 0,
|
|
curTotal: 0,
|
|
curName: '',
|
|
pct: 0,
|
|
// 结果
|
|
errMsg: '',
|
|
report: null as AnalyzeReport | null,
|
|
running: false,
|
|
btnText: '开始分析',
|
|
})
|
|
|
|
function azYmd(s: string) {
|
|
return parseInt((s || '').replace(/-/g, '')) || 0
|
|
}
|
|
|
|
function openAnalyze(id: number, name: string) {
|
|
az.appId = id
|
|
az.appLabel = '应用:' + name + ' (#' + id + ')'
|
|
az.start = ''
|
|
az.end = ''
|
|
az.resultShow = false
|
|
az.mode = ''
|
|
az.report = null
|
|
az.errMsg = ''
|
|
az.steps = []
|
|
az.curIndex = 0
|
|
az.curTotal = 0
|
|
az.curName = ''
|
|
az.pct = 0
|
|
az.running = false
|
|
az.btnText = '开始分析'
|
|
analyzeModal.value?.showModal()
|
|
}
|
|
|
|
function closeAnalyze() {
|
|
analyzeModal.value?.close()
|
|
}
|
|
|
|
// 进度事件 → 更新进度条 + 分步状态(当前步转圈,已完成步骤打勾)
|
|
function azUpdateProgress(ev: { index: number; total: number; name: string }) {
|
|
az.steps[ev.index - 1] = ev.name
|
|
// 把「完成(done)」当作隐含的第 total+1 步:最后一步只到 ~90%,真正完成才 100%
|
|
az.pct = Math.round((ev.index / (ev.total + 1)) * 100)
|
|
az.curIndex = ev.index
|
|
az.curTotal = ev.total
|
|
az.curName = ev.name
|
|
}
|
|
|
|
async function runAnalyze() {
|
|
const name = az.appLabel
|
|
const s = az.start || '最早'
|
|
const e = az.end || '至今'
|
|
if (!window.confirm(`将先删除【${name}】在 ${s} ~ ${e} 区间的历史统计数据,然后重新分析写入。\n\n确认继续?`)) return
|
|
|
|
az.running = true
|
|
az.btnText = '分析中...'
|
|
az.resultShow = true
|
|
az.resultClass = ''
|
|
az.mode = 'progress'
|
|
az.steps = []
|
|
az.curIndex = 0
|
|
az.curTotal = 0
|
|
az.curName = ''
|
|
az.pct = 0
|
|
az.report = null
|
|
az.errMsg = ''
|
|
|
|
const body = { app_id: az.appId, start_day: azYmd(az.start), end_day: azYmd(az.end) }
|
|
|
|
let doneReport: AnalyzeReport | null = null
|
|
let errMsg: string | null = null
|
|
let needLogout = false
|
|
|
|
function handleLine(line: string) {
|
|
line = (line || '').trim()
|
|
if (!line) return
|
|
let ev: any
|
|
try { ev = JSON.parse(line) } catch (_) { return }
|
|
if (ev.code === 18) { needLogout = true; return } // 未登录信封
|
|
if (ev.code !== undefined && ev.code !== 0) { errMsg = ev.msg || '请求被拒绝'; return }
|
|
if (ev.type === 'progress') { azUpdateProgress(ev) }
|
|
else if (ev.type === 'done') { doneReport = ev.report }
|
|
else if (ev.type === 'error') { errMsg = ev.msg }
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/console/api/apps/analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const reader = resp.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
while (true) {
|
|
const r = await reader.read()
|
|
if (r.done) break
|
|
buf += decoder.decode(r.value, { stream: true })
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop() as string // 末尾可能是半行,留到下次
|
|
for (let i = 0; i < lines.length; i++) handleLine(lines[i] as string)
|
|
}
|
|
handleLine(buf) // 处理最后残留(无换行的信封)
|
|
} catch (ex: any) {
|
|
errMsg = '网络异常: ' + ex.message
|
|
}
|
|
|
|
if (needLogout) { auth.logout(); router.push('/'); return }
|
|
az.running = false
|
|
az.btnText = '重新分析'
|
|
if (errMsg) {
|
|
az.resultClass = 'text-red-600'
|
|
az.mode = 'error'
|
|
az.errMsg = errMsg
|
|
return
|
|
}
|
|
if (!doneReport) {
|
|
az.resultClass = 'text-red-600'
|
|
az.mode = 'error'
|
|
az.errMsg = '分析中断,未收到完成事件'
|
|
return
|
|
}
|
|
az.resultClass = ''
|
|
az.mode = 'done'
|
|
az.report = doneReport
|
|
success('分析完成')
|
|
loadData() // 刷新列表
|
|
}
|
|
|
|
function notifyApp(id: number) {
|
|
if (!window.confirm(`向应用 #${id} 发送一条配置 reload 事件?`)) return
|
|
consoleApi<{ subject?: string }>('config/notify', { app_id: id, type: 'all', action: 'reload' })
|
|
.then((d: { subject?: string }) => { success('已发送 → ' + (d && d.subject)) })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|