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.
 
 
 
 
 
 

761 lines
30 KiB

<template>
<div>
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ -->
<div v-if="view === 'list'">
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议模板</h2>
<p class="text-xs text-slate-400 mt-1">
会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40" @change="loadData">
<option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
</div>
</div>
<!-- 筛选条 -->
<div class="panel p-3 mb-5">
<div class="flex flex-wrap items-center gap-2">
<select v-model="catFilter" class="select select-bordered select-sm w-40">
<option value="">全部分类</option>
<option v-for="c in catOptions" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="typeFilter" class="select select-bordered select-sm w-40">
<option value="">全部类型</option>
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="kwFilter" class="input input-bordered input-sm w-56" placeholder="搜索标题 / tid / 描述" />
<span class="ml-auto text-xs text-slate-400">
{{ cards.length }} 个模板 · {{ rows.length }} 个语言版本 · 命中 {{ filteredCards.length }}
</span>
</div>
</div>
<div v-if="loading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else-if="!filteredCards.length" class="panel py-16 text-center text-slate-400 text-sm">
{{ rows.length ? '当前筛选下没有匹配的模板' : '暂无模板,点击右上角「新增模板」创建' }}
</div>
<div v-else class="tpl-grid">
<div v-for="c in filteredCards" :key="c.tid" class="tpl-card" @click="openDetail(c)">
<div class="tc-top">
<div class="tc-ico">
<img v-if="isImg(c.primary.icon)" :src="c.primary.icon" alt="" />
<span v-else>{{ initials(c) }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="tc-title" :title="c.primary.title">{{ c.primary.title || '(未命名)' }}</div>
<div class="tc-tid" :title="c.tid">{{ c.tid || '无 tid' }}</div>
</div>
</div>
<div class="tc-desc">{{ c.primary.description || '(无描述)' }}</div>
<div class="tc-tags">
<span v-if="c.primary.ttype" class="chip chip-type">{{ c.primary.ttype }}</span>
<span v-for="(tag, i) in tagList(c.primary.tags)" :key="i" class="chip">{{ tag }}</span>
</div>
<div class="tc-foot">
<span class="chip chip-lang">{{ c.langs.length }} 种语言</span>
<span v-if="!c.hasPrimary" class="chip chip-warn">缺 {{ PRIMARY_LANG }},按 {{ c.primary.language }} 展示</span>
<span class="ml-auto tc-go">查看 →</span>
</div>
</div>
</div>
</div>
<!-- ══════════════ 详情视图:一个 tid 的全部语言版本 ══════════════ -->
<div v-else-if="view === 'detail' && current">
<div class="flex flex-wrap items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm" @click="backToList()">&larr; 返回列表</button>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-slate-800 truncate">{{ form.title || current.tid }}</h2>
<div class="text-xs text-slate-400 font-mono">{{ current.tid }} · {{ current.langs.length }} 种语言</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn btn-sm" @click="openSync()">同步到其他语言</button>
<button class="btn btn-sm" @click="openAddLang()">+ 添加语言</button>
<button class="btn btn-ghost btn-sm text-red-500" @click="delWholeTid()">删除整组</button>
</div>
</div>
<!-- 语言切换条 -->
<div class="panel p-3 mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-slate-400 shrink-0">语言版本</span>
<button
v-for="l in current.langs"
:key="l.language"
class="lang-pill"
:class="{ on: l.language === curLang, primary: isPrimary(l.language) }"
:title="langName(l.language)"
@click="switchLang(l.language)"
>
{{ l.language }}
<span class="lp-name">{{ langName(l.language) }}</span>
</button>
</div>
</div>
<!-- 当前语言内容 -->
<div class="panel p-6">
<div v-if="detailLoading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<form v-else @submit.prevent="saveCurrent()">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<span class="badge badge-outline">{{ curLang }}</span>
<span class="text-sm text-slate-500">{{ langName(curLang) }}</span>
<span v-if="isPrimary(curLang)" class="chip chip-primary">主语言</span>
<span class="text-xs text-slate-300 font-mono ml-2">id {{ form.id }}</span>
<div class="ml-auto flex gap-2">
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="delCurrentLang()">
删除此语言版本
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="form.title" type="text" class="input input-bordered w-full" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">类型 (ttype)</span></label>
<input v-model="form.ttype" type="text" class="input input-bordered w-full" list="ttypelist" />
<datalist id="ttypelist">
<option v-for="t in typeOptions" :key="t" :value="t" />
</datalist>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">标签 (tags)</span></label>
<input v-model="form.tags" type="text" class="input input-bordered w-full" placeholder="逗号分隔" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">图标 (icon)</span></label>
<input v-model="form.icon" type="text" class="input input-bordered w-full" placeholder="图标 URL 或名称" />
</div>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">大纲 (outline)</span></label>
<textarea v-model="form.outline" class="textarea textarea-bordered w-full font-mono text-sm" rows="5"></textarea>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">模板内容 (template)</span></label>
<textarea v-model="form.template" class="textarea textarea-bordered w-full font-mono text-sm" rows="16"></textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存当前语言</button>
<button type="button" class="btn btn-ghost" :disabled="submitting" @click="reloadCurrent()">放弃修改</button>
</div>
</form>
</div>
</div>
<!-- ══════════════ 同步弹窗 ══════════════ -->
<div v-if="syncOpen" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-base mb-1">同步到其他语言</h3>
<p class="text-xs text-slate-400 mb-4">
以 <span class="font-mono">{{ curLang }}</span> 版本为源,把勾选字段覆盖写入所选语言版本。
标题/描述/大纲/模板属译文,覆盖后目标语言的译文会丢失,请谨慎勾选。
</p>
<div class="mb-4">
<div class="text-sm font-medium text-slate-600 mb-2">同步哪些字段</div>
<div class="flex flex-wrap gap-x-5 gap-y-2">
<label v-for="f in SYNC_FIELDS" :key="f.key" class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="syncFields" type="checkbox" :value="f.key" class="checkbox checkbox-sm checkbox-primary" />
<span>{{ f.label }}</span>
<span v-if="f.translated" class="chip chip-warn">译文</span>
</label>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-medium text-slate-600">同步到哪些语言</span>
<span class="text-xs text-slate-400">已选 {{ syncLangs.length }} / {{ otherLangs.length }}</span>
<button type="button" class="btn btn-ghost btn-xs ml-auto" @click="syncLangs = otherLangs.slice()">全选</button>
<button type="button" class="btn btn-ghost btn-xs" @click="syncLangs = []">清空</button>
</div>
<div class="lang-picker">
<label v-for="l in otherLangs" :key="l" class="lang-opt">
<input v-model="syncLangs" type="checkbox" :value="l" class="checkbox checkbox-xs checkbox-primary" />
<span class="lo-code">{{ l }}</span>
<span class="lo-name">{{ langName(l) }}</span>
</label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="syncOpen = false">取消</button>
<button
class="btn btn-primary text-white"
:disabled="submitting || !syncFields.length || !syncLangs.length"
@click="doSync()"
>
同步到 {{ syncLangs.length }} 个语言
</button>
</div>
</div>
</div>
<!-- ══════════════ 添加语言弹窗 ══════════════ -->
<div v-if="addLangOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">为「{{ current?.tid }}」添加语言版本</h3>
<p class="text-xs text-slate-400 mb-4">
新版本以当前 <span class="font-mono">{{ curLang }}</span> 版本的内容为初稿,创建后再逐项译成目标语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">语言码 *</span></label>
<input
v-model="newLang"
class="input input-bordered w-full"
list="addlanglist"
placeholder="BCP-47,如 en-US"
/>
<datalist id="addlanglist">
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.name }}</option>
</datalist>
<span v-if="newLangErr" class="text-xs text-rose-600 mt-1">{{ newLangErr }}</span>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="addLangOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doAddLang()">创建</button>
</div>
</div>
</div>
<!-- ══════════════ 新增模板弹窗 ══════════════ -->
<div v-if="createOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">新增模板</h3>
<p class="text-xs text-slate-400 mb-4">
先建出主语言版本,创建后在详情页补齐内容,再用「添加语言」扩展其他语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">模板标识 tid *</span></label>
<input v-model="createTid" class="input input-bordered w-full" placeholder="如 Meeting-WeeklyMeeting" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="createTitle" class="input input-bordered w-full" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">语言</span></label>
<input v-model="createLang" class="input input-bordered w-full" list="addlanglist" />
</div>
<span v-if="createErr" class="text-xs text-rose-600 mt-2 block">{{ createErr }}</span>
<div class="modal-action">
<button class="btn btn-ghost" @click="createOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doCreate()">创建</button>
</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'
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions'
definePageMeta({ title: '会议模板' })
interface MeetTemplate {
id: number
title: string
tid: string
language: string
ttype: string
tags: string
icon: string
description: string
outline: string
template: string
}
// 一个 tid 的聚合视图:主语言版本用于卡面展示,langs 是该 tid 下的全部语言版本。
interface TplCard {
tid: string
primary: MeetTemplate // 主语言(中文)版本;没有则回退到第一个可用语言
hasPrimary: boolean
langs: MeetTemplate[]
}
const PRIMARY_LANG = 'zh-CN'
// 可同步字段。translated=true 的属各语言译文,同步等于用源语言覆盖,默认不勾。
const SYNC_FIELDS = [
{ key: 'ttype', label: '类型 ttype' },
{ key: 'tags', label: '标签 tags' },
{ key: 'icon', label: '图标 icon' },
{ key: 'title', label: '标题 title', translated: true },
{ key: 'description', label: '描述 description', translated: true },
{ key: 'outline', label: '大纲 outline', translated: true },
{ key: 'template', label: '模板内容 template', translated: true },
] as const
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名来源同 appversion.vue:
// products/list ∪ apps/list,无权读时走 getmyapps。用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 空列表,页面提示先选应用 */ }
}
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
}
const view = ref<'list' | 'detail'>('list')
const rows = ref<MeetTemplate[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const submitting = ref(false)
const catFilter = ref('')
const typeFilter = ref('')
const kwFilter = ref('')
const current = ref<TplCard | null>(null)
const curLang = ref('')
// 详情大字段(outline/template)列表接口不返回,按 id 单独拉;缓存避免来回切语言重复请求。
const detailCache = new Map<number, MeetTemplate>()
const form = reactive<MeetTemplate>({
id: 0, title: '', tid: '', language: '', ttype: '', tags: '',
icon: '', description: '', outline: '', template: '',
})
const syncOpen = ref(false)
const syncFields = ref<string[]>([])
const syncLangs = ref<string[]>([])
const addLangOpen = ref(false)
const newLang = ref('')
const newLangErr = ref('')
const createOpen = ref(false)
const createTid = ref('')
const createTitle = ref('')
const createLang = ref(PRIMARY_LANG)
const createErr = ref('')
function tagList(s?: string) {
return (s || '').split(',').map((x) => x.trim()).filter(Boolean)
}
function isImg(s?: string) {
return /^https?:\/\//i.test(s || '')
}
function isPrimary(lang: string) {
return lang === PRIMARY_LANG
}
// tid 惯例是 `分类-名称`(General-Meeting / Speech-Lecture),取前缀当分类。
function catOf(tid: string) {
const i = (tid || '').indexOf('-')
return i > 0 ? tid.slice(0, i) : '其他'
}
function initials(c: TplCard) {
const t = (c.primary.title || c.tid || '?').trim()
return t.slice(0, 1).toUpperCase()
}
// ── 按 tid 聚合成卡片 ──
const cards = computed<TplCard[]>(() => {
const byTid = new Map<string, MeetTemplate[]>()
rows.value.forEach((t) => {
const k = t.tid || ''
if (!byTid.has(k)) byTid.set(k, [])
byTid.get(k)!.push(t)
})
const out: TplCard[] = []
byTid.forEach((items, tid) => {
const langs = items.slice().sort((a, b) => {
// 主语言永远排最前,其余按语言码字典序
if (isPrimary(a.language) !== isPrimary(b.language)) return isPrimary(a.language) ? -1 : 1
return (a.language || '') < (b.language || '') ? -1 : 1
})
const zh = langs.find((t) => isPrimary(t.language))
out.push({ tid, primary: zh || langs[0]!, hasPrimary: !!zh, langs })
})
return out.sort((a, b) => (a.tid < b.tid ? -1 : 1))
})
const catOptions = computed(() =>
Array.from(new Set(cards.value.map((c) => catOf(c.tid)))).sort(),
)
const typeOptions = computed(() =>
Array.from(new Set(rows.value.map((t) => t.ttype).filter(Boolean))).sort(),
)
const filteredCards = computed(() => {
const kw = kwFilter.value.trim().toLowerCase()
return cards.value.filter((c) => {
if (catFilter.value && catOf(c.tid) !== catFilter.value) return false
if (typeFilter.value && c.primary.ttype !== typeFilter.value) return false
if (!kw) return true
return `${c.tid} ${c.primary.title} ${c.primary.description}`.toLowerCase().includes(kw)
})
})
// 当前 tid 下除当前语言外的其它语言码(同步/添加语言用)
const otherLangs = computed(() =>
(current.value?.langs ?? []).map((t) => t.language).filter((l) => l && l !== curLang.value),
)
const addableLangs = computed(() => {
const has = new Set((current.value?.langs ?? []).map((t) => t.language))
return ALL_LANG_OPTIONS.filter((o) => !has.has(o.code))
})
function fillForm(t: Partial<MeetTemplate>) {
form.id = t.id || 0
form.title = t.title || ''
form.tid = t.tid || ''
form.language = t.language || ''
form.ttype = t.ttype || ''
form.tags = t.tags || ''
form.icon = t.icon || ''
form.description = t.description || ''
form.outline = t.outline || ''
form.template = t.template || ''
}
async function loadData() {
loading.value = true
try {
if (!scopeApp.value) { rows.value = []; loading.value = false; return }
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', { app_name: scopeApp.value })
rows.value = d?.templates ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
rows.value = []
} finally {
loading.value = false
}
}
function backToList() {
view.value = 'list'
current.value = null
curLang.value = ''
loadData()
}
function openDetail(c: TplCard) {
current.value = c
view.value = 'detail'
switchLang(c.primary.language)
}
// 切到某个语言版本:先用列表里的浅字段占位,再拉详情补 outline/template。
async function switchLang(lang: string) {
const t = current.value?.langs.find((x) => x.language === lang)
if (!t) return
curLang.value = lang
fillForm(t)
const cached = detailCache.get(t.id)
if (cached) {
fillForm(cached)
return
}
detailLoading.value = true
try {
const detail = await webApi<MeetTemplate>('getmeettemplate', { id: t.id })
if (detail) {
detailCache.set(t.id, detail)
// 只有仍停在这个语言上才回填,避免慢请求覆盖用户已切走的内容
if (curLang.value === lang) fillForm(detail)
}
} catch (e: any) {
error(e?.message ?? '加载详情失败')
} finally {
detailLoading.value = false
}
}
function reloadCurrent() {
detailCache.delete(form.id)
switchLang(curLang.value)
}
async function saveCurrent() {
submitting.value = true
try {
await webApi('updatemeettemplate', {
app_name: scopeApp.value,
id: Number(form.id),
title: form.title.trim(),
tid: form.tid.trim(),
language: form.language.trim(),
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
detailCache.delete(form.id)
success('已保存')
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
// 列表数据变动后,重新拉列表并把 current 指向同一 tid 的新聚合结果。
async function refreshCurrentCard(preferLang?: string) {
const tid = current.value?.tid
const lang = preferLang || curLang.value
await loadData()
if (!tid) return
const c = cards.value.find((x) => x.tid === tid)
if (!c) {
backToList()
return
}
current.value = c
const target = c.langs.find((x) => x.language === lang) ? lang : c.primary.language
await switchLang(target)
}
// ── 同步 ──
function openSync() {
syncFields.value = SYNC_FIELDS.filter((f) => !('translated' in f && f.translated)).map((f) => f.key)
syncLangs.value = otherLangs.value.slice()
syncOpen.value = true
}
async function doSync() {
submitting.value = true
try {
const res = await webApi<{ updated: number }>('syncmeettemplate', {
app_name: scopeApp.value,
src_id: Number(form.id),
fields: syncFields.value,
languages: syncLangs.value,
})
syncOpen.value = false
detailCache.clear()
success(`已同步 ${res?.updated ?? 0} 个语言版本`)
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '同步失败')
} finally {
submitting.value = false
}
}
// ── 添加语言 ──
function openAddLang() {
newLang.value = ''
newLangErr.value = ''
addLangOpen.value = true
}
async function doAddLang() {
const lang = newLang.value.trim()
newLangErr.value = ''
if (!lang) {
newLangErr.value = '请填写语言码'
return
}
if (current.value?.langs.some((t) => t.language === lang)) {
newLangErr.value = `该 tid 下已存在 ${lang} 版本`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', {
app_name: scopeApp.value,
title: form.title.trim(),
tid: form.tid.trim(),
language: lang,
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
addLangOpen.value = false
success(`已创建 ${lang} 版本`)
await refreshCurrentCard(lang)
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// ── 新增模板 ──
function openCreate() {
createTid.value = ''
createTitle.value = ''
createLang.value = PRIMARY_LANG
createErr.value = ''
createOpen.value = true
}
async function doCreate() {
const tid = createTid.value.trim()
const title = createTitle.value.trim()
const lang = createLang.value.trim()
createErr.value = ''
if (!tid || !title) {
createErr.value = 'tid 与标题均必填'
return
}
if (cards.value.some((c) => c.tid === tid)) {
createErr.value = `tid「${tid}」已存在,请到列表中打开它再「添加语言」`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', { app_name: scopeApp.value, tid, title, language: lang })
createOpen.value = false
success('已创建')
await loadData()
const c = cards.value.find((x) => x.tid === tid)
if (c) openDetail(c)
} catch (e: any) {
createErr.value = e?.message ?? '创建失败'
} finally {
submitting.value = false
}
}
// ── 删除 ──
async function delCurrentLang() {
if (!current.value) return
if (current.value.langs.length <= 1) {
error('这是最后一个语言版本,请用「删除整组」')
return
}
if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: [Number(form.id)] })
detailCache.delete(form.id)
success('已删除')
await refreshCurrentCard(PRIMARY_LANG)
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
async function delWholeTid() {
const c = current.value
if (!c) return
if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: c.langs.map((t) => Number(t.id)) })
detailCache.clear()
success('已删除整组')
backToList()
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
await loadScopeApps()
await loadData()
})
</script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
.tpl-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); }
.tpl-card {
background:#fff; border:1px solid #eef2f7; border-radius:16px; padding:16px;
cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:10px;
box-shadow:0 1px 2px rgba(0,0,0,0.04);
}
.tpl-card:hover { border-color:#c7d2fe; box-shadow:0 6px 18px rgba(15,23,42,0.08); transform:translateY(-1px); }
.tc-top { display:flex; align-items:flex-start; gap:11px; }
.tc-ico {
width:40px; height:40px; border-radius:11px; flex-shrink:0; overflow:hidden;
background:linear-gradient(135deg,#eef2ff,#e0e7ff); color:#4338ca;
display:flex; align-items:center; justify-content:center; font-weight:700; font-size:17px;
}
.tc-ico img { width:100%; height:100%; object-fit:cover; }
.tc-title { font-weight:600; font-size:14.5px; color:#1e293b; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-tid { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-desc {
font-size:12.5px; color:#64748b; line-height:1.55; min-height:39px;
display:-webkit-box; -webkit-line-clamp:2; line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.tc-tags { display:flex; flex-wrap:wrap; gap:4px; min-height:20px; }
.tc-foot { display:flex; align-items:center; gap:6px; flex-wrap:wrap; padding-top:9px; border-top:1px solid #f8fafc; }
.tc-go { font-size:11.5px; color:#6366f1; font-weight:600; }
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.chip-type { background:#e0e7ff; color:#4338ca; }
.chip-lang { background:#cffafe; color:#0e7490; }
.chip-warn { background:#fef3c7; color:#b45309; }
.chip-primary { background:#d1fae5; color:#047857; }
.lang-pill {
display:inline-flex; align-items:center; gap:5px; font-size:11.5px; padding:3px 9px;
border-radius:999px; border:1px solid #e2e8f0; background:#f8fafc; color:#64748b;
cursor:pointer; transition:all .12s; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
}
.lang-pill:hover { border-color:#c7d2fe; color:#4338ca; }
.lang-pill.on { background:#4338ca; border-color:#4338ca; color:#fff; }
.lang-pill.primary:not(.on) { background:#ecfdf5; border-color:#a7f3d0; color:#047857; }
.lp-name { font-family:inherit; opacity:.75; font-size:11px; }
.lang-pill.on .lp-name { opacity:.9; }
.lang-picker {
display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:3px;
max-height:260px; overflow-y:auto; border:1px solid #eef2f7; border-radius:10px; padding:8px;
}
.lang-opt { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:6px; cursor:pointer; font-size:12px; }
.lang-opt:hover { background:#f8fafc; }
.lo-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#475569; }
.lo-name { color:#94a3b8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>