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.
 
 
 
 
 
 

550 lines
26 KiB

<template>
<div>
<!-- ── 产品列表 ── -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-slate-800">产品列表</h2>
<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><th>功能</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="9" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!rows.length"><td colspan="9" class="text-center text-slate-400 py-8">暂无数据</td></tr>
<tr v-for="p in rows" :key="p.id">
<td class="font-mono text-sm">{{ hexId(p.id) }}</td>
<td>
<img v-if="p.productimage" :src="p.productimage" class="w-10 h-10 rounded object-cover" />
<div v-else class="w-10 h-10 rounded bg-slate-100 flex items-center justify-center text-slate-300">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</td>
<td class="font-medium">{{ p.devicename }}</td>
<td>{{ brands[p.brandid] || '' }}</td>
<td>
<div v-if="appNamesOf(p).length" class="flex flex-wrap gap-1">
<span v-for="n in appNamesOf(p)" :key="n" class="badge badge-ghost badge-sm">{{ n }}</span>
</div>
<span v-else class="text-slate-300 text-xs">未绑定</span>
</td>
<td><span class="badge badge-outline badge-sm">{{ deviceTypes[p.devicetype] || p.devicetype }}</span></td>
<td class="text-sm"><span v-if="p.version">{{ p.version }}</span><span v-else class="text-slate-300">-</span></td>
<td><span class="text-xs text-slate-500">{{ featureCount(p) }}/14</span></td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(p)">编辑</button>
<button class="btn btn-ghost btn-xs text-indigo-600" @click="showVersions(p)">版本</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(p)">删除</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="saveProduct">
<h3 class="text-sm font-semibold text-slate-500 mb-3">基本信息</h3>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">产品名称 *</span></label>
<input v-model="form.devicename" type="text" class="input input-bordered w-full" required />
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">所属品牌商</span></label>
<select v-model.number="form.brandid" class="select select-bordered w-full">
<option :value="0">请选择</option>
<option v-for="b in brandOptions" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">设备类型</span></label>
<select v-model.number="form.devicetype" class="select select-bordered w-full">
<option v-for="(label, val) in deviceTypeOptions" :key="val" :value="Number(val)">{{ label }}</option>
</select>
</div>
</div>
<!-- 绑定应用:决定该产品的数据算在哪个应用下,也决定品牌商/渠道商账号能选到哪些应用 -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">绑定应用</span>
<span class="label-text-alt text-slate-400">可多选;品牌商/渠道商账号的「应用」下拉只列出其产品绑定过的应用</span>
</label>
<div v-if="!appOptions.length" class="text-xs text-slate-400 border border-dashed border-slate-200 rounded-lg px-3 py-2">
暂无可选应用,请先到【SaaS 管理 → 应用中心】新建应用
</div>
<div v-else class="flex flex-wrap gap-2 border border-slate-200 rounded-lg px-3 py-2">
<label v-for="n in appOptions" :key="n" class="label cursor-pointer gap-2 py-1">
<input v-model="form.appnames" type="checkbox" :value="n" class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text">{{ n }}</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">扫描UUID</span></label>
<input v-model="form.scanuuid" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">设备码数量(每副)</span></label>
<select v-model.number="form.devicecodecount" class="select select-bordered w-full">
<option :value="1">单份 (1个/副)</option>
<option :value="2">双份 (2个/副)</option>
</select>
</div>
</div>
<!-- 渠道 -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">渠道方</span></label>
<select v-model="form.channel" class="select select-bordered w-full">
<option value="official">官方</option>
<option value="mobile">移动</option>
</select>
</div>
</div>
<div v-show="form.channel === 'mobile'" class="mb-4">
<div class="grid grid-cols-3 gap-4">
<div class="form-control"><label class="label"><span class="label-text font-medium">移动平台ID</span></label>
<input v-model="mobileExtra.platform_id" type="text" class="input input-bordered w-full" placeholder="移动平台分配的 ID" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">移动平台KEY</span></label>
<input v-model="mobileExtra.platform_key" type="text" class="input input-bordered w-full" placeholder="移动平台分配的 KEY" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">TUI(8位)</span></label>
<input v-model="mobileExtra.tui" type="text" maxlength="8" class="input input-bordered w-full" placeholder="CMEI 前缀,如 11113396" /></div>
</div>
</div>
<!-- 产品图片 -->
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">产品图片</span></label>
<div class="flex items-start gap-4">
<div class="flex-1">
<input v-model="form.productimage" type="text" class="input input-bordered w-full input-sm mb-2" placeholder="图片URL(可手动输入或上传)" />
<div class="flex items-center gap-2">
<input type="file" accept="image/*" class="file-input file-input-bordered file-input-sm flex-1" @change="onImagePick" />
<button type="button" class="btn btn-outline btn-sm btn-primary" :disabled="imgUploading" @click="uploadImage">
<span v-if="imgUploading" class="loading loading-spinner loading-xs"></span>上传
</button>
</div>
</div>
<div v-if="form.productimage">
<img :src="form.productimage" class="w-20 h-20 object-cover rounded-lg border" />
</div>
</div>
</div>
<!-- 绑定奖励 -->
<div class="divider text-sm text-slate-400">绑定奖励设置</div>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="form-control"><label class="label"><span class="label-text font-medium">VIP时长(天)</span></label>
<input v-model.number="form.bindrewardvpitime" type="number" min="0" class="input input-bordered w-full" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">翻译时长(分钟)</span></label>
<input v-model.number="form.bindrewardtranslate" type="number" min="0" class="input input-bordered w-full" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">会议时长(分钟)</span></label>
<input v-model.number="form.bindrewardmeeting" type="number" min="0" class="input input-bordered w-full" /></div>
</div>
<!-- 功能开关 -->
<div class="divider text-sm text-slate-400">功能开关</div>
<div class="grid grid-cols-3 gap-3 mb-6">
<label v-for="t in toggleFields" :key="t.key" class="flex items-center gap-2 cursor-pointer">
<input v-model="form.toggles[t.key]" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">{{ t.label }}</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>保存
</button>
</div>
</form>
</div>
</div>
<!-- ── 版本管理 ── -->
<div v-show="view === 'version'">
<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">版本管理 - {{ currentProduct?.devicename || '' }}</h2>
</div>
<!-- 当前版本信息 -->
<div v-if="currentProduct" class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-5">
<div class="flex items-center gap-6 text-sm flex-wrap">
<span class="text-slate-400">产品ID:</span><span class="font-mono">{{ hexId(currentProduct.id) }}</span>
<span class="text-slate-400">当前版本:</span><span class="badge badge-primary badge-sm">{{ currentProduct.version || '无' }}</span>
<span class="text-slate-400">版本ID:</span><span class="text-xs">{{ currentProduct.versionid || '无' }}</span>
<span class="text-slate-400">更新包:</span><span class="text-xs max-w-md truncate">{{ currentProduct.updatepackageaddress || '无' }}</span>
</div>
</div>
<!-- 添加新版本 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 mb-5">
<h3 class="text-sm font-semibold text-slate-700 mb-3">添加新版本</h3>
<form @submit.prevent="addVersion">
<div class="grid grid-cols-2 gap-4 mb-3">
<div class="form-control"><label class="label"><span class="label-text text-xs">版本号 *</span></label>
<input v-model="ver.version" type="text" class="input input-bordered input-sm w-full" placeholder="如 1.2.0" /></div>
<div class="form-control"><label class="label"><span class="label-text text-xs">描述</span></label>
<input v-model="ver.desc" type="text" class="input input-bordered input-sm w-full" placeholder="版本描述" /></div>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot" @change="onOtaPick" />
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>
<button type="submit" class="btn btn-primary btn-sm text-white ml-auto" :disabled="!canAddVersion || versionSubmitting">
<span v-if="versionSubmitting" class="loading loading-spinner loading-xs"></span>添加版本
</button>
</div>
</form>
</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></tr></thead>
<tbody>
<tr v-if="!versions.length"><td colspan="5" class="text-center text-slate-400 py-8">暂无版本</td></tr>
<tr v-for="v in versions" :key="v.id">
<td>{{ v.id }}</td>
<td>
<span class="badge badge-sm" :class="isCurrentVer(v) ? 'badge-primary' : 'badge-outline'">{{ v.version }}</span>
<span v-if="isCurrentVer(v)" class="badge badge-success badge-xs ml-1">当前</span>
</td>
<td class="text-sm">{{ v.description || '' }}</td>
<td class="text-xs text-slate-400 max-w-xs truncate">{{ v.updatepackageaddress || '' }}</td>
<td>
<div class="flex gap-1">
<span v-if="isCurrentVer(v)" class="text-xs text-slate-400">使用中</span>
<template v-else>
<button class="btn btn-ghost btn-xs text-indigo-600" @click="useVersion(v)">设为当前</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delVersion(v)">删除</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '产品管理' })
interface Product {
id: number; devicename: string; brandid: number; devicetype: number
scanuuid: string; devicecodecount: number; productimage: string
channel: string; channelextra: string
appnames?: string // 绑定应用名 CSV(空=未绑定)
bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number
version?: string; versionid?: number; updatepackageaddress?: string
[key: string]: unknown
}
interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number }
const { webApi, consoleApi, uploadFile } = useApi()
const { success, error, warn } = useToast()
const deviceTypes: Record<number, string> = { 0: '未知', 1: '经典蓝牙', 2: 'BLE双端', 3: 'BLE单端', 4: '录音笔', 5: '智能眼镜', 6: '激活码卡片' }
const deviceTypeOptions: Record<number, string> = { 0: '未知', 1: '经典蓝牙耳机', 2: 'BLE双端', 3: 'BLE单端', 4: '智能录音笔', 5: '智能眼镜', 6: '激活码卡片' }
const toggleFields = [
{ key: 'iscanbindagain', label: '可重复绑定奖励' },
{ key: 'iscontinuouschat', label: '连续对话' },
{ key: 'eqenable', label: 'EQ 均衡器' },
{ key: 'keyenable', label: '按键控制' },
{ key: 'workmodeenable', label: '工作模式' },
{ key: 'callmodeenable', label: '通话模式' },
{ key: 'ledenable', label: 'LED 灯' },
{ key: 'finddeviceenable', label: '查找设备' },
{ key: 'noisereductionenable', label: '降噪' },
{ key: 'shutdownenable', label: '定时关机' },
{ key: 'showcasebattery', label: '充电盒电量' },
{ key: 'wakeupenable', label: '唤醒控制' },
{ key: 'broadcastpeertranslate', label: '播报对方翻译' },
{ key: 'hideaiassistant', label: '隐藏AI助手' },
]
const view = ref<'list' | 'form' | 'version'>('list')
const rows = ref<Product[]>([])
const brands = ref<Record<number, string>>({})
const brandOptions = ref<{ id: number; brand: string }[]>([])
// 可绑定的应用名(应用中心 app_product.name);只有超管/管理员进得来产品页,直接调注册表接口取。
const appOptions = ref<string[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
function emptyToggles(): Record<string, boolean> {
const o: Record<string, boolean> = {}
toggleFields.forEach((t) => { o[t.key] = false })
return o
}
const form = reactive({
id: 0, devicename: '', brandid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1,
productimage: '', channel: 'official', appnames: [] as string[],
bindrewardvpitime: 0, bindrewardtranslate: 0, bindrewardmeeting: 0,
toggles: emptyToggles() as Record<string, boolean>,
})
const mobileExtra = reactive({ platform_id: '', platform_key: '', tui: '' })
function hexId(id: number) { return id ? Number(id).toString(16).toUpperCase() : '' }
function appNamesOf(p: Product): string[] {
return String(p.appnames || '').split(',').map((s) => s.trim()).filter(Boolean)
}
function featureCount(p: Product) { return toggleFields.filter((t) => !!p[t.key]).length }
// ── 列表 ──
async function loadData() {
loading.value = true
try {
const fd = await webApi<{ brands: { id: number; brand: string }[] }>('getbrands', {})
const fmap: Record<number, string> = {}
;(fd?.brands ?? []).forEach((b) => { fmap[b.id] = b.brand })
brands.value = fmap
const pd = await webApi<{ products: Product[] }>('getproducts', {})
rows.value = pd?.products ?? []
} catch (e: any) {
error(e?.message ?? '获取产品列表失败')
rows.value = []
} finally {
loading.value = false
}
}
async function loadBrands() {
try {
const fd = await webApi<{ brands: { id: number; brand: string }[] }>('getbrands', {})
brandOptions.value = fd?.brands ?? []
} catch { brandOptions.value = [] }
}
// 应用候选取自应用中心(console/api/products/list,即 app_product 表),与部署无关,只按应用名绑。
async function loadAppOptions() {
try {
const list = await consoleApi<{ name: string; enabled?: boolean }[]>('products/list', {})
appOptions.value = (list ?? []).map((a) => a.name).filter(Boolean)
} catch { appOptions.value = [] }
}
function showList() { view.value = 'list'; loadData() }
function showForm(p?: Product) {
loadBrands()
loadAppOptions()
if (p) {
isEditing.value = true
form.id = p.id
form.devicename = p.devicename || ''
form.brandid = p.brandid || 0
form.devicetype = p.devicetype || 0
form.scanuuid = p.scanuuid || ''
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
form.productimage = p.productimage || ''
form.channel = p.channel || 'official'
form.appnames = appNamesOf(p)
form.bindrewardvpitime = p.bindrewardvpitime || 0
form.bindrewardtranslate = p.bindrewardtranslate || 0
form.bindrewardmeeting = p.bindrewardmeeting || 0
let extra: any = {}
try { extra = p.channelextra ? JSON.parse(p.channelextra) : {} } catch { extra = {} }
mobileExtra.platform_id = extra.platform_id || ''
mobileExtra.platform_key = extra.platform_key || ''
mobileExtra.tui = extra.tui || ''
toggleFields.forEach((t) => { form.toggles[t.key] = !!p[t.key] })
} else {
isEditing.value = false
form.id = 0
form.devicename = ''
form.brandid = 0
form.devicetype = 0
form.scanuuid = ''
form.devicecodecount = 1
form.productimage = ''
form.channel = 'official'
form.appnames = []
form.bindrewardvpitime = 0
form.bindrewardtranslate = 0
form.bindrewardmeeting = 0
mobileExtra.platform_id = ''
mobileExtra.platform_key = ''
mobileExtra.tui = ''
toggleFields.forEach((t) => { form.toggles[t.key] = false })
}
view.value = 'form'
}
async function saveProduct() {
submitting.value = true
try {
let channelextra = ''
if (form.channel === 'mobile') {
channelextra = JSON.stringify({ platform_id: mobileExtra.platform_id || '', platform_key: mobileExtra.platform_key || '', tui: mobileExtra.tui || '' })
}
const product: Record<string, unknown> = {
devicename: form.devicename,
brandid: Number(form.brandid) || 0,
devicetype: Number(form.devicetype) || 0,
scanuuid: form.scanuuid,
devicecodecount: Number(form.devicecodecount) || 1,
productimage: form.productimage,
channel: form.channel,
channelextra,
appnames: form.appnames.join(','),
bindrewardvpitime: Number(form.bindrewardvpitime) || 0,
bindrewardtranslate: Number(form.bindrewardtranslate) || 0,
bindrewardmeeting: Number(form.bindrewardmeeting) || 0,
}
toggleFields.forEach((t) => { product[t.key] = form.toggles[t.key] })
if (isEditing.value) product.id = Number(form.id)
await webApi(isEditing.value ? 'updateproduct' : 'addproduct', { product })
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delProduct(p: Product) {
if (!window.confirm(`确定删除产品 "${p.devicename}" 吗?`)) return
webApi('delproduct', { id: p.id })
.then(() => { success('已删除'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// ── 图片上传 ──
const imgFile = ref<File | null>(null)
const imgUploading = ref(false)
function onImagePick(e: Event) {
imgFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
async function uploadImage() {
if (!imgFile.value) { warn('请先选择图片'); return }
imgUploading.value = true
try {
const path = form.id ? `product/${form.id}/images/` : 'product/images/'
const url = await uploadFile(imgFile.value, path)
form.productimage = url
success('上传成功')
} catch (e: any) {
error(e?.message ?? '上传失败')
} finally {
imgUploading.value = false
}
}
// ── 版本管理 ──
const currentProduct = ref<Product | null>(null)
const versions = ref<Version[]>([])
const versionSubmitting = ref(false)
const ver = reactive({ version: '', desc: '', isuse: false })
const otaFile = ref<File | null>(null)
const canAddVersion = computed(() => !!ver.version.trim() && !!otaFile.value)
function isCurrentVer(v: Version) { return !!currentProduct.value?.versionid && v.id === currentProduct.value.versionid }
async function showVersions(p: Product) {
view.value = 'version'
currentProduct.value = null
versions.value = []
ver.version = ''; ver.desc = ''; ver.isuse = false; otaFile.value = null
try {
const d = await webApi<{ product: Product }>('getproduct', { id: p.id })
currentProduct.value = d?.product ?? p
} catch { currentProduct.value = p }
await loadVersions(p.id)
}
async function loadVersions(pid: number) {
try {
const d = await webApi<{ versions: Version[] }>('getproductversions', { pid })
versions.value = d?.versions ?? []
} catch { versions.value = [] }
}
function onOtaPick(e: Event) {
otaFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
async function switchCurrentVersion(v: Version): Promise<boolean> {
if (!currentProduct.value) { error('产品信息未加载'); return false }
const product = { ...currentProduct.value, versionid: v.id }
try {
await webApi('updateproduct', { product })
const d = await webApi<{ product: Product }>('getproduct', { id: product.id })
currentProduct.value = d?.product ?? product
return true
} catch (e: any) {
error(e?.message ?? '切换失败')
return false
}
}
function useVersion(v: Version) {
if (!window.confirm(`确定将版本 "${v.version}" 设为当前使用版本?`)) return
switchCurrentVersion(v).then((ok) => {
if (ok) { success('切换成功'); if (currentProduct.value) loadVersions(currentProduct.value.id) }
})
}
async function addVersion() {
const pid = currentProduct.value?.id
if (!pid) { error('产品ID不存在'); return }
if (!ver.version.trim()) { warn('请输入版本号'); return }
if (!otaFile.value) { warn('请选择固件文件'); return }
versionSubmitting.value = true
try {
// 固件包:目录传 product/<产品id>/,后端强制置于 yunyan/ 根下 → yunyan/product/<id>/<文件名>
const url = await uploadFile(otaFile.value, `product/${pid}/`)
const d = await webApi<{ version: Version }>('addproductversion', {
version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url },
isuse: false,
})
success('添加成功')
if (ver.isuse && d?.version) await switchCurrentVersion(d.version)
ver.version = ''; ver.desc = ''; ver.isuse = false; otaFile.value = null
await loadVersions(pid)
} catch (e: any) {
error(e?.message ?? '添加失败')
} finally {
versionSubmitting.value = false
}
}
function delVersion(v: Version) {
if (currentProduct.value?.versionid === v.id) { error('不能删除当前使用中的版本'); return }
if (!window.confirm('删除此版本?')) return
webApi('delproductversion', { id: v.id })
.then(() => { success('已删除'); if (currentProduct.value) loadVersions(currentProduct.value.id) })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadData)
</script>