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

<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()">&larr; 返回</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>