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.
739 lines
29 KiB
739 lines
29 KiB
<template>
|
|
<div>
|
|
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ -->
|
|
<div v-if="view === 'list'">
|
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
|
|
<p class="text-xs text-slate-400 max-w-3xl">
|
|
会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示
|
|
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
|
|
</p>
|
|
<button class="btn btn-primary btn-sm text-white shrink-0" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
|
|
</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()">← 返回列表</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, watch } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions'
|
|
|
|
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 } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名由「会议配置」页统一下发,
|
|
// 这里不再自己拉一份应用列表——同一页上三块配置共用一个应用选择器,各拉各的会出现
|
|
// 「转写配的是 A、模板改的是 B」这种对不上的状态。
|
|
const props = defineProps<{ appName: string }>()
|
|
const scopeApp = computed(() => props.appName)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 切换应用要退回列表:详情停在上一个应用的模板上,保存时会带着新应用名写过去。
|
|
watch(scopeApp, () => {
|
|
view.value = 'list'
|
|
current.value = null
|
|
curLang.value = ''
|
|
detailCache.clear()
|
|
loadData()
|
|
})
|
|
|
|
onMounted(() => { 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>
|
|
|